feat(stepper): implement multi-step wizard for appointment confirmation process

This commit is contained in:
hamed
2026-08-09 09:12:00 +03:30
parent 948f59827a
commit 3ffab2bbd0
7 changed files with 544 additions and 81 deletions
@@ -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>