feat(appointments): implement multi-step wizard for appointment booking modal
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user