# یکسان‌سازی تنظیمات نوبت‌دهی + تب پزشکان در پنل کلینیک

## زمینه

تنظیمات نوبت‌دهی امروز فقط برای پزشکِ مستقل در دسترس است. مالک کلینیک نمی‌تواند نوبت‌دهی پزشکان کلینیکش را تنظیم کند: مسیر `/admin/appointment-settings` با `RoleRoute roles={['doctor']}` بسته است، هر ۱۴ endpoint در `AppointmentSettingsController` شرط یکسانِ «پزشک == کاربر جاری یا ادمین» دارند، و هیچ ورودی منویی برای نقش `clinic` وجود ندارد.

خبر خوب: زیرساخت تقریباً کامل است. هر ۱۴ endpoint از قبل uuid پزشک را از path یا body می‌گیرند، و کامپوننت `ScheduleSection` هم `doctorUuid` را به‌صورت prop می‌گیرد. یعنی برای «مالک کلینیک تنظیمات پزشک X را مدیریت کند» فقط سه چیز مانع است: شرط هویت در بک‌اند، گارد route، و نبود ورودی منو.

> پیش‌نیاز: `clinic-doctor-permissions.md` (Entity و چکر مجوز از آنجا می‌آید). اول آن را اجرا کن.

## مشکل / هدف

۱. **پنل شخصی پزشک** باید دقیقاً مثل پزشک مستقل کار کند — هیچ تفاوتی در ساختار و امکانات.
۲. **پنل کلینیک** در «تنظیمات → نوبت‌دهی» باید برای هر پزشک یک تب داشته باشد و با انتخاب تب، تنظیمات همان پزشک را نشان دهد.
۳. **یک پیاده‌سازی واحد** — نه دو نسخه موازی. هر دو حالت باید همان کامپوننت را رندر کنند.
۴. تغییرات هر پزشک فقط روی خودش اثر بگذارد.

## فایل‌های مرتبط

| فایل | نقش |
|------|-----|
| `src/Appointment/Controller/AppointmentSettingsController.php` | هر ۱۴ endpoint تنظیمات نوبت‌دهی |
| `src/Appointment/Entity/WeeklySchedule.php` | `OneToOne` Doctor، unique روی `doctor_id` |
| `src/Appointment/Entity/DateOverride.php` | `ManyToOne` Doctor، unique روی `(doctor_id, date)` |
| `src/Appointment/Entity/Holiday.php` | `ManyToOne` Doctor |
| `assets/admin/pages/DoctorDetailPage.tsx:2058` | `ScheduleSection` — پیاده‌سازی واقعی، داخل یک فایل صفحه |
| `assets/admin/pages/DoctorDetailPage.tsx:1252, 1772, 1944` | `WeeklyScheduleTab` / `DateOverridesTab` / `HolidaysTab` |
| `assets/admin/pages/AppointmentSettingsPage.tsx` | صفحه پزشک مستقل (۳۶ خط، فقط پوسته) |
| `assets/admin/components/FreeVisitPrice.tsx` | قیمت ویزیت — **بدون پارامتر پزشک، فقط JWT-scoped** |
| `assets/admin/App.tsx:232` | route `appointment-settings` با `roles={['doctor']} blockClinicScope` |
| `assets/admin/components/layout/SettingsLayout.tsx:22-35` | `SETTINGS_MENU` (منوی موبایل) |
| `assets/admin/components/layout/PurchaseSubscriptionSidebar.tsx:22` | منوی دسکتاپ تنظیمات — تعریف موازی و جدا |
| `docs/api/appointment-settings.md` | مستند API |

## وضعیت فعلی

**شرط هویت، ۱۴ بار کپی شده** — `src/Appointment/Controller/AppointmentSettingsController.php:75-77` و مشابهش در `:123-125`، `:199-201`، `:406-408`:

```php
if ($doctor->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
    return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
}
```

نسخه‌های فرزند: `$schedule->getDoctor()->getUser()->getId() !== $user->getId()`، و همین برای `$override` و `$holiday`.

`ROLE_CLINIC` در کل این کنترلر یک بار هم استفاده نشده. `ClinicRepository` تزریق شده (`:36`) ولی فقط در `availableLocations` (`:410`) برای لیست آدرس‌ها به کار می‌رود، نه برای مجوز.

