From d56c41c87eabaf03d5d25b0a452ea84f8f4e4be9 Mon Sep 17 00:00:00 2001
From: hamed <15238-genius.ha@users.noreply.drupalcode.org>
Date: Fri, 31 Jul 2026 19:51:29 +0330
Subject: [PATCH] feat(admin): resource booking mode with a readiness guard
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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)
---
.../components/schedule/ScheduleSection.tsx | 92 ++++++++++++++++++-
docs/api/appointment-settings.md | 41 +++++++++
.../task-06-availability-engine/checklist.md | 28 +++---
.../AppointmentSettingsController.php | 36 ++++++++
.../Repository/SegmentTemplateRepository.php | 28 ++++++
.../Controller/ClinicServiceController.php | 10 +-
.../Repository/ClinicResourceRepository.php | 14 +++
.../Appointment/ResourceModeReadinessTest.php | 85 +++++++++++++++++
8 files changed, 318 insertions(+), 16 deletions(-)
create mode 100644 tests/Appointment/ResourceModeReadinessTest.php
diff --git a/assets/admin/components/schedule/ScheduleSection.tsx b/assets/admin/components/schedule/ScheduleSection.tsx
index 9793b46f..32f9b2f7 100644
--- a/assets/admin/components/schedule/ScheduleSection.tsx
+++ b/assets/admin/components/schedule/ScheduleSection.tsx
@@ -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 = {
+ 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(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>('/api/v1/resources'),
+ api.get>('/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
⚠️ توجه: نوع نوبتدهی پس از اولین ثبت بههیچعنوان قابل تغییر نیست. پیش از ذخیره با دقت انتخاب کنید.
+
+ پیش از انتخاب این حالت، اینها باید آماده باشند:
+
+ {/* انتخاب برگشتناپذیر است، پس شرطها باید **قبل** از ثبت دیده شوند نه در قالب خطای ۴۲۲ بعدش. */}
+
+ {[
+ ['حداقل یک منبع فعال (اتاق، اپراتور یا دستگاه)', resourceReadiness.hasResources],
+ ['حداقل یک سرویس با بخشهای تعریفشده', resourceReadiness.hasSegments],
+ ].map(([label, ok]) => (
+