From e00fe997f919dbedf1d27d837cac5725dd9c3905 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Thu, 16 Jul 2026 10:41:15 +0330 Subject: [PATCH] feat: section-based service picker + per-appointment duration override (service mode) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Service-booking mode now selects services by section like slot mode: appointment-booking-services returns service_section per item; ServiceSlotPicker groups by section (SearchableSelect), accumulates picks across sections into a removable 'section -> service' chip list. Secretaries can override a service's duration for a single appointment without changing the service default: appointment-service-slots accepts durations[uuid] and both create endpoints accept service_durations; the override drives total duration and slot_end. Online (patient) booking is unaffected — it never sends overrides. Backend + frontend tests and docs updated. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...appointment-service-mode-section-picker.md | 192 +++++++++++++++++ .../appointments/ServiceSlotPicker.test.tsx | 86 ++++++++ .../appointments/ServiceSlotPicker.tsx | 203 +++++++++++++----- .../admin/hooks/useDoctorBookingServices.ts | 1 + .../pages/AppointmentCreatePage.test.tsx | 7 +- assets/admin/pages/AppointmentCreatePage.tsx | 4 +- docs/api/admin.md | 3 +- docs/api/appointment.md | 7 +- src/Admin/Controller/AdminApiController.php | 9 +- .../Controller/AppointmentController.php | 26 ++- .../Controller/MyAppointmentsController.php | 9 +- .../ServiceModeSectionDurationTest.php | 122 +++++++++++ 12 files changed, 598 insertions(+), 71 deletions(-) create mode 100644 .claude/prompt/appointment-service-mode-section-picker.md create mode 100644 assets/admin/components/appointments/ServiceSlotPicker.test.tsx create mode 100644 tests/Appointment/ServiceModeSectionDurationTest.php diff --git a/.claude/prompt/appointment-service-mode-section-picker.md b/.claude/prompt/appointment-service-mode-section-picker.md new file mode 100644 index 00000000..b1426631 --- /dev/null +++ b/.claude/prompt/appointment-service-mode-section-picker.md @@ -0,0 +1,192 @@ +# انتخاب سرویس بر اساس بخش + ویرایش مدت توسط منشی — حالت نوبت‌دهی سرویسی + +## زمینه + +صفحهٔ ایجاد نوبت پنل ادمین (`assets/admin/pages/AppointmentCreatePage.tsx`) دو حالت دارد که با `booking_mode` پزشک تعیین می‌شود (`useDoctorBookingServices`): + +- **اسلاتی (`slot`)**: کاربر بخش را انتخاب می‌کند، سپس سرویس‌های همان بخش به‌صورت چک‌باکس نشان داده می‌شوند، انتخاب‌ها در یک لیستِ انباشته (chip قابل حذف) جمع می‌شوند و بین چند بخش انباشته می‌مانند. تاریخ/ساعت شروع/پایان دستی است. (این الگو **قبلاً پیاده شده** — state `selectedServices: {uuid,name}[]`، endpointهای `GET /api/v1/service-sections` و `GET /api/v1/service-items/{sectionUuid}`.) +- **سرویسی (`service`)**: از کامپوننت `assets/admin/components/appointments/ServiceSlotPicker.tsx` استفاده می‌شود که سرویس‌ها را **تخت** (بدون بخش) از `GET /api/v1/appointment-booking-services/{doctorUuid}` می‌گیرد؛ کاربر یک/چند سرویس را تیک می‌زند، مدت کل = مجموع `duration_minutes` سرویس‌ها، و زمان‌های خالیِ پیشنهادی از `GET /api/v1/appointment-service-slots` می‌آید. + +پزشک نمونه: `ab747d75-2114-42b8-9e6d-abdaa338edbe` (سرویس‌ها در بخش‌های «زیبایی»، «لیزر»). + +## مشکل / هدف + +۱. در حالت **سرویسی**، انتخاب سرویس هم باید مثل حالت اسلاتی «بخش → سرویس» شود (نه لیست تخت): +- انتخاب بخش (Select/Autocomplete) → نمایش فقط سرویس‌های همان بخش → افزودن به لیست انباشته → انباشت بین چند بخش → حذف هر سرویس. +- در chip سرویس انتخاب‌شده، **نام بخش کنار نام سرویس** نشان داده شود (مثل «زیبایی → بوتاکس»). +- محاسبهٔ مدت/اسلات سرویسی باید **حفظ** شود (`appointment-service-slots`). + +۲. **زمان متوسط سرویس (duration) قابل ویرایش توسط منشی، فقط برای همان نوبت**: +- هر سرویس `duration_minutes` پیش‌فرض از تنظیمات سرویس دارد. +- نوبت‌دهی آنلاین (سایت عمومی، بیمار): غیرقابل تغییر. +- نوبت‌دهی پنل (منشی/کلینیک/پزشک): منشی بتواند مدت هر سرویس را **فقط برای این نوبت** ویرایش کند؛ مقدار پیش‌فرض سرویس در تنظیمات (`ServiceItem.durationMinutes`) **نباید** تغییر کند. +- کنار هر سرویس انتخاب‌شده مدتش نمایش داده شود و در پنل قابل ویرایش باشد. مجموع مدت (و در نتیجه اسلات‌های پیشنهادی + `slot_end` نهایی) باید بر اساس مقدارِ override محاسبه شود. + +## فایل‌های مرتبط + +| فایل | نقش | +|------|-----| +| `src/Appointment/Controller/AppointmentController.php` | `bookingServices` (خط ۲۰۲) — افزودن `service_section` به هر سرویس؛ `serviceSlots` (خط ۱۴۵) — پذیرش override مدت | +| `src/Appointment/Controller/MyAppointmentsController.php` | `createAppointment` (`POST /api/v1/my/appointment`) — پذیرش مدتِ override هنگام محاسبهٔ `slot_end` سرویسی | +| `src/Admin/Controller/AdminApiController.php` | `createAppointment` (`POST /api/v1/admin/appointment`) — همان منطق override | +| `assets/admin/hooks/useDoctorBookingServices.ts` | type `BookingService` + استخراج `section` | +| `assets/admin/components/appointments/ServiceSlotPicker.tsx` | بازطراحی UI انتخاب سرویس به «بخش → سرویس + مدتِ قابل‌ویرایش + chip» | +| `assets/admin/pages/AppointmentCreatePage.tsx` | اتصال payload (مدت override) در `create` mutation | +| `docs/api/appointment.md` | مستند تغییرات `appointment-booking-services`، `appointment-service-slots`، `my/appointment` | +| `tests/Appointment/*` | تست backend (section در پاسخ، override مدت در اسلات و ثبت) | +| `assets/admin/components/appointments/ServiceSlotPicker.test.tsx` + `assets/admin/pages/AppointmentCreatePage.test.tsx` | تست frontend | + +## وضعیت فعلی + +### backend: `bookingServices` — سرویس تخت، بدون بخش +```php +// src/Appointment/Controller/AppointmentController.php:213 +$services = array_map(fn(\App\ClinicService\Entity\ServiceItem $i) => [ + 'uuid' => $i->getUuid(), + 'name' => $i->getName(), + 'duration_minutes' => $i->getDurationMinutes(), + 'price_rials' => $i->getPriceRials(), +], $this->itemRepo->findBookableByEntity('doctor', $doctor->getId())); +``` +`ServiceItem::getSection(): ServiceSection` موجود است (`getUuid()`, `getName()`). + +### backend: `serviceSlots` — مدت کل فقط از duration پیش‌فرض +```php +// src/Appointment/Controller/AppointmentController.php:170 +$totalMinutes = 0; +foreach ($uuids as $u) { + $item = $this->itemRepo->findByUuid($u); + // ... اعتبارسنجی bookable/duration ... + $totalMinutes += (int) $item->getDurationMinutes(); +} +// ... +'total_duration_minutes' => $totalMinutes, +'start_times' => $this->slotCalculator->getServiceStartTimes($doctor, $date, $totalMinutes), +``` + +### backend: `my/appointment` — بازمحاسبهٔ slot_end از duration پیش‌فرض +```php +// src/Appointment/Controller/MyAppointmentsController.php (createAppointment) +$computeDuration = (bool) ($data['duration_from_services'] ?? false); +if (!empty($serviceUuids) && !$isReserve) { + $totalMinutes = 0; + foreach ($serviceUuids as $u) { + $item = $this->itemRepo->findByUuid($u); + if ($computeDuration) { + // ... اعتبارسنجی ... + $totalMinutes += (int) $item->getDurationMinutes(); + } + $serviceItems[] = $item; + } + if ($computeDuration) { $slotEnd = $slotStart + $totalMinutes * 60; } +} +``` + +### frontend: `BookingService` type — بدون section +```ts +// assets/admin/hooks/useDoctorBookingServices.ts +export interface BookingService { + uuid: string; + name: string; + duration_minutes: number | null; + price_rials: number; +} +``` + +### frontend: `ServiceSlotPicker` — لیست تخت با تیک، بدون بخش، مدت غیرقابل‌ویرایش +```tsx +// assets/admin/components/appointments/ServiceSlotPicker.tsx (خلاصه) +const [serviceUuids, setServiceUuids] = useState([]); +// slotsQ: GET /api/v1/appointment-service-slots?doctor_uuid=..&date=..&service_item_uuids[]=.. +// services.map(...) → دکمهٔ تیک‌دار؛ چیدنِ start_times؛ onSelect({serviceUuids, slot}) +``` + +## وظایف + +### ۱. backend — افزودن بخش به پاسخ `appointment-booking-services` + +در `bookingServices`، هر سرویس `service_section` بگیرد: +```php +$services = array_map(function (\App\ClinicService\Entity\ServiceItem $i) { + $section = $i->getSection(); + return [ + 'uuid' => $i->getUuid(), + 'name' => $i->getName(), + 'duration_minutes' => $i->getDurationMinutes(), + 'price_rials' => $i->getPriceRials(), + 'service_section' => ['uuid' => $section->getUuid(), 'name' => $section->getName()], + ]; +}, $this->itemRepo->findBookableByEntity('doctor', $doctor->getId())); +``` +- سازگاری عقب‌رو: افزودنِ فیلد است، مصرف‌کنندهٔ سایت عمومی (`nobat724_front`) نمی‌شکند. + +### ۲. backend — پذیرش override مدت در `serviceSlots` + +`serviceSlots` باید علاوه بر مدت پیش‌فرض، یک override اختیاری بپذیرد تا اسلات‌ها بر اساس مدتِ ویرایش‌شدهٔ منشی چیده شوند. الگوی پیشنهادی: پارامتر `durations[]=` (map) یا `total_duration_minutes` مستقیم. +```php +// اگر durations[uuid] آمده و > 0 بود، به‌جای getDurationMinutes همان استفاده شود +$overrides = (array) $request->query->all('durations'); // uuid => minutes +// در حلقه: +$dur = isset($overrides[$u]) && (int)$overrides[$u] > 0 + ? (int) $overrides[$u] + : (int) $item->getDurationMinutes(); +if ($dur <= 0) { /* 422 مدت تعریف نشده */ } +$totalMinutes += $dur; +``` +- اعتبارسنجی: override باید عدد مثبت باشد؛ مقدار نامعتبر ⇒ `422`. +- **مقدار پیش‌فرض سرویس تغییر نکند** — override فقط در محاسبهٔ همین درخواست استفاده شود (هیچ `set`/`save` روی `ServiceItem`). + +### ۳. backend — اعمال override مدت هنگام ثبت نوبت + +در `MyAppointmentsController::createAppointment` و `AdminApiController::createAppointment`، وقتی `duration_from_services=true`، بازمحاسبهٔ `slot_end` باید مدتِ override را لحاظ کند تا با اسلاتی که منشی انتخاب کرده هم‌خوان بماند. یک فیلد جدید در payload، مثلاً `service_durations: { "": }`: +```php +$durations = (array) ($data['service_durations'] ?? []); // uuid => minutes +// در حلقهٔ محاسبهٔ مدت: +$dur = isset($durations[$u]) && (int)$durations[$u] > 0 + ? (int) $durations[$u] + : (int) $item->getDurationMinutes(); +$totalMinutes += $dur; +``` +- منطق پیوستِ چند سرویس (`addServiceItem`) و پرچم `duration_from_services` که قبلاً پیاده شده، حفظ شود. +- **مهم — سازگاری قیمت/گزارش**: بررسی شود آیا مدتِ override باید روی خودِ نوبت ذخیره شود (برای نمایش/گزارش بعدی). اگر بله، به Entity `Appointment` یک ستون/فیلد برای مدتِ مؤثر یا map مدت‌ها اضافه شود (⇒ **migration**). اگر ذخیره لازم نیست و فقط `slot_end` کافی است، ذخیرهٔ اضافه لازم نیست — این تصمیم را در زمان اجرا بر اساس نیاز گزارش‌گیری مشخص کن و در پرامپت‌کننده تأیید بگیر. + +### ۴. frontend — type و hook + +`BookingService` را با بخش گسترش بده: +```ts +export interface BookingService { + uuid: string; + name: string; + duration_minutes: number | null; + price_rials: number; + service_section: { uuid: string; name: string }; +} +``` + +### ۵. frontend — بازطراحی `ServiceSlotPicker` به «بخش → سرویس» + +منطق slot/مدت را نگه دار، فقط UIِ انتخاب سرویس را عوض کن — از همان الگوی حالت اسلاتیِ `AppointmentCreatePage.tsx` تقلید کن: +- گروه‌بندی `services` بر اساس `service_section.uuid` (client-side؛ نیازی به endpoint جدید نیست چون همهٔ سرویس‌های bookable یکجا آمده‌اند). +- Select/Autocomplete بخش (`SearchableSelect`) → نمایش سرویس‌های همان بخش به‌صورت چک‌باکس → افزودن به `selected: { uuid; name; section: string; duration: number }[]` (انباشته، بین چند بخش). +- chip قابل حذف با نمایش «بخش → سرویس» و مدت؛ در حالت پنل (منشی) مدت با `DigitInput`/عدد قابل ویرایش. +- مجموع مدت از `selected` (با override) محاسبه و در query `appointment-service-slots` به‌صورت `durations[uuid]=minutes` ارسال شود تا `start_times` هماهنگ بماند. +- `onSelect` باید `serviceUuids` + `durations` map + `slot` را بالا بفرستد. + +### ۶. frontend — payload در `AppointmentCreatePage` + +در `create` mutation، حالت سرویسی علاوه بر `service_item_uuids` و `duration_from_services:true`، در صورت override منشی `service_durations: { uuid: minutes }` هم بفرستد. +- تشخیص «منشی/پنل بودن» برای فعال‌کردن ویرایش مدت: از نقش کاربر (`useAuthStore().primaryRole`) — همهٔ نقش‌های پنل (admin/clinic/doctor/secretary) مجازند؛ این صفحه اصلاً پنل است، پس ویرایش مدت همیشه در این صفحه فعال است (محدودیت «غیرقابل‌تغییر» فقط مربوط به سایت عمومی `nobat724_front` است، نه این صفحه). + +## نکات مهم + +- **عدم تغییر پیش‌فرض سرویس**: override مدت هرگز نباید `ServiceItem.durationMinutes` را در دیتابیس تغییر دهد — نه در `serviceSlots`، نه در ثبت نوبت. فقط در محاسبهٔ همان درخواست/نوبت. +- **حفظ منطق موجود**: پرچم `duration_from_services`, تابع `addServiceItem` (چند سرویس)، و جریان اسلاتیِ فعلی نباید بشکنند. حالت اسلاتی دست‌نخورده بماند. +- **سازگاری مصرف‌کننده‌ها**: `appointment-booking-services` و `appointment-service-slots` توسط `nobat724_front` هم مصرف می‌شوند (`services/response.js`). افزودن فیلد (`service_section`) و پارامتر اختیاری (`durations`) عقب‌رو-سازگار است؛ سایت عمومی نباید override را فعال کند (بیمار مجاز به تغییر مدت نیست). +- **پاسخ‌ها**: با `$this->success(...)` / `$this->error(ErrorCodes::..., msg, status, field)` مطابق `BaseController`. +- **الگوی frontend**: `SearchableSelect` (نه `