# یکسان‌سازی تنظیمات نوبت‌دهی + تب پزشکان در پنل کلینیک ## زمینه تنظیمات نوبت‌دهی امروز فقط برای پزشکِ مستقل در دسترس است. مالک کلینیک نمی‌تواند نوبت‌دهی پزشکان کلینیکش را تنظیم کند: مسیر `/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; ... ``` **کامپوننت اصلی از قبل پارامتری است** — `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 ; } ``` **ورودی منو فقط برای پزشک** — `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>(`/api/v1/clinic/doctor-list/${clinicUuid}`), enabled: !!clinicUuid, }); const doctorList = (doctorsQ.data?.data as any)?.data ?? doctorsQ.data?.data ?? []; const [activeUuid, setActiveUuid] = useState(null); const selected = activeUuid ?? doctorList[0]?.uuid ?? null;
{/* همان الگوی تب در ClinicDoctorsManager */} {doctorList.map(d => ( ))}
{selected && }
``` نکات حیاتی: - `key={selected}` روی `ScheduleSection` **الزامی است** — بدون آن، state داخلی تب (برنامه هفتگی در حال ویرایش) بین پزشک‌ها نشت می‌کند و ممکن است تنظیمات پزشک A روی B ذخیره شود. این دقیقاً همان چیزی است که خواسته «تغییرات هر پزشک فقط روی خودش» را نقض می‌کند. - `clinicUuid` را مثل `ClinicDoctorsPage.tsx` از context نوع `clinic` بگیر، نه مستقیم از `dbUuid` (کاربری که هم پزشک است هم مالک کلینیک، `dbUuid`‌اش ممکن است uuid پزشک باشد و همه فراخوانی‌ها ۴۰۴ شوند). - حالت خالی: کلینیک بدون پزشک → پیام «هیچ پزشکی به این کلینیک متصل نیست» + لینک به `/admin/settings/clinic-doctors`. - اگر تعداد پزشکان زیاد شد، تب‌ها باید افقی اسکرول شوند نه شکسته. ### ۴. Route و منو `assets/admin/App.tsx`: ```tsx } /> ``` مسیر موجود `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` استفاده کن نه `