Files
clinicpro/.claude/prompt/appointment-service-mode-section-picker.md
hamedandClaude Opus 4.8 e00fe997f9 feat: section-based service picker + per-appointment duration override (service mode)
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>
2026-07-16 10:41:15 +03:30

15 KiB
Raw Permalink Blame History

انتخاب سرویس بر اساس بخش + ویرایش مدت توسط منشی — حالت نوبت‌دهی سرویسی

زمینه

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

// 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) محاسبه و در 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 (نه <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؛ حذف سرویس.
  • مستندات: 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 — همه سبز.