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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user