**صفحه پزشک مستقل، uuid را از authStore می‌گیرد** — `assets/admin/pages/AppointmentSettingsPage.tsx:12-14, 31`:

```tsx
const doctorUuid = useAuthStore((s) => s.doctorUuid);
const dbUuid     = useAuthStore((s) => s.dbUuid);
const uuid = doctorUuid ?? dbUuid ?? undefined;
...
<ScheduleSection doctorUuid={uuid} />
```

**کامپوننت اصلی از قبل پارامتری است** — `DoctorDetailPage.tsx:2062-2064` و `:1263, 1309-1310`:

```tsx
queryFn: () => api.get(`/api/v1/appointment-settings/weekly-schedule/${doctorUuid}`)
...
? api.patch(`/api/v1/appointment-settings/weekly-schedule/${doctorUuid}`, { schedule: scheduleMap, meta })
: api.post('/api/v1/appointment-settings/weekly-schedule', { doctor_uuid: doctorUuid, schedule: scheduleMap, meta });
```

**گارد route پزشکِ در scope کلینیک را بیرون می‌اندازد** — `assets/admin/App.tsx:117-123`:

```tsx
if (blockClinicScope && primaryRole === 'doctor' && context?.scope === 'clinic') {
  return <Navigate to="/admin/dashboard" replace />;
}
```

**ورودی منو فقط برای پزشک** — `PurchaseSubscriptionSidebar.tsx:22` و `SettingsLayout.tsx` (هر دو باید ویرایش شوند):

```tsx
{ key: 'appointment', label: 'مدیریت نوبت دهی', to: '/admin/appointment-settings', roles: ['doctor'] },
```

## وظایف

### ۱. بک‌اند — یک helper واحد به‌جای ۱۴ شرط تکراری

در `AppointmentSettingsController` یک متد خصوصی اضافه کن و **هر ۱۴ شرط را با آن جایگزین کن**:

```php
private function assertDoctorAccess(Doctor $doctor, User $user): void
{
    if ($user->hasRole('ROLE_ADMIN')) {
        return;
    }
    if ($doctor->getUser()->getId() === $user->getId()) {
        return;
    }
    // مالک کلینیکی که این پزشک عضو آن است
    $clinic = $this->clinicRepo->findByUser($user);
    if ($clinic !== null && $clinic->hasDoctor($doctor)) {
        return;
    }
    throw new AppException(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
}
```

نکات:
- `Clinic::hasDoctor()` از قبل در `src/Clinic/Entity/Clinic.php:156` وجود دارد.
- برای endpointهای فرزند (`{uuid}` = uuid برنامه/override/holiday) همان helper را با `$schedule->getDoctor()` صدا بزن.
- اگر پرامپت مجوزها اجرا شده، برای پزشکِ عضو کلینیک هم مسیر بده: اگر `$user` خودش پزشکِ عضو همان کلینیک است، `ClinicDoctorPermissionChecker::can($user, $clinic, 'appointment_settings', 'update')` را چک کن. برای متدهای GET با `'view'`.
- منطق اعتبارسنجی موجود دست نخورد: `assertModeImmutable` (`:48-54`)، `serviceModeHasNoBookable` (`:56-60`)، `validateSessionsHaveLocation` (`:432-442`).

### ۲. استخراج `ScheduleSection` به فایل مستقل

امروز `ScheduleSection` داخل `assets/admin/pages/DoctorDetailPage.tsx` (خط ۲۰۵۸) تعریف و از آنجا export می‌شود. برای اینکه «یک ساختار واحد» واقعاً یک ماژول باشد و صفحه‌ای از صفحه دیگر import نکند:

- `assets/admin/components/schedule/ScheduleSection.tsx` بساز و `ScheduleSection` + `WeeklyScheduleTab` (`:1252`) + `DateOverridesTab` (`:1772`) + `DateOverrideModal` (`:1632`) + `HolidaysTab` (`:1944`) + `HolidayModal` (`:1871`) و helperهای مربوطه (`BookingMeta` `:101-114`، `calcSlotCount` `:338`، `SessionEditor` `:1118`، `SlotEditor` `:1077`) را به آن منتقل کن.
- `DoctorDetailPage.tsx` و `AppointmentSettingsPage.tsx` هر دو از همان فایل import کنند.
- **هیچ تغییری در منطق نده** — این مرحله صرفاً جابه‌جایی است. بعد از انتقال، `tsc` و `yarn dev` باید بدون خطا رد شوند و رفتار صفحه پزشک مستقل عیناً همان باشد.

