feat(stepper): implement multi-step wizard for appointment confirmation process
This commit is contained in:
@@ -11,6 +11,7 @@ import { useAppointmentInsurance } from '../../hooks/useAppointmentInsurance';
|
||||
import Modal from '../ui/Modal';
|
||||
import PriceInput from '../ui/PriceInput';
|
||||
import SearchableSelect from '../ui/SearchableSelect';
|
||||
import Stepper from '../ui/Stepper';
|
||||
|
||||
/** همان چهار روشِ SessionPayment::METHODS در بکاند. */
|
||||
const METHOD_OPTIONS = [
|
||||
@@ -49,6 +50,20 @@ interface Props {
|
||||
queryKey?: unknown[];
|
||||
}
|
||||
|
||||
/**
|
||||
* مراحلِ قطعی کردن: اول «چقدر»، بعد «چطور»، آخر «تأیید».
|
||||
*
|
||||
* فرم قبلاً یک صفحهٔ بلند بود — بیمه، جدول هزینه، ردیفهای پرداخت و خلاصه با هم — و
|
||||
* با دو روش پرداخت از ارتفاع صفحه بلندتر میشد.
|
||||
*/
|
||||
const CONFIRM_STEPS = [
|
||||
{ key: 'cost', title: 'بیمه و هزینه' },
|
||||
{ key: 'payment', title: 'پرداخت' },
|
||||
{ key: 'review', title: 'تأیید' },
|
||||
] as const;
|
||||
|
||||
type ConfirmStepKey = (typeof CONFIRM_STEPS)[number]['key'];
|
||||
|
||||
/** یک ردیفِ پرداخت در تسویهٔ چندروشی. */
|
||||
interface PaymentRow {
|
||||
id: number;
|
||||
@@ -99,6 +114,7 @@ export default function ConfirmAppointmentModal({
|
||||
const [rows, setRows] = useState<PaymentRow[]>([makeRow()]);
|
||||
/** تا وقتی کاربر مبلغ را دست نزده، ردیفِ اول با کل مبلغ پر میماند. */
|
||||
const [touched, setTouched] = useState(false);
|
||||
const [stepIdx, setStepIdx] = useState(0);
|
||||
|
||||
// وقتی صفحهی میزبان نوبت را ندارد (مثل ردیف لیست) خودمان جزئیات را میگیریم:
|
||||
// مبلغ ویزیت و قیمت سرویسها فقط در detail هستند.
|
||||
@@ -225,6 +241,7 @@ export default function ConfirmAppointmentModal({
|
||||
nextId.current = 1;
|
||||
setRows([makeRow()]);
|
||||
setTouched(false);
|
||||
setStepIdx(0);
|
||||
}
|
||||
|
||||
function patchRow(id: number, patch: Partial<PaymentRow>) {
|
||||
@@ -254,6 +271,8 @@ export default function ConfirmAppointmentModal({
|
||||
}
|
||||
|
||||
const loading = detailQuery.isLoading && !appointment;
|
||||
const currentStep: ConfirmStepKey = CONFIRM_STEPS[Math.min(stepIdx, CONFIRM_STEPS.length - 1)].key;
|
||||
const isLastStep = currentStep === 'review';
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@@ -263,17 +282,34 @@ export default function ConfirmAppointmentModal({
|
||||
onClose={handleClose}
|
||||
footer={
|
||||
<>
|
||||
{/* بستنِ فرم همیشه یک کلیک است؛ «مرحلهٔ قبل» جایش را نمیگیرد. */}
|
||||
<button type="button" className="btn ghost" onClick={handleClose}>
|
||||
انصراف
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary"
|
||||
disabled={loading || overpaid || confirmMut.isPending}
|
||||
onClick={() => confirmMut.mutate()}
|
||||
>
|
||||
{confirmMut.isPending ? 'در حال ثبت…' : 'تأیید و قطعی کردن'}
|
||||
</button>
|
||||
{stepIdx > 0 && (
|
||||
<button type="button" className="btn secondary" onClick={() => setStepIdx(i => i - 1)}>
|
||||
مرحلهٔ قبل
|
||||
</button>
|
||||
)}
|
||||
{isLastStep ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary"
|
||||
disabled={loading || overpaid || confirmMut.isPending}
|
||||
onClick={() => confirmMut.mutate()}
|
||||
>
|
||||
{confirmMut.isPending ? 'در حال ثبت…' : 'تأیید و قطعی کردن'}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary"
|
||||
disabled={loading || overpaid}
|
||||
onClick={() => setStepIdx(i => i + 1)}
|
||||
>
|
||||
مرحلهٔ بعد
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
@@ -295,6 +331,14 @@ export default function ConfirmAppointmentModal({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Stepper
|
||||
steps={CONFIRM_STEPS.map(st => ({ key: st.key, title: st.title }))}
|
||||
current={currentStep}
|
||||
ariaLabel="مراحل قطعی کردن نوبت"
|
||||
/>
|
||||
|
||||
{currentStep === 'cost' && (
|
||||
<>
|
||||
{/* بیمه — نوع خدمت فقط وقتی چند نوع فعال است پرسیده میشود. */}
|
||||
<div style={{ display: 'flex', gap: 10, marginBottom: 16, flexWrap: 'wrap' }}>
|
||||
{insurance.needsCategoryChoice && (
|
||||
@@ -390,6 +434,11 @@ export default function ConfirmAppointmentModal({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</>
|
||||
)}
|
||||
|
||||
{currentStep === 'payment' && (
|
||||
<>
|
||||
{/* پرداختها — تقسیم بین چند روش */}
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||||
@@ -508,8 +557,31 @@ export default function ConfirmAppointmentModal({
|
||||
مجموع پرداختها از مبلغ قابل پرداخت بیشتر است.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
{currentStep === 'review' && (
|
||||
<>
|
||||
{/* خلاصهٔ همان چیزی که ثبت میشود */}
|
||||
<div
|
||||
style={{
|
||||
border: '1px solid var(--border)', borderRadius: 'var(--r)',
|
||||
padding: '4px 14px 10px', marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
<div style={rowStyle}>
|
||||
<span>مبلغ قابل پرداخت</span>
|
||||
<strong style={{ color: 'var(--text)' }}>{formatRial(payable)}</strong>
|
||||
</div>
|
||||
{rows.filter(r => tomanToRial(r.amountToman) > 0).map(r => (
|
||||
<div key={r.id} style={{ ...rowStyle, borderTop: '1px solid var(--border)' }}>
|
||||
<span>{METHOD_OPTIONS.find(m => m.value === r.method)?.label ?? r.method}</span>
|
||||
<strong style={{ color: 'var(--text)' }}>{formatRial(tomanToRial(r.amountToman))}</strong>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* خلاصه */}
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--surface-2)', border: '1px solid var(--border)',
|
||||
@@ -540,6 +612,8 @@ export default function ConfirmAppointmentModal({
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
Reference in New Issue
Block a user