diff --git a/.claude/prompt/doctor-booking-window-and-month-availability.md b/.claude/prompt/doctor-booking-window-and-month-availability.md new file mode 100644 index 00000000..c7565263 --- /dev/null +++ b/.claude/prompt/doctor-booking-window-and-month-availability.md @@ -0,0 +1,176 @@ +# تنظیمات نوبت‌دهی آنلاین دکتر: بازه‌ی رزرو (booking window) + endpoint در‌دسترس‌بودن ماهانه + +## پروژه + +`clinicpro` (Backend + Admin React panel). **این پرامپت اول اجرا شود.** + +> **Cross-repo:** قرارداد API این پرامپت توسط سایت عمومی مصرف می‌شود. پرامپت همتا در سمت فرانت: +> `nobat724_front/.claude/prompt/appointment-calendar-disabled-dates.md` + +## زمینه + +پزشک در پنل ادمین (`assets/admin/pages/DoctorDetailPage.tsx`) از قبل می‌تواند **برنامه‌ی هفتگی**، **date override** (روز تعطیل/سفارشی) و **تعطیلات (holidays)** را تنظیم کند. `SlotCalculatorService` هم این‌ها را با اولویت درست اعمال می‌کند (holiday → override → weekly). پس «۲۷/۰۳/۱۴۰۵ تعطیل است» همین حالا با یک date override (`active:false`) قابل تنظیم است و `appointment-slots` برای آن روز آرایه‌ی خالی برمی‌گرداند. + +اما **دو چیز وجود ندارد:** + +1. **بازه‌ی رزرو آنلاین (booking window):** پزشک نمی‌تواند تعیین کند «تا چند هفته/ماه جلوتر بیمار می‌تواند آنلاین نوبت بگیرد» و نمی‌تواند نوبت‌دهی آنلاین را کلاً خاموش کند. الان هیچ سقفی نیست و `appointment-slots` برای هر تاریخ آینده‌ای اسلات می‌دهد. +2. **در‌دسترس‌بودن ماهانه (month availability):** سایت عمومی برای خاکستری‌کردن روزهای تعطیل/خارج‌از‌بازه روی تقویم، باید بداند کدام روزهای یک ماه قابل‌انتخاب‌اند. الان فقط endpoint تک‌روزه (`appointment-slots`) هست؛ صدا‌زدن آن برای ۳۰ روز سنگین است. یک endpoint که برای یک ماه، آرایه‌ی روزهای غیرفعال را برگرداند لازم است. + +## مشکل / هدف + +۱. افزودن تنظیمات «نوبت‌دهی آنلاین» به پزشک: `online_booking_enabled` (boolean) و `booking_window` (عدد + واحد هفته/ماه). بدون migration — داخل همان `WeeklySchedule.setting` JSON ذخیره شود. +۲. اعمال این بازه در `SlotCalculatorService`: اگر نوبت‌دهی آنلاین خاموش است یا تاریخ خارج از بازه است → اسلات خالی. +۳. endpoint عمومی جدید: `GET /api/v1/appointment-settings/month-availability/{doctorUuid}?year=&month=` که برای یک ماه شمسی **یا** میلادی، روزهای غیرفعال (تعطیل/override بسته/خارج‌از‌بازه/بدون session) را برگرداند تا تقویم سایت آن‌ها را غیرقابل‌انتخاب کند. +۴. UI در پنل ادمین (`DoctorDetailPage.tsx`) برای تنظیم این دو مقدار. + +## فایل‌های مرتبط + +| فایل | نقش | +|------|-----| +| `src/Appointment/Entity/WeeklySchedule.php` | `setting` JSON — محل ذخیره‌ی booking window (بدون فیلد جدید DB) | +| `src/Appointment/Service/SlotCalculatorService.php` | منطق محاسبه‌ی اسلات — اعمال window | +| `src/Appointment/Controller/AppointmentController.php` | `appointment-slots` (تک‌روز) — باید window را respect کند | +| `src/Appointment/Controller/AppointmentSettingsController.php` | weekly-schedule upsert + endpoint جدید month-availability | +| `src/Appointment/Repository/WeeklyScheduleRepository.php` | `findByDoctor` | +| `docs/api/appointment-settings.md` | مستندسازی window + endpoint جدید | +| `docs/api/appointment.md` | اگر رفتار `appointment-slots` تغییر کرد | +| `assets/admin/pages/DoctorDetailPage.tsx` | UI تنظیم booking window + toggle آنلاین | + +## وضعیت فعلی (کد واقعی) + +### `WeeklySchedule.php` — ذخیره‌سازی در JSON + +```php +#[ORM\Column(type: 'json')] +private array $setting = []; +``` + +> `setting` الان فقط کلیدهای `"0".."6"` (روزهای هفته) را دارد. می‌توان یک کلید رزروشده‌ی غیرعددی مثل `"meta"` اضافه کرد بدون اینکه `SlotCalculatorService` که فقط کلیدهای عددی روز را می‌خواند، بشکند. + +### `SlotCalculatorService::buildAllSessions()` — ترتیب اولویت فعلی + +```php +private function buildAllSessions(Doctor $doctor, string $date): array +{ + $dayStart = (int) strtotime($date . ' 00:00:00'); + $dayEnd = $dayStart + 86400; + + // 1. Blocked by holiday + if (!empty($this->holidayRepo->findActiveByDoctor($doctor, $dayStart, $dayEnd - 1))) { + return []; + } + // 2. Date override + foreach ($this->overrideRepo->findByDoctor($doctor) as $override) { + if (date('Y-m-d', $override->getDate()) === $date) { + if (!$override->isActive()) return []; + return $this->buildSessionsFromOverride($override->getSetting() ?? [], $dayStart); + } + } + // 3. Weekly schedule + $schedule = $this->scheduleRepo->findByDoctor($doctor); + if ($schedule === null) return []; + // ... +} +``` + +### `appointment-slots` controller — بدون چک window + +```php +#[Route('/api/v1/appointment-slots', methods: ['GET'])] +public function slots(Request $request): JsonResponse +{ + $doctorUuid = trim($request->query->get('doctor_uuid', '')); + $date = trim($request->query->get('date', '')); + $doctor = $this->doctorRepo->findByUuid($doctorUuid); + // ... validation ... + $sessions = $this->slotCalculator->getAllSlotsWithAvailability($doctor, $date); + return $this->success([...]); +} +``` + +### `DoctorDetailPage.tsx` — schedule از قبل ذخیره می‌شود + +```tsx +const saveMut = useMutation({ + mutationFn: () => existing + ? api.patch(`/api/v1/appointment-settings/weekly-schedule/${doctorUuid}`, { schedule: scheduleMap }) + : api.post('/api/v1/appointment-settings/weekly-schedule', { doctor_uuid: doctorUuid, schedule: scheduleMap }), +}); +``` + +## وظایف + +اجرای مرحله‌به‌مرحله؛ بعد از هر قابلیت تست (PHP lint + migrate در صورت نیاز + admin build) و سپس commit جدا. + +### ۱. مدل booking window در `WeeklySchedule.setting` + +- ساختار جدید زیر کلید رزروشده `"meta"` (یا `"settings"`) در `setting`: + +```json +{ + "0": { "sessions": [...] }, + "...": {}, + "meta": { + "online_booking_enabled": true, + "booking_window_value": 2, + "booking_window_unit": "month" + } +} +``` + +- `booking_window_unit`: یکی از `"week"` یا `"month"`. `booking_window_value`: عدد مثبت (مثلاً ۲ هفته یا ۲ ماه). +- پیش‌فرض وقتی `meta` نیست: نوبت‌دهی آنلاین **روشن**، بازه‌ی پیش‌فرض (مثلاً ۱ ماه) — این پیش‌فرض را به‌صورت ثابت در سرویس تعریف کن و در پاسخ‌ها هم برگردان تا فرانت بداند. +- weekly-schedule controller (POST/PATCH) باید فیلدهای `meta` را در صورت ارسال بپذیرد و ذخیره کند (validation: `unit ∈ {week, month}`، `value ≥ 1`). کلیدهای عددی روز دست‌نخورده بمانند. + +### ۲. اعمال window در `SlotCalculatorService` + +- یک متد private مثل `isWithinBookingWindow(Doctor $doctor, string $date): bool` بساز: + - `meta` را از `schedule->getSetting()['meta']` بخوان (با fallback پیش‌فرض). + - اگر `online_booking_enabled === false` → خارج از بازه (false). + - سقف را با `strtotime("+{$value} {$unit}", today)` حساب کن؛ اگر `dayStart` بعد از سقف بود → false. تاریخ‌های گذشته هم false (همین حالا فرانت گذشته را می‌بندد ولی backend هم باید مقاوم باشد). +- در `buildAllSessions` **قبل از** بازگشت اسلات‌ها این چک را اعمال کن: اگر خارج از بازه → `return []`. +- توجه: تاریخ‌های یونیکس صحیح (نه DateTime object). + +### ۳. endpoint عمومی month-availability + +- متد جدید در `AppointmentSettingsController` با route: + `GET /api/v1/appointment-settings/month-availability/{doctorUuid}` — **Permission: PUBLIC** (مثل `available-locations`). +- Query params: `year` و `month` (عددی). چون تقویم سایت شمسی است، **هر دو حالت را بپذیر**: اگر `calendar=jalali` آمد ورودی را شمسی تفسیر کن، در غیر این صورت میلادی. (یا ساده‌تر: همیشه میلادی بگیر و در پرامپت فرانت تبدیل شمسی→میلادی انجام شود — یکی را انتخاب کن و دقیق مستند کن.) +- برای هر روز ماه، با همان منطق `SlotCalculatorService` (holiday/override/window/weekly) تعیین کن آیا روز اسلات دارد یا نه. **برای پرهیز از سنگینی، یک متد سبک در سرویس اضافه کن** مثل `hasAnyAvailability(Doctor, string $date): bool` که فقط وجود session را چک کند (نه ساخت کامل اسلات‌ها). +- پاسخ: + +```json +{ + "success": true, + "data": { + "year": 2026, + "month": 6, + "disabled_dates": ["2026-06-16", "2026-06-17", "2026-06-20"], + "enabled_dates": ["2026-06-21", "2026-06-22"], + "online_booking_enabled": true, + "booking_window": { "value": 2, "unit": "month" } + } +} +``` + +> `disabled_dates`: روزهایی که تعطیل/خارج‌از‌بازه/بدون session‌اند (غیرقابل‌انتخاب). فرمت `Y-m-d`. فرانت این‌ها را روی تقویم خاکستری می‌کند. + +### ۴. UI در پنل ادمین (`DoctorDetailPage.tsx`) + +- در بخش تنظیمات برنامه‌ی هفتگی، یک کارت/سکشن «نوبت‌دهی آنلاین» اضافه کن: + - یک toggle برای `online_booking_enabled`. + - یک input عددی + select واحد (`هفته`/`ماه`) برای بازه. +- مقدار اولیه را از پاسخ `weekly-schedule` (`data?.data?.data?.meta`) بخوان (double-nested). +- در `saveMut` همان weekly-schedule، فیلد `meta` را هم به payload اضافه کن (`{ schedule: scheduleMap, meta: {...} }` یا داخل خود `scheduleMap` با کلید `meta`). +- از کلاس‌های CSS موجود همان صفحه استفاده کن؛ RTL؛ بدون کتابخانه‌ی جدید. + +## نکات مهم + +- **بدون migration:** booking window در `WeeklySchedule.setting` JSON می‌رود. اگر ترجیح می‌دهی فیلد مجزا در DB باشد، **متوقف شو و بپرس** (نیاز به migration دارد). +- همه‌ی controllerها از `BaseController`؛ پاسخ‌ها `$this->success()`/`$this->error()`. خطاها با `AppException(ErrorCodes::ERR_XXX, ...)`. +- `appointment-slots` تک‌روزه هم باید window را respect کند (وگرنه بیمار می‌تواند تاریخ خارج‌از‌بازه را مستقیم صدا بزند و اسلات بگیرد). +- `month-availability` باید **public** باشد (تقویم سایت قبل از لاگین لود می‌شود) — مثل `available-locations`. +- پاسخ weekly-schedule **double-nested** است (`data.data.data`) — UI ادمین با همین الگو می‌خواند. +- اولویت منطق دست‌نخورده بماند: holiday > override > window > weekly. window نباید روی اسلات‌های گذشته‌ی همین ماه تأثیر اشتباه بگذارد. +- بعد از تغییر API، **هم `docs/api/appointment-settings.md` و هم (در صورت تغییر رفتار) `docs/api/appointment.md`** را در همین session به‌روز کن: endpoint جدید با method/path/permission/query/response، و فیلدهای `meta`. +- تست: `ddev exec php -l ...` روی فایل‌های PHP؛ `ddev exec php bin/console debug:router | grep month-availability`؛ یک‌بار واقعی `curl` روی `month-availability` با پزشک تست `4a0594b1-008b-478a-a593-259b95d8c2dd`؛ و `ddev exec yarn dev` برای build پنل ادمین. سپس commit. diff --git a/src/Appointment/Controller/AppointmentSettingsController.php b/src/Appointment/Controller/AppointmentSettingsController.php index 716ab461..8eb7b37e 100644 --- a/src/Appointment/Controller/AppointmentSettingsController.php +++ b/src/Appointment/Controller/AppointmentSettingsController.php @@ -61,6 +61,10 @@ class AppointmentSettingsController extends BaseController $schedule = new WeeklySchedule($doctor, $data['schedule'] ?? []); } + if (isset($data['meta']) && is_array($data['meta'])) { + $schedule->setMeta($data['meta']); + } + $this->scheduleRepo->save($schedule); return $this->success(['data' => $schedule->toArray()], 201); @@ -88,6 +92,9 @@ class AppointmentSettingsController extends BaseController if (isset($data['schedule'])) { $schedule->setSetting($data['schedule']); } + if (isset($data['meta']) && is_array($data['meta'])) { + $schedule->setMeta($data['meta']); + } $this->scheduleRepo->save($schedule); diff --git a/src/Appointment/Entity/WeeklySchedule.php b/src/Appointment/Entity/WeeklySchedule.php index 11e23647..c7bcd7fc 100644 --- a/src/Appointment/Entity/WeeklySchedule.php +++ b/src/Appointment/Entity/WeeklySchedule.php @@ -13,6 +13,13 @@ class WeeklySchedule { public const DAYS = ['saturday', 'sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday']; + public const META_KEY = 'meta'; + public const DEFAULT_META = [ + 'online_booking_enabled' => true, + 'booking_window_value' => 1, + 'booking_window_unit' => 'month', + ]; + #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column(type: 'integer')] @@ -48,14 +55,51 @@ class WeeklySchedule public function getDoctor(): Doctor { return $this->doctor; } public function getSetting(): array { return $this->setting; } - public function setSetting(array $setting): self { $this->setting = $setting; $this->updatedAt = time(); return $this; } + public function setSetting(array $setting): self + { + $meta = $this->setting[self::META_KEY] ?? null; + unset($setting[self::META_KEY]); + if ($meta !== null) { + $setting[self::META_KEY] = $meta; + } + $this->setting = $setting; + $this->updatedAt = time(); + return $this; + } + + public function getMeta(): array + { + return array_merge(self::DEFAULT_META, $this->setting[self::META_KEY] ?? []); + } + + public function setMeta(array $meta): self + { + $current = $this->getMeta(); + $this->setting[self::META_KEY] = [ + 'online_booking_enabled' => (bool)($meta['online_booking_enabled'] ?? $current['online_booking_enabled']), + 'booking_window_value' => max(1, (int)($meta['booking_window_value'] ?? $current['booking_window_value'])), + 'booking_window_unit' => in_array($meta['booking_window_unit'] ?? null, ['week', 'month'], true) + ? $meta['booking_window_unit'] + : $current['booking_window_unit'], + ]; + $this->updatedAt = time(); + return $this; + } + + public function getDaySchedule(): array + { + $schedule = $this->setting; + unset($schedule[self::META_KEY]); + return $schedule; + } public function toArray(): array { return [ 'uuid' => $this->uuid, 'doctor_uuid' => $this->doctor->getUuid(), - 'schedule' => $this->setting, + 'schedule' => $this->getDaySchedule(), + 'meta' => $this->getMeta(), 'created_at' => $this->createdAt, 'updated_at' => $this->updatedAt, ];