From ed516c81a8f6284691f2397a04ac435456ca97be Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Wed, 22 Jul 2026 16:43:56 +0330 Subject: [PATCH] feat: Enhance appointment management by decoupling online booking toggle for admin context - Introduced management mode for appointment slots, allowing doctors, admins, and clinic managers to view and book slots regardless of the online booking status. - Updated SlotCalculatorService to accept a management context parameter, bypassing online booking restrictions. - Modified appointment-related endpoints to handle management context and ensure proper authorization checks. - Added tests to verify that management users can access slots even when online booking is disabled, while public users are still restricted. - Improved documentation for API endpoints to reflect new management parameters and behaviors. --- ...ix-appointment-management-online-toggle.md | 232 ++++++++++++++++++ .../admin/components/NewAppointmentDrawer.tsx | 2 +- .../appointments/ServiceSlotPicker.tsx | 2 +- assets/admin/components/layout/Sidebar.tsx | 31 +-- .../components/schedule/ScheduleSection.tsx | 46 ++-- .../pages/AppointmentSettingsPage.test.tsx | 41 +++- .../admin/pages/AppointmentSettingsPage.tsx | 73 +++++- assets/admin/pages/AppointmentsPage.tsx | 4 +- config/packages/security.yaml | 6 +- docs/api/appointment.md | 18 +- src/Admin/Controller/AdminApiController.php | 2 +- .../Controller/AppointmentController.php | 50 +++- .../Controller/MyAppointmentsController.php | 2 +- .../Security/AppointmentAccessChecker.php | 61 +++++ .../Service/SlotCalculatorService.php | 48 ++-- .../OnlineBookingManagementTest.php | 123 ++++++++++ 16 files changed, 658 insertions(+), 83 deletions(-) create mode 100644 .claude/prompt/fix-appointment-management-online-toggle.md create mode 100644 tests/Appointment/OnlineBookingManagementTest.php diff --git a/.claude/prompt/fix-appointment-management-online-toggle.md b/.claude/prompt/fix-appointment-management-online-toggle.md new file mode 100644 index 00000000..a1b7a0a4 --- /dev/null +++ b/.claude/prompt/fix-appointment-management-online-toggle.md @@ -0,0 +1,232 @@ +# اصلاح باگ‌ها و بهبود منطق نوبت‌دهی (پنل مدیریت مستقل از نوبت‌دهی آنلاین + Location پزشک عضو کلینیک + منوی رزرو) + +## پروژه + +`clinicpro` (Backend Symfony + پنل ادمین React). سه بخش مستقل ولی مرتبط. + +--- + +## زمینه کلی + +سه باگ در جریان نوبت‌دهی که همه از یک ریشه می‌آیند: منطق «رزرو عمومی بیمار از سایت» با منطق «مدیریت نوبت توسط دکتر/منشی/ادمین در پنل» تفکیک نشده است. + +- اسلات‌ها هم برای سایت عمومی و هم برای پنل ادمین از **یک موتور واحد** تولید می‌شوند: `SlotCalculatorService`. هیچ مسیر جدا برای admin وجود ندارد. +- اندپوینت‌های اسلات (`/api/v1/appointment-slots`, `appointment-service-slots`, `month-availability`) در `security.yaml` **عمومی (`PUBLIC_ACCESS`)** هستند و بدون auth اجرا می‌شوند؛ پنل ادمین هم همان اندپوینت‌ها را صدا می‌زند. + +نتیجه: هر شرطی که برای سایت گذاشته شده (مثل `online_booking_enabled`) به‌اشتباه روی پنل هم اعمال می‌شود. + +--- + +## وظیفه ۱ — پنل مدیریت باید مستقل از `online_booking_enabled` نوبت را نشان دهد و ثبت کند + +### مشکل + +وقتی «نوبت‌دهی آنلاین» در `/admin/settings/appointment-settings` خاموش شود، دکتر/منشی در `/admin/appointments` (و صفحه رزرو) دیگر اسلات نمی‌بینند و نوبت ثبت نمی‌کنند. پیام «خارج از بازهٔ نوبت‌دهی / نوبت‌دهی آنلاین خاموش است» نمایش داده می‌شود. + +### ریشه — کد فعلی + +فایل: `src/Appointment/Service/SlotCalculatorService.php` + +گیت مرکزی در `isWithinBookingWindow()` (حدود خط ۲۸۹–۳۰۶): + +```php +private function isWithinBookingWindow(Doctor $doctor, int $dayStart, ?Clinic $clinic): bool +{ + $todayStart = (int) strtotime('today 00:00:00'); + if ($dayStart < $todayStart) { + return false; + } + $meta = $this->getBookingMeta($doctor, $clinic); + if (!($meta['online_booking_enabled'] ?? true)) { // <-- این خط پنل را هم می‌بندد + return false; + } + // ... در ادامه: محدودیت سقف روزهای آیندهٔ مجاز رزرو (advance window) +} +``` + +که در `buildAllSessions()` صدا زده می‌شود (حدود خط ۳۲۴): + +```php +if (!$this->isWithinBookingWindow($doctor, $dayStart, $clinic)) { + return []; +} +``` + +همهٔ متدهای اسلات از `buildAllSessions()` عبور می‌کنند: `getAvailableSlots()`, `getAllSlotsWithAvailability()`, `hasAnyAvailability()`, `getServiceStartTimes()`. یک چک دوم هم در `findNextAvailableStart()` (حدود خط ۱۹۰) هست. + +همچنین `POST /api/v1/appointment` (`book()`) و `explainEmptyDay()` مسیرِ «disabled» را به‌صورت `EMPTY_OUTSIDE_WINDOW = 'outside_window'` گزارش می‌کنند. + +### راه‌حل — افزودن «کانتکست مدیریت» (`$forManagement`) + +یک پارامتر بولی `bool $forManagement = false` را از اندپوینت تا موتور اسلات نخ کن. وقتی `true` باشد، **فقط** گیت `online_booking_enabled` و محدودیت سقف روزهای آیندهٔ رزرو (advance window) نادیده گرفته شوند. سایر قواعد (تعطیلی/holiday، روز تعطیلِ شیفت/day_off، override، اسلاتِ گذشتهٔ همان روز که `start < now`) دست‌نخورده بمانند. + +> نکته: چک `$dayStart < $todayStart` (روز کاملاً گذشته) را نگه دار مگر لازم باشد ثبت گذشته؛ در این تسک فقط توگل آنلاین و advance-window را برای مدیریت باز کن. ثبت نوبتِ گذشته خارج از این تسک است. + +۱. امضای متدها را گسترش بده (پیش‌فرض `false` تا سایت عمومی تغییری نکند): + +```php +public function getAllSlotsWithAvailability(Doctor $doctor, string $date, ?Clinic $clinic = null, bool $forManagement = false): array +public function getServiceStartTimes(Doctor $doctor, string $date, int $durationMinutes, ?Clinic $clinic = null, bool $forManagement = false): array +public function hasAnyAvailability(Doctor $doctor, string $date, ?Clinic $clinic = null, bool $forManagement = false): bool +public function getAvailableSlots(Doctor $doctor, string $date, ?Clinic $clinic = null, bool $forManagement = false): array +private function buildAllSessions(Doctor $doctor, string $date, ?Clinic $clinic, bool $forManagement = false): array +private function isWithinBookingWindow(Doctor $doctor, int $dayStart, ?Clinic $clinic, bool $forManagement = false): bool +``` + +۲. در `isWithinBookingWindow()` توگل و advance-window را با `$forManagement` مشروط کن: + +```php +$meta = $this->getBookingMeta($doctor, $clinic); +if (!$forManagement && !($meta['online_booking_enabled'] ?? true)) { + return false; +} +// advance window (سقف روزهای آیندهٔ مجاز) هم فقط وقتی !$forManagement اعمال شود +``` + +۳. اندپوینت‌ها (`src/Appointment/Controller/AppointmentController.php`): وقتی درخواست از پنل مدیریت است، `forManagement` را پاس بده. + +- روش تشخیص: پارامتر query `management=1` **به‌علاوهٔ** احراز اینکه کاربرِ لاگین‌کرده واقعاً به این پزشک/کلینیک دسترسی مدیریت دارد. صرفِ وجود پارامتر کافی نیست — چون اندپوینت عمومی است، اگر کاربر لاگین نکرده یا دسترسی ندارد، `management` نادیده گرفته شود و مثل سایت رفتار کند (fail-safe به عمومی). +- برای احراز دسترسی از منطق موجود استفاده کن؛ **API جدید نساز**. مرجع موجود: `denyDoctorAccess()` در `AppointmentSettingsController.php:511-522` و `AppointmentAccessChecker`/`SecretaryPermissionChecker`. یک helper خصوصی در کنترلر بساز: + +```php +private function isManagementContext(Request $request, Doctor $doctor, ?Clinic $clinic): bool +{ + if ($request->query->get('management') !== '1') return false; + $user = $this->getUser(); // ممکن است null باشد (اندپوینت عمومی) + if (!$user instanceof User) return false; + // admin | خودِ دکتر | منشی/مدیر کلینیک با دسترسی appointment + // از همان چکِ AppointmentAccessChecker / permChecker موجود استفاده کن، تکراری ننویس +} +``` + +سپس در `slots()`: + +```php +$clinic = $this->bookingClinic($doctor, $request->query->get('clinic_uuid')); +$forManagement = $this->isManagementContext($request, $doctor, $clinic); +$sessions = $this->slotCalculator->getAllSlotsWithAvailability($doctor, $date, $clinic, $forManagement); +``` + +همین کار برای `serviceSlots()` و `monthAvailability()`. + +۴. `book()` (`POST /api/v1/appointment`, نیازمند auth): وقتی ثبت‌کننده دکتر/منشی/ادمینِ دارای دسترسی است، چک `online_booking_enabled` را رد کن. اگر `book()` مستقیم یا غیرمستقیم از `getAvailableSlots()` برای اعتبارسنجی اسلات استفاده می‌کند، `forManagement=true` را برای کاربر مدیریتی پاس بده تا نوبت دستی رد نشود. اگر بیمار خودش ثبت می‌کند، رفتار فعلی حفظ شود. + +### فرانت‌اند + +فایل‌های صفحهٔ نوبت‌ها و رزرو در پنل: `assets/admin/pages/ReserveAppointmentsPage.tsx` و صفحهٔ `/admin/appointments`. سرویس فراخوانی اسلات را پیدا کن (`assets/admin/lib/api.ts` + هوک/کوئری اسلات) و در تمام فراخوانی‌های اسلات/سرویس‌اسلات/month-availability از داخل پنل، پارامتر `management=1` اضافه کن. چون `lib/api.ts` توکن JWT را از `localStorage['clinicpro-auth']` خودکار ضمیمه می‌کند، بک‌اند کاربر را می‌شناسد. + +--- + +## وظیفه ۲ — انتقال «رزرو نوبت» به زیرمنوی «نوبت‌دهی» + +### مشکل + +`/admin/appointments/reserve` الان آیتم منوی جداگانه («نوبت‌های رزرو») است، نه زیرمنوی بخش نوبت‌دهی. همهٔ عملیات نوبت باید زیر یک بخش جمع شود. + +### کد فعلی + +فایل: `assets/admin/components/layout/Sidebar.tsx` + +زیرمنوی مشترک نوبت (خط ۴۸–۵۱): + +```tsx +const APPOINTMENTS_CHILDREN: SubItem[] = [ + { to: "/admin/appointments", label: "نوبت ها", icon: CalendarDaysIcon }, + { to: "/admin/appointments/new", label: "افزودن نوبت", icon: PlusIcon }, +]; +``` + +آیتم reserve الان جدا و تکراری در هر نقش تعریف شده: admin (خط ۱۲۲–۱۲۶)، clinic (خط ۲۳۸)، doctor (خط ۳۸۳). + +### راه‌حل + +آیتم «رزرو نوبت» را به‌عنوان فرزند سوم به `APPOINTMENTS_CHILDREN` اضافه کن و آیتم‌های مستقلِ reserve را در سه نقش حذف کن: + +```tsx +const APPOINTMENTS_CHILDREN: SubItem[] = [ + { to: "/admin/appointments", label: "نوبت ها", icon: CalendarDaysIcon }, + { to: "/admin/appointments/new", label: "افزودن نوبت", icon: PlusIcon }, + { to: "/admin/appointments/reserve", label: "رزرو نوبت", icon: /* آیکن مناسب موجود */ }, +]; +``` + +- برچسب را یکدست کن («رزرو نوبت» یا همان «نوبت‌های رزرو» — یکی را در کل انتخاب کن). +- مسیر route در `assets/admin/App.tsx:176` تغییر نمی‌کند (همان `appointments/reserve` با `RoleRoute roles={['admin','clinic','doctor','secretary']}`). فقط منو اصلاح می‌شود. +- برای نقش دکترِ مهمانِ کلینیک (`scope=clinic`, خط ۶۱–۸۳) اگر reserve نباید نمایش داده شود، `APPOINTMENTS_CHILDREN` مشترک را دستکاری نکن؛ برای آن شاخه یک آرایهٔ children جدا بساز که آیتم reserve را ندارد (تا رفتار فعلی‌اش نشکند). گیت `can("appointments","view")` حفظ شود. + +--- + +## وظیفه ۳ — پزشک عضو کلینیک نباید مجبور به ثبت آدرس مستقل باشد + +### مشکل + +در `/admin/appointment-settings` برای پزشکی که داخل کلینیک اضافه شده، پیام «ابتدا آدرس مطب را ثبت کنید» و «برنامه کاری نیاز به حداقل یک مکان نوبت دارد» نمایش داده می‌شود. این پزشک باید از مکان‌های کلینیک استفاده کند. + +### ریشه — کد فعلی + +این دو پیام **فرانت‌اند** هستند، نه بک‌اند: + +فایل: `assets/admin/components/schedule/ScheduleSection.tsx` + +```tsx +// خط ۵۹۸ +if (addresses.length === 0) return ( /* «ابتدا آدرس مطب را ثبت کنید» + «برنامه کاری نیاز به حداقل یک مکان نوبت دارد» */ ); +``` +(خطوط ۶۰۴–۶۰۵ متن، و تکرار در ۱۰۵۶–۱۰۵۷ برای تب date-override) + +`addresses` از کوئری خط ۱۳۱۴–۱۳۲۰ می‌آید که اندپوینت زیر را صدا می‌زند: + +`GET /api/v1/appointment-settings/available-locations/{doctorUuid}` (با `?clinic_uuid=` اختیاری) + +کنترلر: `AppointmentSettingsController::availableLocations()` (`src/Appointment/Controller/AppointmentSettingsController.php:478-498`) که از `DoctorAddressRepository::findForContext($doctor, $clinic?->getId())` استفاده می‌کند. + +منطق resolver — `src/Doctor/Repository/DoctorAddressRepository.php:71-88`: + +```php +// clinicId === null → فقط آدرس‌های personal همان دکتر (type=personal) +// clinicId set → فقط آدرس‌های همان کلینیک (type=clinic) +// این دو ست هیچ‌وقت union نمی‌شوند +``` + +### تشخیص عضویت کلینیک + +- عضویت = جوین ManyToMany `clinic_doctors`؛ تست: `Clinic::hasDoctor($doctor)` (`src/Clinic/Entity/Clinic.php:157`). +- آدرس‌ها: `DoctorAddress` با `type` = `personal` (متعلق به `doctor_id`) یا `clinic` (متعلق به `clinic_id`) — `src/Doctor/Entity/DoctorAddress.php:17-18`. + +### راه‌حل + +منطق درست انتخاب مکان (باید رعایت شود): + +1. اگر پزشک **مطب مستقل** دارد (کانتکست شخصی، `clinic_uuid` نداریم و آدرس personal دارد) → از آدرس‌های personal استفاده شود. +2. اگر پزشک **عضو کلینیک** است و در کانتکست کلینیک تنظیم می‌کند → از آدرس‌های فعال همان کلینیک استفاده شود. +3. خطای «ابتدا آدرس مطب را ثبت کنید» فقط وقتی نمایش داده شود که: پزشک نه مکان مستقل دارد و نه عضو کلینیکی با مکان فعال است. + +**بک‌اند:** بررسی کن `findForContext` در کانتکست کلینیک واقعاً آدرس‌های کلینیک را برمی‌گرداند (باید). اگر برای پزشک عضو کلینیک، UI بدون انتخاب کلینیک باز می‌شود و کانتکست شخصی خالی است، مشکل در انتخاب کانتکست پیش‌فرض فرانت است، نه دیتابیس. + +**فرانت‌اند — گره اصلی:** `ScheduleSection.tsx` باید کانتکست درست را انتخاب کند: + +- اگر پزشک عضو یک/چند کلینیک است، لیست کانتکست‌ها (مطب شخصی + هر کلینیک عضو) را از دادهٔ پروفایل پزشک بگیر و کانتکست فعال را با `clinic_uuid` صحیح به کوئری `available-locations` بده. +- پیام خالی‌بودن را فقط زمانی نشان بده که در **کانتکست انتخاب‌شدهٔ فعلی** هیچ آدرسی نباشد — و متن را به کانتکست وابسته کن: + - کانتکست شخصی خالی → «ابتدا آدرس مطب را ثبت کنید». + - کانتکست کلینیک خالی → پیام مناسب («این کلینیک هنوز مکان نوبت‌دهی فعال ندارد») به‌جای الزام پزشک به ثبت آدرس شخصی. +- اگر پزشک هیچ مطب شخصی ندارد ولی عضو کلینیک با مکان فعال است، کانتکست پیش‌فرض روی همان کلینیک برود تا پیام اشتباه ظاهر نشود. + +منبع تشخیص کانتکست‌های پزشک را در دادهٔ موجود پروفایل/پزشک پیدا کن (کلینیک‌های عضو). اگر اندپوینتی که کلینیک‌های عضو پزشک را می‌دهد وجود ندارد، **اول بگرد**؛ فقط اگر نبود، توسعه بده (طبق قاعدهٔ ۲ پروژه). + +--- + +## نکات مهم (رعایت الزامی) + +- **API جدید فقط در نهایت.** اول اندپوینت/منطق موجود را بگرد و توسعه بده (قاعدهٔ ۲ `clinicpro/CLAUDE.md`). برای احراز دسترسی مدیریت از `AppointmentAccessChecker` / `denyDoctorAccess` موجود استفاده کن. +- **Fail-safe عمومی:** پارامتر `management=1` بدون کاربرِ احرازشده و دارای دسترسی، هرگز نباید توگل را دور بزند — وگرنه یک نشت امنیتی است که اسلات‌های خاموش را به عموم نشان می‌دهد. +- سایت عمومی `nobat724_front` همین اندپوینت‌های اسلات را مصرف می‌کند. چون پارامترها پیش‌فرض `false`/غیرمدیریتی‌اند، رفتار سایت نباید تغییر کند. **این را تست کن.** +- تمام رشته‌های UI فارسی، تاریخ‌ها Jalali، RTL. از کامپوننت‌ها و توکن‌های موجود استفاده کن؛ طراحی جدید نساز ([new-pages-follow-existing-design]). +- SearchableSelect برای انتخاب کانتکست/کلینیک؛ هرگز `