### ۳. صفحه تنظیمات نوبت‌دهی کلینیک با تب پزشکان

`assets/admin/pages/ClinicAppointmentSettingsPage.tsx`:

```tsx
// لیست پزشکان کلینیک → تب‌ها → همان ScheduleSection با doctorUuid انتخاب‌شده
const doctorsQ = useQuery({
  queryKey: ['clinic-doctors', clinicUuid],
  queryFn:  () => api.get<ApiResponse<{ data: ClinicDoctorItem[] }>>(`/api/v1/clinic/doctor-list/${clinicUuid}`),
  enabled:  !!clinicUuid,
});
const doctorList = (doctorsQ.data?.data as any)?.data ?? doctorsQ.data?.data ?? [];
const [activeUuid, setActiveUuid] = useState<string | null>(null);
const selected = activeUuid ?? doctorList[0]?.uuid ?? null;

<SettingsLayout active="appointment">
  <div className="seg"> {/* همان الگوی تب در ClinicDoctorsManager */}
    {doctorList.map(d => (
      <button key={d.uuid} className={selected === d.uuid ? 'active' : ''} onClick={() => setActiveUuid(d.uuid)}>
        {d.name}
      </button>
    ))}
  </div>
  {selected && <ScheduleSection key={selected} doctorUuid={selected} />}
</SettingsLayout>
```

نکات حیاتی:
- `key={selected}` روی `ScheduleSection` **الزامی است** — بدون آن، state داخلی تب (برنامه هفتگی در حال ویرایش) بین پزشک‌ها نشت می‌کند و ممکن است تنظیمات پزشک A روی B ذخیره شود. این دقیقاً همان چیزی است که خواسته «تغییرات هر پزشک فقط روی خودش» را نقض می‌کند.
- `clinicUuid` را مثل `ClinicDoctorsPage.tsx` از context نوع `clinic` بگیر، نه مستقیم از `dbUuid` (کاربری که هم پزشک است هم مالک کلینیک، `dbUuid`‌اش ممکن است uuid پزشک باشد و همه فراخوانی‌ها ۴۰۴ شوند).
- حالت خالی: کلینیک بدون پزشک → پیام «هیچ پزشکی به این کلینیک متصل نیست» + لینک به `/admin/settings/clinic-doctors`.
- اگر تعداد پزشکان زیاد شد، تب‌ها باید افقی اسکرول شوند نه شکسته.

### ۴. Route و منو

`assets/admin/App.tsx`:

```tsx
<Route path="settings/appointment-settings"
       element={<RoleRoute roles={['clinic']}><ClinicAppointmentSettingsPage /></RoleRoute>} />
```

مسیر موجود `appointment-settings` (`:232`، `roles={['doctor']} blockClinicScope`) دست‌نخورده بماند — آن پنل شخصی پزشک است و باید دقیقاً مثل امروز کار کند.

**هر دو منو** باید ورودی بگیرند (تعریفشان موازی و جداست):
- `SettingsLayout.tsx:22-35` → `SETTINGS_MENU`: یک آیتم با `roles: ['clinic']` و مقصد `/admin/settings/appointment-settings`. آیتم فعلی `roles: ['doctor']` دست‌نخورده بماند.
- `PurchaseSubscriptionSidebar.tsx:22` → همان.

هر دو آیتم `key: 'appointment'` داشته باشند تا `active="appointment"` در `SettingsLayout` برای هر دو کار کند.

### ۵. تکلیف `FreeVisitPrice`

`assets/admin/components/FreeVisitPrice.tsx` روی `/api/v1/insurance-pricing` کار می‌کند و **هیچ پارامتر پزشکی نمی‌گیرد** — فقط از JWT scope می‌گیرد. اگر آن را داخل تب کلینیک رندر کنی، مالک کلینیک قیمت ویزیتِ خودش را ویرایش می‌کند نه پزشک انتخاب‌شده. یکی از دو کار را بکن و در گزارش صریح بگو کدام:

