# انتخاب سرویس بر اساس بخش + ویرایش مدت توسط منشی — حالت نوبت‌دهی سرویسی ## زمینه صفحهٔ ایجاد نوبت پنل ادمین (`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` (نه `