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

## زمینه

صفحهٔ ایجاد نوبت پنل ادمین (`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<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` بگیرد:
```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[<service_uuid>]=<minutes>` (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: { "<uuid>": <minutes> }`:
```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` (نه `<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` — همه سبز.
