feat(admin): resource booking mode with a readiness guard

Task 06's engine could only be switched on through the API, and nothing
checked whether the environment was ready for it. Since the mode choice is
irreversible, picking it with no resources defined would lock a clinic into a
state where no appointment is ever computable.

Backend now refuses that: resource mode requires at least one active resource,
with a message that says what to define first. Same shape as the existing
service-mode guard, applied on both save paths.

The panel shows the same conditions as a ✓/✗ list before the choice is made,
each unmet one linking to where it gets fixed — a 422 after an irreversible
decision is the wrong place to learn about a prerequisite.

Also adds the search step (minimum 5 minutes) and extends the existing mode
cards to three rather than building a parallel component.

No strategy picker: task 06 never built the strategies, and an empty menu reads
worse than an absent one.

GET /api/v1/service-items now returns has_segments, computed with one aggregate
query for the whole list rather than one per service.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-31 19:51:29 +03:30
co-authored by Claude Opus 5
parent 1d6855a2b0
commit d56c41c87e
8 changed files with 318 additions and 16 deletions
@@ -59,7 +59,9 @@ interface BookingMeta {
online_booking_enabled: boolean;
booking_window_value: number;
booking_window_unit: BookingWindowUnit;
booking_mode: 'slot' | 'service';
booking_mode: 'slot' | 'service' | 'resource';
/** گام جستجوی وقت در حالت منبع‌محور — دقیقه */
step_minutes?: number;
buffer_minutes: number;
}
type BookingWindowUnit = 'day' | 'week' | 'month';
@@ -68,6 +70,13 @@ const BOOKING_WINDOW_UNITS: { value: BookingWindowUnit; label: string }[] = [
{ value: 'week', label: 'هفته' },
{ value: 'month', label: 'ماه' },
];
/** برچسب فارسی هر حالت — یک جا، تا پیام تأیید و کارت‌ها از هم واگرا نشوند. */
const MODE_LABELS: Record<BookingMeta['booking_mode'], string> = {
slot: 'نوبت‌دهی اسلاتی',
service: 'نوبت‌دهی سرویسی',
resource: 'نوبت‌دهی منبع‌محور',
};
const DEFAULT_BOOKING_META: BookingMeta = {
online_booking_enabled: true,
booking_window_value: 3,
@@ -542,6 +551,32 @@ export function WeeklyScheduleTab({ doctorUuid, clinicUuid, addresses, readOnly
const [meta, setMeta] = useState<BookingMeta>(DEFAULT_BOOKING_META);
// نوع نوبت‌دهی پس از اولین ثبت قفل می‌شود؛ confirmMode = دیالوگ هشدار قبل از ثبت اول.
const [modeLocked, setModeLocked] = useState(false);
/**
* شرط‌های آمادگیِ حالت منبع‌محور.
*
* فقط وقتی پرسیده می‌شود که کاربر واقعاً همان حالت را انتخاب کرده باشد — دو
* درخواست اضافه روی هر بازکردن تنظیمات، برای چیزی که اکثر کلینیک‌ها انتخابش
* نمی‌کنند، هزینهٔ بی‌دلیل است.
*/
const readinessQ = useQuery({
queryKey: ['resource-mode-readiness'],
queryFn: async () => {
const [resources, services] = await Promise.all([
api.get<ApiResponse<{ uuid: string }[]>>('/api/v1/resources'),
api.get<ApiResponse<{ uuid: string; has_segments?: boolean }[]>>('/api/v1/service-items'),
]);
return {
hasResources: (resources.data ?? []).length > 0,
hasSegments: (services.data ?? []).some((s) => s.has_segments === true),
};
},
enabled: meta.booking_mode === 'resource' && !modeLocked,
staleTime: 30_000,
});
const resourceReadiness = readinessQ.data ?? { hasResources: false, hasSegments: false };
const [confirmMode, setConfirmMode] = useState(false);
const scheduleQ = useQuery({
@@ -695,6 +730,7 @@ export function WeeklyScheduleTab({ doctorUuid, clinicUuid, addresses, readOnly
{([
['slot', 'نوبت‌دهی اسلاتی', 'شما بازه‌های کاری و «مدت هر نوبت» را مشخص می‌کنید؛ سیستم بازه را به نوبت‌های هم‌اندازه تقسیم می‌کند. مناسب ویزیت‌های با زمان یکسان.'],
['service', 'نوبت‌دهی سرویسی', 'مدت هر نوبت از «مدت سرویس» انتخاب‌شده تعیین می‌شود؛ سیستم نزدیک‌ترین زمان خالیِ کافی را پیشنهاد می‌دهد. مناسب خدمات با زمان متفاوت.'],
['resource', 'نوبت‌دهی منبع‌محور', 'نوبت به چند بخش تقسیم می‌شود و هر بخش منابع خودش (اتاق، اپراتور، دستگاه) را می‌گیرد؛ وقت آزاد از تقاطع تقویم همان منابع می‌آید. مناسب کلینیک زیبایی و لیزر.'],
] as const).map(([val, lbl, desc]) => {
const selected = meta.booking_mode === val;
return (
@@ -728,6 +764,58 @@ export function WeeklyScheduleTab({ doctorUuid, clinicUuid, addresses, readOnly
توجه: نوع نوبتدهی پس از اولین ثبت <span className="font-medium">بههیچعنوان قابل تغییر نیست</span>. پیش از ذخیره با دقت انتخاب کنید.
</p>
)}
{meta.booking_mode === 'resource' && !modeLocked && (
<div className="rounded-lg border border-[var(--border)] bg-[var(--surface-2)] p-3 space-y-2">
<span className="text-xs font-medium text-[var(--text)]">
پیش از انتخاب این حالت، اینها باید آماده باشند:
</span>
{/* انتخاب برگشت‌ناپذیر است، پس شرط‌ها باید **قبل** از ثبت دیده شوند نه در قالب خطای ۴۲۲ بعدش. */}
<ul className="space-y-1.5">
{[
['حداقل یک منبع فعال (اتاق، اپراتور یا دستگاه)', resourceReadiness.hasResources],
['حداقل یک سرویس با بخش‌های تعریف‌شده', resourceReadiness.hasSegments],
].map(([label, ok]) => (
<li key={String(label)} className="flex items-center gap-2 text-xs">
<span className={ok ? 'text-[var(--success)]' : 'text-[var(--danger)]'}>
{ok ? '✓' : '✗'}
</span>
<span className="text-[var(--text-2)]">{label}</span>
{!ok && (
<a href="/admin/resources" className="text-[var(--primary)] underline">
تعریف کنید
</a>
)}
</li>
))}
</ul>
{!resourceReadiness.hasResources && (
<p className="text-xs text-[var(--danger)]">
بدون منبع فعال، هیچ وقتی محاسبه نمیشود و چون این انتخاب برگشتناپذیر است، محیط قفل میماند.
</p>
)}
</div>
)}
{meta.booking_mode === 'resource' && (
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm text-[var(--text-2)]">گام جستجوی وقت</span>
<input
type="text"
inputMode="numeric"
dir="ltr"
value={meta.step_minutes ?? 15}
onChange={(e) =>
setMeta(m => ({ ...m, step_minutes: Math.max(5, Number(digitsOnly(e.target.value)) || 5) }))
}
className="w-16 text-center text-sm rounded-lg border border-[var(--border)] bg-[var(--surface)] px-2 py-1.5 focus:outline-none focus:ring-0"
/>
<span className="text-sm text-[var(--text-2)]">دقیقه</span>
<p className="w-full text-xs text-[var(--text-3)] leading-relaxed">
گام کوچکتر وقتهای بیشتری پیدا میکند ولی جستجو را کندتر میکند؛ پنج دقیقه کمترین مقدار مجاز است.
</p>
</div>
)}
{meta.booking_mode === 'service' ? (
<>
<div className="flex flex-wrap items-center gap-2">
@@ -894,7 +982,7 @@ export function WeeklyScheduleTab({ doctorUuid, clinicUuid, addresses, readOnly
open={confirmMode}
danger
title="تأیید نوع نوبت‌دهی"
message={`روش «${meta.booking_mode === 'service' ? 'نوبت‌دهی سرویسی' : 'نوبت‌دهی اسلاتی'}» را انتخاب کرده‌اید. این انتخاب پس از ثبت به‌هیچ‌عنوان قابل تغییر نیست. ادامه می‌دهید؟`}
message={`روش «${MODE_LABELS[meta.booking_mode]}» را انتخاب کرده‌اید. این انتخاب پس از ثبت به‌هیچ‌عنوان قابل تغییر نیست. ادامه می‌دهید؟`}
confirmLabel="ثبت و قفل"
loading={saveMut.isPending}
onConfirm={() => { setConfirmMode(false); saveMut.mutate(); }}