feat(appointment): implement immutable booking mode after first save and update API documentation

This commit is contained in:
hamed
2026-07-15 23:33:00 +03:30
parent ad94c165e8
commit 2df16c6d29
5 changed files with 176 additions and 32 deletions
+78 -31
View File
@@ -13,6 +13,7 @@ import {
PlusIcon, CameraIcon, ChevronDownIcon, ChevronRightIcon, ChevronLeftIcon,
CheckCircleIcon, XMarkIcon, ExclamationTriangleIcon,
HeartIcon, CheckIcon, IdentificationIcon, DocumentTextIcon, GlobeAltIcon,
LockClosedIcon,
} from '@heroicons/react/24/outline';
import { StarIcon as StarSolid } from '@heroicons/react/24/solid';
import { toast } from 'sonner';
@@ -111,7 +112,7 @@ const DEFAULT_BOOKING_META: BookingMeta = {
booking_mode: 'slot',
buffer_minutes: 0,
};
interface WeeklyScheduleData { uuid: string; doctor_uuid: string; schedule: NewScheduleMap; meta?: BookingMeta; }
interface WeeklyScheduleData { uuid: string; doctor_uuid: string; schedule: NewScheduleMap; meta?: BookingMeta; booking_mode_locked?: boolean; }
// ── Constants ──────────────────────────────────────────────────────────────
@@ -1106,11 +1107,13 @@ function SlotEditor({ slots, onChange }: {
);
}
function SessionEditor({ session, onChange, onRemove, addresses }: {
function SessionEditor({ session, onChange, onRemove, addresses, serviceMode = false }: {
session: SessionConfig;
onChange: (s: SessionConfig) => void;
onRemove: () => void;
addresses: AddressData[];
/** حالت نوبت‌دهی سرویسی: فیلدهای اسلاتی (بازه هر نوبت، استراحت، شمارش) نمایش داده نمی‌شوند. */
serviceMode?: boolean;
}) {
const upd = <K extends keyof SessionConfig>(k: K, v: SessionConfig[K]) =>
onChange({ ...session, [k]: v });
@@ -1134,16 +1137,18 @@ function SessionEditor({ session, onChange, onRemove, addresses }: {
</button>
</div>
{/* Duration + Location */}
<div style={{ padding: '0 16px 14px', display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div>
<div style={{ fontSize: 12, color: 'var(--text-2)', marginBottom: 5 }}>بازه هر نوبت</div>
<GlobalSearchableSelect
options={DURATION_OPTS.map(d => ({ value: d, label: `${d} دقیقه` }))}
value={session.duration_per_patient}
onChange={(v) => upd('duration_per_patient', Number(v))}
/>
</div>
{/* Duration (slot mode only) + Location */}
<div style={{ padding: '0 16px 14px', display: 'grid', gridTemplateColumns: serviceMode ? '1fr' : '1fr 1fr', gap: 12 }}>
{!serviceMode && (
<div>
<div style={{ fontSize: 12, color: 'var(--text-2)', marginBottom: 5 }}>بازه هر نوبت</div>
<GlobalSearchableSelect
options={DURATION_OPTS.map(d => ({ value: d, label: `${d} دقیقه` }))}
value={session.duration_per_patient}
onChange={(v) => upd('duration_per_patient', Number(v))}
/>
</div>
)}
<div>
<div style={{ fontSize: 12, color: 'var(--text-2)', marginBottom: 5 }}>مکان نوبت</div>
<GlobalSearchableSelect
@@ -1165,7 +1170,8 @@ function SessionEditor({ session, onChange, onRemove, addresses }: {
</div>
</div>
{/* Rest toggle */}
{/* Rest toggle (slot mode only) */}
{!serviceMode && (
<div style={{
padding: '12px 16px', display: 'flex', alignItems: 'center',
justifyContent: 'space-between', borderTop: '1px solid var(--border)',
@@ -1189,9 +1195,10 @@ function SessionEditor({ session, onChange, onRemove, addresses }: {
}} />
</button>
</div>
)}
{/* Rest params */}
{session.has_rest && (
{!serviceMode && session.has_rest && (
<div style={{ padding: '0 16px 14px', display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div>
<div style={{ fontSize: 12, color: 'var(--text-2)', marginBottom: 5 }}>هر (دقیقه کار)</div>
@@ -1210,7 +1217,8 @@ function SessionEditor({ session, onChange, onRemove, addresses }: {
</div>
)}
{/* Slot count footer */}
{/* Slot count footer (slot mode only) */}
{!serviceMode && (
<div style={{
padding: '10px 16px', display: 'flex', alignItems: 'center',
justifyContent: 'space-between', borderTop: '1px solid var(--border)',
@@ -1226,6 +1234,7 @@ function SessionEditor({ session, onChange, onRemove, addresses }: {
<span className="muted" style={{ fontSize: 12 }}></span>
)}
</div>
)}
</div>
);
}
@@ -1238,6 +1247,9 @@ export function WeeklyScheduleTab({ doctorUuid, addresses, readOnly = false }: {
const [scheduleUuid, setScheduleUuid] = useState<string | null>(null);
const [expandedDay, setExpandedDay] = useState<string | null>(null);
const [meta, setMeta] = useState<BookingMeta>(DEFAULT_BOOKING_META);
// نوع نوبت‌دهی پس از اولین ثبت قفل می‌شود؛ confirmMode = دیالوگ هشدار قبل از ثبت اول.
const [modeLocked, setModeLocked] = useState(false);
const [confirmMode, setConfirmMode] = useState(false);
const scheduleQ = useQuery({
queryKey: ['doctor-schedule', doctorUuid],
@@ -1259,6 +1271,7 @@ export function WeeklyScheduleTab({ doctorUuid, addresses, readOnly = false }: {
setScheduleUuid(d.uuid);
}
if (d?.meta) setMeta({ ...DEFAULT_BOOKING_META, ...d.meta });
setModeLocked(!!d?.booking_mode_locked);
} else if (scheduleQ.error instanceof ApiError && scheduleQ.error.status === 404) {
setScheduleMap(EMPTY_NEW_SCHEDULE); setScheduleUuid(null);
}
@@ -1291,6 +1304,7 @@ export function WeeklyScheduleTab({ doctorUuid, addresses, readOnly = false }: {
onSuccess: (res) => {
const d: WeeklyScheduleData = res?.data?.data ?? res?.data;
if (d?.uuid && !scheduleUuid) setScheduleUuid(d.uuid);
setModeLocked(true); // پس از ثبت، نوع نوبت‌دهی قفل می‌شود
toast.success('برنامه هفتگی ذخیره شد');
qc.invalidateQueries({ queryKey: ['doctor-schedule', doctorUuid] });
},
@@ -1394,22 +1408,43 @@ export function WeeklyScheduleTab({ doctorUuid, addresses, readOnly = false }: {
<span className="text-sm font-medium text-slate-700 dark:text-slate-200">روش نوبتدهی</span>
</div>
<div className="px-4 py-3 space-y-3">
<div className="flex flex-wrap gap-2">
{([['slot', 'اسلاتی (مدت ثابت)'], ['service', 'بر اساس سرویس']] as const).map(([val, lbl]) => (
<button
key={val}
type="button"
onClick={() => setMeta(m => ({ ...m, booking_mode: val }))}
className={`px-3 py-1.5 text-sm rounded-lg border transition-colors ${
meta.booking_mode === val
? 'bg-[var(--primary)] text-white border-[var(--primary)]'
: 'bg-white dark:bg-gray-900 text-slate-600 dark:text-slate-300 border-slate-200 dark:border-gray-700'
}`}
>
{lbl}
</button>
))}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{([
['slot', 'نوبت‌دهی اسلاتی', 'شما بازه‌های کاری و «مدت هر نوبت» را مشخص می‌کنید؛ سیستم بازه را به نوبت‌های هم‌اندازه تقسیم می‌کند. مناسب ویزیت‌های با زمان یکسان.'],
['service', 'نوبت‌دهی سرویسی', 'مدت هر نوبت از «مدت سرویس» انتخاب‌شده تعیین می‌شود؛ سیستم نزدیک‌ترین زمان خالیِ کافی را پیشنهاد می‌دهد. مناسب خدمات با زمان متفاوت.'],
] as const).map(([val, lbl, desc]) => {
const selected = meta.booking_mode === val;
return (
<button
key={val}
type="button"
disabled={modeLocked}
onClick={() => !modeLocked && setMeta(m => ({ ...m, booking_mode: val }))}
className={`text-right p-3 rounded-lg border transition-colors ${
selected
? 'border-[var(--primary)] bg-[var(--primary)]/5'
: 'border-slate-200 dark:border-gray-700 bg-white dark:bg-gray-900'
} ${modeLocked ? 'opacity-70 cursor-not-allowed' : 'hover:border-[var(--primary)]'}`}
>
<div className="flex items-center gap-2 mb-1">
<span className={`w-3.5 h-3.5 rounded-full border shrink-0 ${selected ? 'border-[var(--primary)] bg-[var(--primary)]' : 'border-slate-300 dark:border-gray-600'}`} />
<span className="text-sm font-medium text-slate-700 dark:text-slate-200">{lbl}</span>
</div>
<p className="text-xs text-slate-500 dark:text-slate-400 leading-relaxed">{desc}</p>
</button>
);
})}
</div>
{modeLocked ? (
<p className="text-xs text-slate-500 dark:text-slate-400 flex items-center gap-1.5">
<LockClosedIcon className="w-3.5 h-3.5 shrink-0" />
نوع نوبتدهی ثبت شده و دیگر قابل تغییر نیست.
</p>
) : (
<p className="text-xs text-amber-600 dark:text-amber-400 leading-relaxed">
توجه: نوع نوبتدهی پس از اولین ثبت <span className="font-medium">بههیچعنوان قابل تغییر نیست</span>. پیش از ذخیره با دقت انتخاب کنید.
</p>
)}
{meta.booking_mode === 'service' ? (
<>
<div className="flex flex-wrap items-center gap-2">
@@ -1544,6 +1579,7 @@ export function WeeklyScheduleTab({ doctorUuid, addresses, readOnly = false }: {
</div>
) : sessions.map((session, idx) => (
<SessionEditor key={idx} session={session} addresses={addresses}
serviceMode={meta.booking_mode === 'service'}
onChange={s => updateSession(day.key, idx, s)}
onRemove={() => removeSession(day.key, idx)} />
))}
@@ -1561,13 +1597,24 @@ export function WeeklyScheduleTab({ doctorUuid, addresses, readOnly = false }: {
</p>
)}
{!readOnly && (
<button type="button" onClick={() => saveMut.mutate()}
<button type="button" onClick={() => modeLocked ? saveMut.mutate() : setConfirmMode(true)}
disabled={saveMut.isPending || hasAnyOverlap || missingLocation}
className="btn primary sm" style={{ marginInlineStart: 'auto', opacity: (saveMut.isPending || hasAnyOverlap || missingLocation) ? 0.5 : 1 }}>
{saveMut.isPending ? 'در حال ذخیره...' : 'ذخیره برنامه هفتگی'}
</button>
)}
</div>
<ConfirmDialog
open={confirmMode}
danger
title="تأیید نوع نوبت‌دهی"
message={`روش «${meta.booking_mode === 'service' ? 'نوبت‌دهی سرویسی' : 'نوبت‌دهی اسلاتی'}» را انتخاب کرده‌اید. این انتخاب پس از ثبت به‌هیچ‌عنوان قابل تغییر نیست. ادامه می‌دهید؟`}
confirmLabel="ثبت و قفل"
loading={saveMut.isPending}
onConfirm={() => { setConfirmMode(false); saveMut.mutate(); }}
onCancel={() => setConfirmMode(false)}
/>
</div>
);
}