feat(appointment): let a platform admin change a doctor's locked booking mode

The booking mode locks after the first save because existing appointments were
computed under that mode's rules. A lock with no key, though, traps a practice
that picked the wrong mode on day one, so ROLE_ADMIN can now open it.

The first attempt is still refused when the doctor has active appointments in
the next year, and says how many; the admin repeats the request with
force_mode_change to confirm they know what happens to those. The flag does
nothing for anyone else. GET now returns booking_mode_changeable so the panel
enables the toggle from the server's answer rather than guessing from the role.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-08-20 17:32:52 +03:30
co-authored by Claude Opus 5
parent 7096b8980d
commit a3fc7f5c8b
5 changed files with 274 additions and 19 deletions
@@ -112,6 +112,8 @@ interface WeeklyScheduleData {
locations?: AddressData[];
/** مکان‌هایی که همین کاربر حق دارد روی برنامه بنشاند. */
selectable_location_ids?: number[];
/** آیا همین کاربر می‌تواند قفلِ نوع نوبت‌دهی را باز کند — امروز فقط ادمین پلتفرم. */
booking_mode_changeable?: boolean;
}
// ── Persian (Jalali) date utilities ───────────────────────────────────────
@@ -606,6 +608,12 @@ export function WeeklyScheduleTab({ doctorUuid, clinicUuid, addresses, readOnly
const [meta, setMeta] = useState<BookingMeta>(DEFAULT_BOOKING_META);
// نوع نوبت‌دهی پس از اولین ثبت قفل می‌شود؛ confirmMode = دیالوگ هشدار قبل از ثبت اول.
const [modeLocked, setModeLocked] = useState(false);
// قفل برای همه هست، کلیدش فقط دست ادمین. نوعِ ثبت‌شده هم نگه داشته می‌شود تا
// بدانیم کاربر واقعاً عوضش کرده یا فقط ذخیرهٔ معمولی است.
const [modeChangeable, setModeChangeable] = useState(false);
const [savedMode, setSavedMode] = useState<BookingMeta['booking_mode'] | null>(null);
// پیام واقعی سرور دربارهٔ نوبت‌های آینده؛ تا وقتی null است دیالوگ تأیید بسته می‌ماند.
const [modeChangeWarning, setModeChangeWarning] = useState<string | null>(null);
const [confirmMode, setConfirmMode] = useState(false);
@@ -632,6 +640,8 @@ export function WeeklyScheduleTab({ doctorUuid, clinicUuid, addresses, readOnly
if (d?.locations) setAllLocations(d.locations);
if (d?.selectable_location_ids) setSelectableIds(d.selectable_location_ids.map(Number));
setModeLocked(!!d?.booking_mode_locked);
setModeChangeable(!!d?.booking_mode_changeable);
setSavedMode(d?.meta?.booking_mode ?? null);
} else if (scheduleQ.error instanceof ApiError && scheduleQ.error.status === 404) {
setScheduleMap(EMPTY_NEW_SCHEDULE); setScheduleUuid(null);
}
@@ -665,22 +675,34 @@ export function WeeklyScheduleTab({ doctorUuid, clinicUuid, addresses, readOnly
(scheduleMap[d.key]?.sessions ?? []).some(s => s.active && s.location_id === null)
);
const modeChanged = savedMode !== null && savedMode !== meta.booking_mode;
const saveMut = useMutation({
mutationFn: () => {
mutationFn: ({ forceModeChange = false }: { forceModeChange?: boolean } = {}) => {
if (hasAnyOverlap) throw new Error('تداخل زمانی در برنامه وجود دارد');
if (missingLocation) throw new Error('مکان مطب برای همه بازه‌های فعال الزامی است');
const body = { schedule: scheduleMap, meta, clinic_uuid: clinicUuid ?? null, force_mode_change: forceModeChange };
return scheduleUuid
? api.patch<ApiResponse<any>>(`/api/v1/appointment-settings/weekly-schedule/${doctorUuid}`, { schedule: scheduleMap, meta, clinic_uuid: clinicUuid ?? null })
: api.post<ApiResponse<any>>('/api/v1/appointment-settings/weekly-schedule', { doctor_uuid: doctorUuid, schedule: scheduleMap, meta, clinic_uuid: clinicUuid ?? null });
? api.patch<ApiResponse<any>>(`/api/v1/appointment-settings/weekly-schedule/${doctorUuid}`, body)
: api.post<ApiResponse<any>>('/api/v1/appointment-settings/weekly-schedule', { doctor_uuid: doctorUuid, ...body });
},
onSuccess: (res) => {
const d: WeeklyScheduleData = res?.data?.data ?? res?.data;
if (d?.uuid && !scheduleUuid) setScheduleUuid(d.uuid);
setModeLocked(true); // پس از ثبت، نوع نوبت‌دهی قفل می‌شود
setSavedMode(meta.booking_mode);
toast.success('برنامه هفتگی ذخیره شد');
qc.invalidateQueries({ queryKey: ['doctor-schedule', doctorUuid, clinicUuid ?? null] });
},
onError: (e: Error) => toast.error(e.message),
onError: (e: Error) => {
// سرور تغییرِ نوعِ ثبت‌شده را بار اول رد می‌کند و تعداد نوبت‌های فعال آینده را
// می‌گوید. همان جمله در دیالوگ تأیید نشان داده می‌شود، نه یک متن حدسی.
if (e instanceof ApiError && e.status === 422 && modeChanged) {
setModeChangeWarning(e.message);
return;
}
toast.error(e.message);
},
});
const setDaySessions = (key: string, sessions: SessionConfig[]) =>
@@ -785,13 +807,13 @@ export function WeeklyScheduleTab({ doctorUuid, clinicUuid, addresses, readOnly
<button
key={val}
type="button"
disabled={modeLocked}
onClick={() => !modeLocked && setMeta(m => ({ ...m, booking_mode: val }))}
disabled={modeLocked && !modeChangeable}
onClick={() => (!modeLocked || modeChangeable) && 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-[var(--border)] bg-[var(--surface)]'
} ${modeLocked ? 'opacity-70 cursor-not-allowed' : 'hover:border-[var(--primary)]'}`}
} ${modeLocked && !modeChangeable ? '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-[var(--border-2)]'}`} />
@@ -802,7 +824,12 @@ export function WeeklyScheduleTab({ doctorUuid, clinicUuid, addresses, readOnly
);
})}
</div>
{modeLocked ? (
{modeLocked && modeChangeable ? (
<p className="text-xs text-[var(--warning)] flex items-center gap-1.5">
<ExclamationTriangleIcon className="w-3.5 h-3.5 shrink-0" />
نوع نوبتدهی برای این پزشک ثبت شده است. فقط ادمین میتواند عوضش کند و نوبتهای ثبتشده با قواعد نوع قبلی محاسبه شدهاند.
</p>
) : modeLocked ? (
<p className="text-xs text-[var(--text-2)] flex items-center gap-1.5">
<LockClosedIcon className="w-3.5 h-3.5 shrink-0" />
نوع نوبتدهی ثبت شده و دیگر قابل تغییر نیست.
@@ -965,7 +992,10 @@ export function WeeklyScheduleTab({ doctorUuid, clinicUuid, addresses, readOnly
</p>
)}
{!readOnly && (
<button type="button" onClick={() => modeLocked ? saveMut.mutate() : setConfirmMode(true)}
<button type="button" onClick={() => {
if (modeLocked) { saveMut.mutate({}); return; }
setConfirmMode(true);
}}
disabled={saveMut.isPending || hasAnyOverlap || missingLocation}
className="btn primary sm" style={{ marginInlineStart: 'auto', opacity: (saveMut.isPending || hasAnyOverlap || missingLocation) ? 0.5 : 1 }}>
{saveMut.isPending ? 'در حال ذخیره...' : 'ذخیره برنامه هفتگی'}
@@ -980,9 +1010,22 @@ export function WeeklyScheduleTab({ doctorUuid, clinicUuid, addresses, readOnly
message={`روش «${MODE_LABELS[meta.booking_mode]}» را انتخاب کرده‌اید. این انتخاب پس از ثبت به‌هیچ‌عنوان قابل تغییر نیست. ادامه می‌دهید؟`}
confirmLabel="ثبت و قفل"
loading={saveMut.isPending}
onConfirm={() => { setConfirmMode(false); saveMut.mutate(); }}
onConfirm={() => { setConfirmMode(false); saveMut.mutate({}); }}
onCancel={() => setConfirmMode(false)}
/>
{/* تغییر نوعِ ثبت‌شده مسیر جداست: بازگشتی ندارد و نوبت‌های آینده را زیر قواعد
تازه می‌برد. متن هشدار از خود سرور می‌آید تا تعداد واقعی گفته شود. */}
<ConfirmDialog
open={modeChangeWarning !== null}
danger
title="تغییر نوع نوبت‌دهی"
message={`${modeChangeWarning ?? ''}\n\nنوع نوبت‌دهی از «${savedMode ? MODE_LABELS[savedMode] : '—'}» به «${MODE_LABELS[meta.booking_mode]}» تغییر می‌کند و این کار برگشت‌پذیر نیست.`}
confirmLabel="بله، تغییر بده"
loading={saveMut.isPending}
onConfirm={() => { setModeChangeWarning(null); saveMut.mutate({ forceModeChange: true }); }}
onCancel={() => setModeChangeWarning(null)}
/>
</div>
);
}