feat(appointments): implement multi-step wizard for appointment booking modal

This commit is contained in:
hamed
2026-08-09 08:53:55 +03:30
parent b49abee52f
commit 948f59827a
5 changed files with 369 additions and 46 deletions
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import React, { useEffect, useState } from 'react';
import type { ReactNode } from 'react';
import { useMutation, useQuery } from '@tanstack/react-query';
import { ChevronDownIcon, ClockIcon, MagnifyingGlassIcon, CheckCircleIcon, CpuChipIcon, ExclamationCircleIcon } from '@heroicons/react/24/outline';
@@ -24,6 +24,14 @@ export interface BookingSlot {
doctor_name: string;
}
type StepKey = 'service' | 'patient' | 'confirm';
const STEP_TITLES: Record<StepKey, string> = {
service: 'خدمت و زمان',
patient: 'بیمار',
confirm: 'تأیید و ثبت',
};
interface PatientLookup { found: boolean; name?: string | null; mobile?: string; national_code?: string | null }
/** منبعِ هدفِ نوبت — دستگاه/اتاق/پرسنلی که نوبت رویش می‌نشیند. */
@@ -160,6 +168,7 @@ export default function NewAppointmentModal({
// هزینه ویزیت اختیاری داخل کلپسِ بسته می‌نشیند؛ وقتی الزامی است کلپس همیشه باز است.
const [visitPriceOpen, setVisitPriceOpen] = useState(false);
const visitPriceExpanded = requireVisit || visitPriceOpen;
const [stepIdx, setStepIdx] = useState(0);
const mobileValid = /^09\d{9}$/.test(mobile);
const nationalCodeValid = /^\d{10}$/.test(nationalCode);
@@ -264,19 +273,51 @@ export default function NewAppointmentModal({
...(activeDate && !pickerMode ? [{ label: 'تاریخ', value: formatDate(activeDate) }] : []),
];
// اولین چیزی که جلوی ثبت را گرفته، به ترتیبِ همان مراحلِ فرم. دکمهٔ خاکستریِ
/**
* مراحلِ ویزارد — فقط مراحلی که واقعاً تصمیمی دارند.
*
* فرم قبلاً یک صفحهٔ بلند بود: انتخاب سرویس و زمان، جستجوی بیمار و هزینه همه با هم.
* مرحله‌ای که داده‌اش از قبل معلوم است ساخته نمی‌شود — نوبتِ اسلاتی زمان دارد و
* فرمِ بازشده از پروندهٔ بیمار، بیمار.
*/
const stepKeys: StepKey[] = [
...(pickerMode && activeDate ? (['service'] as StepKey[]) : []),
...(patient === null ? (['patient'] as StepKey[]) : []),
'confirm',
];
const currentStep = stepKeys[Math.min(stepIdx, stepKeys.length - 1)];
const isLastStep = currentStep === 'confirm';
// اولین چیزی که جلوی رفتن به مرحلهٔ بعد (یا ثبت) را گرفته. دکمهٔ خاکستریِ
// بی‌توضیح یعنی کاربر باید حدس بزند چه چیزی کم است.
const blockReason = !serviceTimingValid
? (pick.serviceUuids.length === 0 ? 'یک سرویس انتخاب کنید' : 'ساعت شروع را انتخاب کنید')
: lookup === null
const stepBlockReason: Record<StepKey, string | null> = {
service: !serviceTimingValid
? (pick.serviceUuids.length === 0 ? 'یک سرویس انتخاب کنید' : 'ساعت شروع را انتخاب کنید')
: null,
patient: lookup === null
? 'ابتدا بیمار را جستجو کنید'
: needsDetails && !detailsValid
? 'مشخصات بیمار را کامل کنید'
: !mobileValid
? 'شماره موبایل بیمار معتبر نیست'
: !visitPriceValid
? 'هزینه ویزیت الزامی است'
: null;
: null,
confirm: !visitPriceValid ? 'هزینه ویزیت الزامی است' : null,
};
const blockReason = stepBlockReason[currentStep];
// خلاصهٔ مرحلهٔ آخر: همان چیزی که ثبت می‌شود، پیش از ثبت.
const pickedServiceNames = pick.serviceUuids
.map(uuid => services.find(sv => sv.uuid === uuid)?.name ?? uuid);
const staffName = (staffQ.data?.data ?? []).find(st => st.uuid === staffUuid)?.full_name ?? null;
const summaryRows: { label: string; value: string }[] = [
...targetFacts.map(f => ({ label: f.label, value: f.value })),
...(pickerMode && activeDate ? [{ label: 'تاریخ', value: formatDate(activeDate) }] : []),
...(pickerMode && pick.slot ? [{ label: 'ساعت', value: pick.slot.start_time }] : []),
...(pickedServiceNames.length > 0 ? [{ label: 'سرویس', value: pickedServiceNames.join('، ') }] : []),
...(staffName ? [{ label: 'پرسنل', value: staffName }] : []),
{ label: 'بیمار', value: effectiveName || (patient?.name ?? '—') },
{ label: 'موبایل', value: mobile || (patient?.mobile ?? '—') },
];
const priceHint = pricingLoading
? 'در حال خواندن تعرفهٔ پزشک…'
@@ -288,18 +329,36 @@ export default function NewAppointmentModal({
<Modal
open
title="ثبت نوبت"
size="sm"
// هر بار یک مرحله دیده می‌شود، پس ستون تکی کافی است؛ `md` عرضِ راحتِ
// انتخابگر سرویس و اسلات است بدون فضای خالی.
size="md"
onClose={onClose}
footer={
<>
{/* «قبلی» جای «انصراف» را نمی‌گیرد: بستنِ فرم همیشه باید یک کلیک باشد. */}
<button className="btn ghost" onClick={onClose}>انصراف</button>
<button
className="btn primary"
onClick={() => mutation.mutate()}
disabled={!isValid || mutation.isPending}
>
{mutation.isPending ? 'در حال ثبت…' : 'ثبت نوبت'}
</button>
{stepIdx > 0 && (
<button className="btn secondary" onClick={() => setStepIdx(i => i - 1)}>
مرحلهٔ قبل
</button>
)}
{isLastStep ? (
<button
className="btn primary"
onClick={() => mutation.mutate()}
disabled={!isValid || mutation.isPending}
>
{mutation.isPending ? 'در حال ثبت…' : 'ثبت نوبت'}
</button>
) : (
<button
className="btn primary"
onClick={() => setStepIdx(i => i + 1)}
disabled={blockReason !== null}
>
مرحلهٔ بعد
</button>
)}
</>
}
>
@@ -319,8 +378,12 @@ export default function NewAppointmentModal({
))}
</div>
{pickerMode && activeDate && (
<Step n={1} title="خدمت و زمان">
{stepKeys.length > 1 && (
<Stepper keys={stepKeys} current={currentStep} />
)}
{currentStep === 'service' && (
<Step title={STEP_TITLES.service}>
<div className="field-block" style={{ marginBottom: 12, maxWidth: 220 }}>
<label htmlFor="appt-date">تاریخ نوبت</label>
<PersianDateInput
@@ -357,7 +420,8 @@ export default function NewAppointmentModal({
</Step>
)}
<Step n={pickerMode ? 2 : null} title="بیمار">
{currentStep === 'patient' && (
<Step title={STEP_TITLES.patient}>
{/* بیمارِ از پیش معلوم: فقط تأیید می‌شود، جستجو لازم نیست. */}
{patient !== null && (
<div style={{
@@ -512,9 +576,28 @@ export default function NewAppointmentModal({
</>
)}
</Step>
)}
{/* هزینه ویزیت مرحلهٔ شماره‌دار نیست: در حالت اختیاری یک کلپسِ بسته است و
شماره دادن به آن، کاری اختیاری را اجباری نشان می‌داد. */}
{currentStep === 'confirm' && (
<Step title={STEP_TITLES.confirm}>
{/* خلاصهٔ همان چیزی که ثبت می‌شود — تنها جایی که کاربر پیش از ثبت، انتخاب
سرویس و زمان و بیمار را با هم می‌بیند. */}
<dl style={{
display: 'grid', gridTemplateColumns: 'auto 1fr', gap: '8px 14px', margin: '0 0 18px',
padding: '12px 14px', borderRadius: 'var(--r-sm)', background: 'var(--surface-2)',
fontSize: 13,
}}>
{summaryRows.map(row => (
<React.Fragment key={row.label}>
<dt style={{ color: 'var(--text-3)' }}>{row.label}</dt>
<dd style={{ margin: 0, color: 'var(--text)', fontWeight: 600, minWidth: 0, overflowWrap: 'anywhere' }}>
{row.value}
</dd>
</React.Fragment>
))}
</dl>
{/* هزینه ویزیت در حالت اختیاری یک کلپسِ بسته است. */}
<div className="field-block">
{requireVisit ? (
<label>
@@ -572,6 +655,8 @@ export default function NewAppointmentModal({
</>
)}
</div>
</Step>
)}
{blockReason && (
<div style={{
@@ -579,31 +664,78 @@ export default function NewAppointmentModal({
fontSize: 12.5, color: 'var(--text-3)',
}}>
<ExclamationCircleIcon style={{ width: 15, height: 15, flexShrink: 0 }} />
برای ثبت نوبت: {blockReason}
{isLastStep ? 'برای ثبت نوبت' : 'برای مرحلهٔ بعد'}: {blockReason}
</div>
)}
</Modal>
);
}
/** مرحلهٔ شماره‌دار فرم — ترتیب تصمیم‌ها را دیدنی می‌کند، نه فقط ترتیب فیلدها. */
function Step({ n, title, children }: { n: number | null; title: string; children: ReactNode }) {
/** بدنهٔ یک مرحله — عنوانش در `Stepper` هم هست، اینجا سرِ همان بخش است. */
function Step({ title, children }: { title: string; children: ReactNode }) {
return (
<section style={{ marginBottom: 18 }}>
<h3 style={{
display: 'flex', alignItems: 'center', gap: 8, margin: '0 0 10px',
fontSize: 13.5, fontWeight: 700, color: 'var(--text)',
margin: '0 0 10px', fontSize: 13.5, fontWeight: 700, color: 'var(--text)',
}}>
{/* حالت اسلاتی فقط یک مرحله دارد؛ شمارهٔ «۱» تنها، نویز است نه راهنما. */}
{n !== null && (
<span style={{
display: 'grid', placeItems: 'center', width: 20, height: 20, borderRadius: 999,
background: 'var(--primary)', color: 'var(--on-primary)', fontSize: 11.5, flexShrink: 0,
}}>{n}</span>
)}
{title}
</h3>
{children}
</section>
);
}
/**
* نوار مراحل — «کجای کارم و چقدر مانده».
*
* فرم قبلاً همهٔ مراحل را با هم نشان می‌داد و مودال از ارتفاع صفحه بلندتر می‌شد؛
* حالا هر بار یک مرحله دیده می‌شود و این نوار تنها چیزی است که کلِ مسیر را می‌گوید.
*/
function Stepper({ keys, current }: { keys: StepKey[]; current: StepKey }) {
const activeIdx = keys.indexOf(current);
return (
<ol
aria-label="مراحل ثبت نوبت"
style={{
display: 'flex', alignItems: 'center', gap: 8, listStyle: 'none',
margin: '0 0 18px', padding: 0,
}}
>
{keys.map((key, idx) => {
const done = idx < activeIdx;
const active = idx === activeIdx;
return (
<li
key={key}
aria-current={active ? 'step' : undefined}
style={{ display: 'flex', alignItems: 'center', gap: 8, flex: idx === keys.length - 1 ? '0 0 auto' : 1 }}
>
<span style={{
display: 'grid', placeItems: 'center', width: 22, height: 22, borderRadius: 999,
flexShrink: 0, fontSize: 11.5, fontWeight: 700,
background: active || done ? 'var(--primary)' : 'var(--surface-3)',
color: active || done ? 'var(--on-primary)' : 'var(--text-3)',
}}>
{idx + 1}
</span>
<span style={{
fontSize: 12.5, whiteSpace: 'nowrap',
fontWeight: active ? 700 : 500,
color: active ? 'var(--text)' : 'var(--text-3)',
}}>
{STEP_TITLES[key]}
</span>
{idx < keys.length - 1 && (
<span style={{
flex: 1, height: 1, minWidth: 12,
background: done ? 'var(--primary)' : 'var(--border)',
}} />
)}
</li>
);
})}
</ol>
);
}