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) <noreply@anthropic.com>
15 KiB
انتخاب سرویس بر اساس بخش + ویرایش مدت توسط منشی — حالت نوبتدهی سرویسی
زمینه
صفحهٔ ایجاد نوبت پنل ادمین (assets/admin/pages/AppointmentCreatePage.tsx) دو حالت دارد که با booking_mode پزشک تعیین میشود (useDoctorBookingServices):
- اسلاتی (
slot): کاربر بخش را انتخاب میکند، سپس سرویسهای همان بخش بهصورت چکباکس نشان داده میشوند، انتخابها در یک لیستِ انباشته (chip قابل حذف) جمع میشوند و بین چند بخش انباشته میمانند. تاریخ/ساعت شروع/پایان دستی است. (این الگو قبلاً پیاده شده — stateselectedServices: {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 — سرویس تخت، بدون بخش
// 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 پیشفرض
// 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 پیشفرض
// 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
// assets/admin/hooks/useDoctorBookingServices.ts
export interface BookingService {
uuid: string;
name: string;
duration_minutes: number | null;
price_rials: number;
}
frontend: ServiceSlotPicker — لیست تخت با تیک، بدون بخش، مدت غیرقابلویرایش
// assets/admin/components/appointments/ServiceSlotPicker.tsx (خلاصه)
const [serviceUuids, setServiceUuids] = useState<string[]>([]);
// 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 بگیرد:
$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[<service_uuid>]=<minutes> (map) یا total_duration_minutes مستقیم.
// اگر 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: { "<uuid>": <minutes> }:
$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 را با بخش گسترش بده:
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) محاسبه و در queryappointment-service-slotsبهصورتdurations[uuid]=minutesارسال شود تاstart_timesهماهنگ بماند. onSelectبایدserviceUuids+durationsmap +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(نه<select>خام)،TanStack Queryبرای دیتا، state لوکال React برای انتخابها،DigitInputبرای ورودی عددی مدت. chipها با توکنهای--primary-soft/--primaryمطابق UIِ فعلی. - edge caseها: سرویس بدون
duration_minutes(⇒ 422 یا فیلترشدن)؛ بخشِ بدون سرویس bookable؛ override صفر/منفی/غیرعدد (رد شود، به پیشفرض برگردد)؛ حذف همهٔ سرویسها (اسلات خالی، دکمهٔ ثبت غیرفعال)؛ انتخاب سرویس از دو بخش با مدتهای override متفاوت (مجموع درست). - تست (اجباری، موفق + خطا + مرزی):
- backend:
appointment-booking-servicesفیلدservice_sectionرا برمیگرداند؛appointment-service-slotsباdurations[uuid]مدت کل وstart_timesرا بر اساس override میدهد؛ ثبت نوبت باservice_durationsمقدارslot_endرا بر اساس override میسازد و پیشفرض سرویس در DB تغییر نمیکند. - frontend: انتخاب بخش → نمایش سرویسهای همان بخش؛ انباشت بین دو بخش؛ chip «بخش → سرویس»؛ ویرایش مدت یک سرویس و بازتاب در payload؛ حذف سرویس.
- backend:
- مستندات:
docs/api/appointment.mdبرای هر سه endpoint بهروز شود (فیلدservice_section، پارامترdurations، فیلدservice_durationsدر body ثبت). - بعد از اتمام:
npx tsc --noEmit،ddev exec bin/console lint:container,ddev exec bin/phpunit tests/Appointment,npx vitest runتستهای مربوطه،ddev exec yarn dev— همه سبز.