- **الف)** به endpointهای `/api/v1/insurance-pricing` پارامتر اختیاری `doctor_uuid` اضافه کن (با همان `assertDoctorAccess`) و `FreeVisitPrice` را prop-محور کن. سازگاری عقب‌رو حفظ شود: بدون `doctor_uuid` رفتار امروز.
- **ب)** فعلاً `FreeVisitPrice` را از تب کلینیک حذف کن و در همان‌جا یادداشت بگذار.

گزینه (الف) ارجح است چون خواسته «هیچ تفاوتی بین دو حالت نباشد» است، ولی اگر انتخاب شد باید مستند `docs/api/insurance.md` هم به‌روز شود.

### ۶. تست

`tests/Appointment/ClinicOwnerScheduleAccessTest.php`:
- مالک کلینیک برنامه هفتگی پزشکِ عضو را می‌خواند و PATCH می‌کند → ۲۰۰
- مالک کلینیک روی پزشکی که عضو کلینیکش نیست → ۴۰۳
- پزشک روی برنامه خودش → ۲۰۰ (رگرسیون: رفتار قبلی نشکند)
- پزشک روی برنامه پزشک دیگر → ۴۰۳
- `ROLE_ADMIN` روی هر پزشکی → ۲۰۰
- ذخیره برنامه پزشک A، `WeeklySchedule` پزشک B دست‌نخورده می‌ماند (شرط «فقط روی همان پزشک اثر بگذارد»)
- همین ماتریس برای `date-override` و `holidays`

`docs/api/appointment-settings.md` را به‌روز کن: قاعده جدید دسترسی (مالک/عضو کلینیک) در هر ۱۴ endpoint، و کد خطای ۴۰۳.

## نکات مهم

- `WeeklySchedule` روی `doctor_id` قید `unique` دارد (`Entity:12`) و `OneToOne` است — یعنی هر پزشک دقیقاً یک برنامه دارد و منطق upsert است. اگر تب‌ها `doctorUuid` را درست پاس ندهند، PATCH روی برنامه پزشک اشتباه می‌نشیند و داده‌ی واقعی از بین می‌رود. این پرخطرترین بخش این تسک است.
- `DateOverride` قید `unique(doctor_id, date)` دارد؛ در حالت تب، تداخل تاریخ بین پزشکان معنا ندارد ولی خطای unique را باید به پیام فارسی معنادار تبدیل کنی نه ۵۰۰.
- در booking mode سرویسی، `countBookableByEntity('doctor', $doctor->getId())` (کنترلر `:59`) hard-code روی `'doctor'` است؛ کاتالوگ سرویس خود کلینیک این شرط را برآورده نمی‌کند. اگر پزشکِ عضو کلینیک سرویس شخصی ندارد، حالت سرویسی برایش قابل فعال‌سازی نیست — این را در UI با پیام فارسی روشن کن، نه با خطای خام.
- `booking_mode` بعد از اولین ذخیره قفل می‌شود (`assertModeImmutable` `:48-54` و `modeLocked` در `WeeklyScheduleTab:1282`) — این رفتار در تب کلینیک هم باید دقیقاً همان باشد.
- هر session فعال باید `location_id` داشته باشد (`validateSessionsHaveLocation` `:432-442`)؛ آدرس‌های در دسترس از `GET /api/v1/appointment-settings/available-locations/{doctorUuid}` می‌آید که خودش از `ClinicRepository` تغذیه می‌شود — برای پزشکِ عضو کلینیک، آدرس‌های کلینیک باید در لیست باشند.
- همه controllerها از `BaseController`؛ پاسخ فقط با `$this->success()` / `$this->paginated()` / `$this->error()`.
- تاریخ‌ها Unix timestamp صحیح؛ نمایش شمسی با `formatDate()`.
- Form: React Hook Form + Zod؛ server state: TanStack Query v5؛ برای هر select از `SearchableSelect` استفاده کن نه `<select>` بومی.
- CSS: از کلاس‌های موجود (`seg`، `card`، `btn primary sm`، `field`) استفاده کن؛ کتابخانه جدید اضافه نکن؛ RTL.
- بعد از انتقال کامپوننت‌ها حتماً `ddev exec npx tsc --noEmit` و `ddev exec yarn dev` را اجرا کن — این refactor حجم زیادی import جابه‌جا می‌کند.
