From 3e3a2482fc132542b499b8344f3bf6c8c52920c7 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Mon, 3 Aug 2026 11:31:37 +0330 Subject: [PATCH] feat(appointments): per-resource tabs backed by a resource_uuid list filter Resources now get their own tabs on the appointments page, alongside doctors. An appointment on "Laser CO2" belongs to the device, not to whichever doctor happens to stand behind it, so selecting a resource tab replaces the doctor filter instead of stacking on top of it. GET /api/v1/my/appointments gains an optional resource_uuid filter and returns a `resource` object per row. The join is a leftJoin on purpose: appointments created before the resource-first model have no resource and must not drop out of the list. The resource tab lives in the URL so Back and refresh restore the same view, per the list-state rule in CLAUDE.md. The doctor tab is still useState; moving it is a separate refactor and was left untouched. Verified against the running app: filtering by a resource returns only its appointments, a resource from another tenant returns an empty list (TenantFilter, 200 not 403), and legacy rows still list with resource: null. Co-Authored-By: Claude Opus 5 (1M context) --- .claude/prompt/resource-first-appointments.md | 324 ++++++++++++++++++ assets/admin/pages/AppointmentsPage.test.tsx | 27 ++ assets/admin/pages/AppointmentsPage.tsx | 50 ++- assets/admin/types/index.ts | 2 + docs/api/appointment.md | 34 ++ .../Controller/MyAppointmentsController.php | 14 +- 6 files changed, 447 insertions(+), 4 deletions(-) create mode 100644 .claude/prompt/resource-first-appointments.md diff --git a/.claude/prompt/resource-first-appointments.md b/.claude/prompt/resource-first-appointments.md new file mode 100644 index 00000000..eaf95149 --- /dev/null +++ b/.claude/prompt/resource-first-appointments.md @@ -0,0 +1,324 @@ +# نوبت‌دهی منبع‌محور در صفحهٔ نوبت‌ها — تب هر منبع + رزرو سرویسی + حذف تایم‌لاین منابع + +## زمینه + +صفحهٔ `/admin/appointments` امروز کاملاً پزشک‌محور است: تب‌ها فقط پزشک‌اند +(`AppointmentsPage.tsx:841`)، و منابع فقط یک نوار **فقط‌خواندنی** زیر زمان‌بندی دارند +(`AppointmentsPage.tsx:892-903`) که هیچ اقدامی روی آن ممکن نیست. + +در مدل Resource-First، منبع واحد ظرفیت است: «لیزر CO2» و «لیزر NdYAG» سرویس‌های خودشان +(`ResourceServiceOffering`)، تقویم خودشان (`ResourceCalendar`) و استثناهای خودشان را دارند. +ولی کاربر نمی‌تواند برای آن‌ها نوبت ثبت کند، چون کل مسیر رزرو از پزشک عبور می‌کند. + +## مشکل / هدف + +**هدف:** هر منبع مثل پزشک تب خودش را داشته باشد؛ «افزودن نوبت» روی تب یک منبع، مودال +نوبت‌دهی **سرویسی** را برای همان منبع باز کند (لیست سرویس‌های همان منبع → انتخاب → +زمان خالی → بیمار → ثبت)؛ و نوار «منابع» زیر زمان‌بندی حذف شود. + +**سه مانع واقعی در کد امروز:** + +۱. **نوبت بدون پزشک ممکن نیست.** `Appointment::$doctor` با `nullable: false` تعریف شده + (`Appointment.php:100-101`) و سازندهٔ entity هم `Doctor` می‌گیرد (`Appointment.php:256`). + `book()` بدون `doctor_uuid` خطای ۴۲۲ می‌دهد (`AppointmentController.php:484`) و منبع فقط + وقتی پزشک پیدا می‌کند که خودش پزشک باشد (`AppointmentController.php:471-473`). یعنی + دستگاهِ بدون پزشک اصلاً قابل رزرو نیست. + +۲. **اسلات سرویسی فقط پزشک‌محور است.** `GET /api/v1/appointment-service-slots` پزشک + می‌خواهد و شرط می‌کند `booking_mode` همان پزشک `service` باشد + (`AppointmentController.php:193-209`). منبع `WeeklySchedule` ندارد. + +۳. تایم‌لاین منابع باید حذف شود. + +**تصمیم گرفته‌شده (توسط کاربر):** مسیر «نوبت بدون پزشک» — `doctor` تهی‌پذیر شود. + +### چرا این تصمیم آن‌قدر که به‌نظر می‌رسد پرریسک نیست (شواهد از کد) + +- `Appointment::$resource` **از قبل وجود دارد** و تهی‌پذیر است (`Appointment.php:174-176`). +- تداخل منابع **از قبل در سطح دیتابیس** تضمین شده، نه در کد: `OccupancyBucket` با + `UniqueConstraint('uniq_bucket_resource_seat', ['resource_id','bucket_at','seat'])` + (`OccupancyBucket.php:22`). پس `activeSlotKey` مسئول تداخل **منبع** نیست. +- `activeSlotKey` فقط دوباره‌رزروی **همان پزشک** را می‌گیرد: + `sprintf('%d:%d', $this->doctor->getId(), $this->slotStart)` (`Appointment.php:283`). + وقتی پزشکی وجود ندارد، «دوباره‌رزروی پزشک» بی‌معناست و `null` بودنِ کلید معنای درستی + است — نه یک حفرهٔ ایمنی. + +**ریسک واقعی و باقی‌مانده:** ۷۳ فراخوانی `getDoctor()` در ۲۱+ فایل `src/` که همه امروز +`Doctor` غیرتهی فرض می‌کنند. این بخش سنگین کار است و باید تک‌تک بررسی شود. + +## معیار پذیرش + +- ✅ **موفق:** با توکن مالک کلینیک، `POST /api/v1/appointment` با بدنهٔ + `{resource_uuid, service_item_uuids[], slot_start, patient_national_code, patient_gender}` + و **بدون** `doctor_uuid`، برای منبعِ دستگاهی (`subject_kind = null`) → `200` و نوبت ذخیره + می‌شود با `doctor_id = NULL` و `resource_id` پرشده. در UI: تب «لیزر CO2» → «افزودن نوبت» → + انتخاب سرویس → انتخاب زمان → ثبت → نوبت در فهرست همان تب دیده می‌شود. +- ✅ **موفق:** `GET /api/v1/appointment-service-slots?resource_uuid=…&date=…&service_item_uuids[]=…` + → `200` با همان شکل پاسخِ حالت پزشک (`start_times[]`, `total_duration_minutes`). +- ❌ **خطا:** رزرو منبعی که آن سرویس را ارائه نمی‌دهد → `422` با پیام + «این منبع این سرویس را ارائه نمی‌دهد» و `field = resource_uuid` (این بررسی از قبل در + `AppointmentController.php:533-540` هست و باید در مسیر بدون‌پزشک هم اجرا شود). +- ❌ **خطا:** رزرو منبعِ محیط دیگر → `422` «منبع یافت نشد» با `field = resource_uuid`. +- ❌ **خطا:** نه `doctor_uuid` و نه `resource_uuid` → `422` با envelope خطا. +- ⚠️ **مرزی:** دو رزروِ هم‌زمان روی یک منبع با ظرفیت ۱ در یک بازه → دومی باید با + `409` رد شود (از قید یکتای `uniq_bucket_resource_seat`، نه از بررسی در کد). +- ⚠️ **مرزی:** منبعی که آن روز شیفت ندارد → `start_times` خالی و پیام «زمان خالی کافی + نیست»، نه خطای ۵۰۰. +- ⚠️ **مرزی:** نوبت‌های قدیمیِ دارای پزشک باید بدون تغییر کار کنند (هم API، هم پنل، هم + سایت عمومی) — `doctor` تهی‌پذیر شده، حذف نشده. + +## فایل‌های مرتبط + +| فایل | نقش | +|------|-----| +| `src/Appointment/Entity/Appointment.php` | `doctor` تهی‌پذیر، `activeSlotKey`، `toArray()` | +| `src/Appointment/Controller/AppointmentController.php` | `book()` و `serviceSlots()` | +| `src/Appointment/Booking/Entity/OccupancyBucket.php` | تضمین یکتاییِ اشغال منبع (فقط مرجع — تغییر نمی‌کند) | +| `src/Resource/Entity/ResourceServiceOffering.php` | سرویس‌ها و مدت مؤثرِ هر منبع | +| `src/Resource/Entity/ResourceCalendar.php` | شیفت هفتگی منبع (مبنای اسلات) | +| `migrations/` | migration تهی‌پذیر کردن `appointments.doctor_id` | +| `assets/admin/pages/AppointmentsPage.tsx` | تب‌ها، مودال ثبت، حذف بخش منابع | +| `assets/admin/components/appointments/DoctorTabs.tsx` | تب‌ها (باید عمومی شود) | +| `assets/admin/components/appointments/ServiceSlotPicker.tsx` | انتخاب سرویس/زمان (باید منبع را هم بپذیرد) | +| `assets/admin/components/appointments/ResourceTimeline.tsx` + `.test.tsx` | **حذف** | +| `assets/admin/hooks/useResourceTimeline.ts` | **حذف** اگر مصرف‌کنندهٔ دیگری ندارد | +| `docs/api/appointment-booking.md`, `docs/api/appointment.md`, `docs/api/resource.md` | مستندات | + +## وضعیت فعلی + +`Appointment.php` — پزشک اجباری و کلید یکتا بر پایهٔ پزشک: + +```php +#[ORM\ManyToOne(targetEntity: Doctor::class)] +#[ORM\JoinColumn(name: 'doctor_id', referencedColumnName: 'id', nullable: false, onDelete: 'RESTRICT')] +private Doctor $doctor; + +public function __construct(Doctor $doctor, User $user, int $slotStart, int $slotEnd) + +private function refreshActiveSlotKey(): void +{ + $this->activeSlotKey = !$this->isReserve && in_array($this->status, self::SLOT_OCCUPYING_STATUSES, true) + ? sprintf('%d:%d', $this->doctor->getId(), $this->slotStart) + : null; +} + +public function getDoctor(): Doctor { return $this->doctor; } +``` + +`AppointmentController::book()` — بدون پزشک رد می‌شود: + +```php +if ($doctorUuid === '' && $resource->subject() instanceof \App\Doctor\Entity\Doctor) { + $doctorUuid = $resource->subject()->getUuid(); +} +… +if ($doctorUuid === '' || $slotStart <= 0 || (!$hasServices && $slotEnd <= $slotStart)) { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'doctor_uuid یا resource_uuid به‌همراه slot_start الزامی است', 422); +} + +$doctor = $this->doctorRepo->findByUuid($doctorUuid); +if ($doctor === null) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404); +} +``` + +`AppointmentController::serviceSlots()` — پزشک‌محور و مقیّد به `booking_mode` پزشک: + +```php +$doctor = $this->doctorRepo->findByUuid($doctorUuid); +if ($doctor === null) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404); +} +… +$mode = ($schedule ? $schedule->getMeta() : WeeklySchedule::DEFAULT_META)['booking_mode'] ?? WeeklySchedule::MODE_SLOT; +if ($mode !== WeeklySchedule::MODE_SERVICE) { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'این پزشک در حالت نوبت‌دهی سرویسی نیست', 422); +} +``` + +`AppointmentsPage.tsx` — تب فقط پزشک، و بخش منابع که باید حذف شود: + +```tsx +{showDoctorTabs && ( + +)} +… +{/* منابعِ قابل رزرو، زیر همان روز — یک نوبت می‌تواند هم‌زمان اتاق و + دستگاه را بگیرد و آن است که ظرفیت را تمام می‌کند. */} +
+

منابع

+ +
+``` + +## وظایف + +> ترتیب اجباری است: بک‌اند اول. وظیفهٔ ۱ پایهٔ بقیه است و اگر ناقص بماند، بقیه روی +> خرابه ساخته می‌شوند. + +### ۱. تهی‌پذیر کردن `Appointment::$doctor` + +```php +#[ORM\ManyToOne(targetEntity: Doctor::class)] +#[ORM\JoinColumn(name: 'doctor_id', referencedColumnName: 'id', nullable: true, onDelete: 'RESTRICT')] +private ?Doctor $doctor = null; + +public function __construct(?Doctor $doctor, User $user, int $slotStart, int $slotEnd) + +public function getDoctor(): ?Doctor { return $this->doctor; } + +/** + * کلید یکتای اسلات فقط دوباره‌رزروی «همان پزشک» را می‌گیرد. نوبتِ منبع‌محورِ بدون + * پزشک چنین تداخلی ندارد؛ تداخلِ خودِ منبع را قید یکتای + * `uniq_bucket_resource_seat` روی `resource_occupancy_buckets` می‌گیرد. + */ +private function refreshActiveSlotKey(): void +{ + $this->activeSlotKey = $this->doctor !== null + && !$this->isReserve + && in_array($this->status, self::SLOT_OCCUPYING_STATUSES, true) + ? sprintf('%d:%d', $this->doctor->getId(), $this->slotStart) + : null; +} +``` + +سپس **همهٔ ۷۳ فراخوانی `getDoctor()`** را بررسی کن: + +```bash +ddev exec grep -rn "getDoctor()" src/ | wc -l # باید ۷۳ باشد +ddev exec php vendor/bin/phpstan analyse # سطح ۵ — تهی‌پذیرهای بررسی‌نشده را می‌گیرد +``` + +قاعده: هر جا پزشک واقعاً لازم است (تعرفه، برنامهٔ هفتگی، پیامک پزشک) → `null` را با +خطای معنادار رد کن؛ هر جا فقط نمایشی است (`toArray()` خط ۴۹۹ و ۵۰۳) → مقدار تهی برگردان +نه استثنا. + +**نحوه تست:** `ddev exec php bin/phpunit tests/Appointment/` باید کامل سبز بماند (رگرسیون +مسیر پزشک‌دار). به‌علاوه `ddev exec php vendor/bin/phpstan analyse` بدون خطای تهی‌پذیری. + +### ۲. Migration + +```bash +ddev exec php bin/console doctrine:migrations:diff --no-interaction +ddev exec php bin/console doctrine:migrations:migrate --no-interaction +``` + +**نحوه تست:** بعد از migrate، `DESCRIBE appointments;` باید `doctor_id` را `YES` (nullable) +نشان دهد، و ردیف‌های موجود دست‌نخورده بمانند: +```bash +ddev exec mysql -e "SELECT COUNT(*) FROM appointments WHERE doctor_id IS NULL;" # قبل از فیچر: 0 +``` + +### ۳. اسلات سرویسیِ منبع‌محور + +`GET /api/v1/appointment-service-slots` را طوری توسعه بده که **یا** `doctor_uuid` بگیرد +**یا** `resource_uuid` — با همان شکل پاسخ. + +**الگو: Strategy.** یک interface مثل `ServiceSlotSource` با دو پیاده‌سازی +`DoctorServiceSlotSource` و `ResourceServiceSlotSource`. دلیل انتخاب (guidelines §۵): +همین حالا **دو** پیاده‌سازی واقعی وجود دارد — پزشک از `WeeklySchedule` و اسلات‌های پزشک +می‌آید، منبع از `ResourceCalendar` + `ResourceException` + `resource_blocks` + اشغال. +بدون Strategy، این می‌شد یک `if` روی نوعِ شناسه داخل کنترلر که با هر منبعِ جدید رشد می‌کند. + +نکات محاسبه برای منبع: +- مدت هر سرویس از `ResourceServiceOffering` همان منبع (مدت مؤثر)، نه از پیش‌فرض سرویس. +- شرط `booking_mode === MODE_SERVICE` برای منبع **اعمال نشود** — آن قید مالِ برنامهٔ + هفتگی پزشک است و منبع اصلاً `WeeklySchedule` ندارد. +- بازه‌های اشغال از همان مسیری بیاید که تایم‌لاین منبع می‌خواند + (`ResourceOccupancyRepository`)، نه یک کوئری موازیِ جدید. + +**نحوه تست:** +```bash +TOKEN=$(curl -sk -X POST https://clinic-pro.ddev.site/api/v1/user/login \ + -H 'Content-Type: application/json' \ + -d '{"mobile_number":"0912000201","password":"QaTest@1234"}' | jq -r .access_token) + +curl -sk "https://clinic-pro.ddev.site/api/v1/appointment-service-slots?resource_uuid=&date=$(date +%F)&management=1&service_item_uuids[]=" \ + -H "Authorization: Bearer $TOKEN" | jq +# انتظار: success:true و start_times آرایه‌ای از {start,end,start_time} +``` + +### ۴. رزرو بدون پزشک در `book()` + +- اگر `resource_uuid` آمده و منبع پزشک‌پشت ندارد، دیگر به `doctor_uuid` اصرار نکن. +- شرط خطا به این تغییر کند: «حداقل یکی از `doctor_uuid` یا `resource_uuid` لازم است». +- بررسی «این منبع این سرویس را ارائه نمی‌دهد» (`AppointmentController.php:533-540`) باید + در مسیر بدون‌پزشک هم اجرا شود — امروز داخل بلوکی است که به `$duration` وابسته است. +- بررسی مالکیت محیط منبع (`EntityContext::forBooking`) وقتی پزشک نداریم باید از **محیط + کاربر جاری** بیاید، نه از پزشک. + +**نحوه تست:** رزرو واقعی روی یک منبع دستگاهی (بدون `doctor_uuid`) → ۲۰۰؛ سپس همان بازه +دوباره → ۴۰۹. هر دو با `curl` و توکن بالا. و +`ddev exec mysql -e "SELECT doctor_id, resource_id FROM appointments ORDER BY id DESC LIMIT 1;"`. + +### ۵. تب منابع در کنار تب پزشکان + +`DoctorTabs.tsx` امروز فقط `{uuid, name}` می‌گیرد و رفتارش کاملاً عمومی است — **همان را +عمومی کن** (مثلاً `EntityTabs`) به‌جای ساختن کامپوننت دوم؛ اسم فعلی‌اش تنها چیزِ پزشکیِ +آن است (guidelines §۵: اول بگرد، بعد توسعه بده، در آخر بساز). + +تب انتخاب‌شده باید در URL بنشیند (`useUrlState`) وگرنه «بازگشت» نما را می‌پراند — +همان قاعده‌ای که در `CLAUDE.md` برای وضعیت لیست آمده. پیشنهاد: `?tab=doctor:` و +`?tab=resource:` تا یک کلید هر دو نوع را بگیرد. + +**نحوه تست:** `npx vitest run assets/admin/pages/AppointmentsPage.test.tsx` + تست جدید: +کلیک روی تب یک منبع → فهرست همان منبع؛ رفرش صفحه → همان تب فعال بماند. + +### ۶. مودال ثبت نوبت برای منبع + +`ServiceSlotPicker` باید به‌جای `doctorUuid` اجباری، یکی از این دو را بگیرد. سرویس‌ها +برای منبع از `GET /api/v1/resource/{uuid}/services` می‌آید (نه از +`appointment-booking-services/{doctorUuid}`). + +ترتیب مودال دقیقاً مثل عکس مرجع و مثل حالت پزشک بماند: +سرویس‌های منبع → سرویس‌های انتخاب‌شده (با مدت قابل ویرایش) → زمان‌های خالی → جستجوی +بیمار → هزینه → ثبت. + +**نحوه تست:** `npx vitest run assets/admin/components/appointments/ServiceSlotPicker.test.tsx` +(تست موجود نباید بشکند) + تست جدید برای حالت منبع. سناریوی UI: تب «لیزر CO2» → افزودن +نوبت → یک سرویس → یک زمان → کد ملی بیمار → ثبت → نوبت در فهرست ظاهر شود. + +### ۷. حذف تایم‌لاین منابع + +- بلوک `

منابع

+ ` از `AppointmentsPage.tsx` حذف شود. +- `ResourceTimeline.tsx` و `ResourceTimeline.test.tsx` حذف شوند. +- `useResourceTimeline.ts` **فقط اگر** مصرف‌کنندهٔ دیگری ندارد حذف شود: + ```bash + grep -rn "useResourceTimeline" assets/admin/ + ``` +- `GET /api/v1/resources/timeline` در بک‌اند **دست‌نخورده** بماند (اندپوینت خودش + مشکلی ندارد؛ فقط این مصرف‌کننده حذف می‌شود). اگر بعد از حذف هیچ کلاینتی ندارد، در + گزارش پایانی ذکر کن تا کاربر تصمیم بگیرد. + +**نحوه تست:** `npx vitest run` کامل سبز؛ و اسکرین‌شات صفحه بعد از +`ddev exec yarn dev` که دیگر بخش «منابع» زیر زمان‌بندی ندارد. + +### ۸. مستندات + +- `docs/api/appointment-booking.md`: `resource_uuid` بدون `doctor_uuid`، و اینکه + `doctor` در پاسخ می‌تواند `null` باشد. +- `docs/api/appointment.md`: پارامتر `resource_uuid` در `appointment-service-slots` با + JSON واقعیِ اجرا (نه دست‌ساز). +- `docs/api/resource.md`: اشاره به اینکه سرویس‌های منبع مبنای رزرو منبع‌محورند. + +## نکات مهم + +- **این تغییر cross-repo است.** `nobat724_front` و `clinic-pro-tauri` هر دو کلاینت همین + API‌اند و `appointment.doctor` را غیرتهی فرض می‌کنند. تغییر قرارداد در build آن‌ها خطا + **نمی‌دهد** و در رانتایم می‌شکند (guidelines §۳). بعد از وظیفهٔ ۱، مصرف واقعی را در + `nobat724_front/services/response.js` و صفحات نوبت دستی دنبال کن و نتیجه را گزارش بده. +- `activeSlotKey` را برای نوبت بدون پزشک `null` بگذار — این حفره نیست؛ تداخل منبع را + `uniq_bucket_resource_seat` می‌گیرد. اگر وسوسه شدی کلید را `r:` کنی، اول + بررسی کن که با ظرفیت >۱ منبع نمی‌شکند (منبع سه‌ظرفیتی سه رزرو هم‌زمان دارد). +- Controller نازک بماند: منطق انتخاب اسلات در Service/Strategy، کوئری در Repository، + تزریق با constructor injection. +- خطاها با `AppException(ErrorCodes::ERR_XXX)` و پیام فارسی؛ پاسخ‌ها با + `$this->success()` / `$this->error()`. +- تاریخ‌ها Unix timestamp صحیح؛ رشته‌های UI فارسی و تاریخ‌های نمایشی جلالی. +- اگر وسط کار معلوم شد یکی از ۷۳ فراخوانی `getDoctor()` نیازمند تصمیم محصولی است + (مثلاً «سهم منشی از نوبت بدون پزشک چطور حساب شود؟»)، **متوقف شو و بپرس** — حدس نزن. diff --git a/assets/admin/pages/AppointmentsPage.test.tsx b/assets/admin/pages/AppointmentsPage.test.tsx index 38720b38..0c4dd62f 100644 --- a/assets/admin/pages/AppointmentsPage.test.tsx +++ b/assets/admin/pages/AppointmentsPage.test.tsx @@ -66,6 +66,10 @@ describe('AppointmentsPage — پروفایل کلینیک چندپزشکه', () ] } }); if (url.includes('appointment-slots')) return Promise.resolve({ success: true, data: { sessions: [], empty_reason: 'holiday' } }); if (url.includes('/my/appointments')) return Promise.resolve({ success: true, data: [], meta: { totalRecords: 0, totalPages: 0, currentPage: 1 } }); + if (url.includes('/api/v1/resources')) return Promise.resolve({ success: true, data: [ + { uuid: 'r-1', name: 'اتاق ۱', type_name: 'اتاق درمان' }, + { uuid: 'r-2', name: 'لیزر CO2', type_name: 'دستگاه لیزر' }, + ] }); return Promise.resolve({ success: true, data: [] }); }); }); @@ -77,6 +81,29 @@ describe('AppointmentsPage — پروفایل کلینیک چندپزشکه', () expect(screen.queryByText('همه')).toBeNull(); }); + /** + * منابع مثل پزشکان تب خودشان را دارند: نوبتِ «لیزر CO2» به دستگاه تعلق دارد، نه به + * پزشکی که پشتش ایستاده. + */ + it('برای هر منبع فعال یک تب نشان می‌دهد', async () => { + renderWithProviders(); + expect(await screen.findByText('لیزر CO2')).toBeInTheDocument(); + expect(screen.getByText('اتاق ۱')).toBeInTheDocument(); + }); + + /** تب فعال از URL خوانده می‌شود و فهرست با resource_uuid فیلتر می‌شود، نه doctor_uuid. */ + it('تب منبع از URL خوانده می‌شود و فهرست را با resource_uuid می‌گیرد', async () => { + renderWithProviders(, { route: '/admin/appointments?resource=r-2' }); + await screen.findByText('لیزر CO2'); + + const calls = get.mock.calls + .map((c: any[]) => c[0]) + .filter((u: any) => typeof u === 'string' && u.includes('/my/appointments')); + + expect(calls.some((u: string) => u.includes('resource_uuid=r-2'))).toBe(true); + expect(calls.some((u: string) => u.includes('doctor_uuid='))).toBe(false); + }); + it('auto-selects the first doctor so the timeline loads its slots', async () => { renderWithProviders(); await screen.findByText('دکتر محمدی'); diff --git a/assets/admin/pages/AppointmentsPage.tsx b/assets/admin/pages/AppointmentsPage.tsx index c9728cbc..b8c00fb0 100644 --- a/assets/admin/pages/AppointmentsPage.tsx +++ b/assets/admin/pages/AppointmentsPage.tsx @@ -26,6 +26,10 @@ import type { TurnsViewMode } from '../components/appointments/TurnsViewToggle'; import ResourceTimeline from '../components/appointments/ResourceTimeline'; import { useResourceTimeline } from '../hooks/useResourceTimeline'; import DoctorTabs from '../components/appointments/DoctorTabs'; +/** هیچ تبی فعال نیست — وقتی تب منبع انتخاب شده، نوار پزشکان نباید هایلایت داشته باشد. */ +const NO_ACTIVE_TAB = '\u0000'; +import { useResources } from '../hooks/useResources'; +import { useUrlState } from '../hooks/useUrlState'; import TurnsTimeline from '../components/appointments/TurnsTimeline'; import TurnsTable from '../components/appointments/TurnsTable'; import { usePermissions } from '../hooks/usePermissions'; @@ -514,6 +518,24 @@ export default function AppointmentsPage() { const [selectedDate, setSelectedDate] = useState(params.get('date') || today); const [viewMode, setViewMode] = useState('timeline'); const [selectedDoctorUuid, setSelectedDoctorUuid] = useState(isDoctor ? (doctorUuid ?? '') : ''); + + /** + * تب منبع در URL می‌نشیند تا «بازگشت» و رفرش همان تب را برگردانند — همان قاعده‌ای که + * `CLAUDE.md` برای وضعیت لیست‌ها می‌گذارد. (تب پزشک هنوز `useState` است؛ رفعش + * refactor جداست و اینجا دست نمی‌خورد.) + */ + const [urlState, setUrlState] = useUrlState({ resource: '' }); + const selectedResourceUuid = urlState.resource; + const { resources: bookableResources } = useResources({ active: '1' }); + + const selectResource = (uuid: string) => { + setUrlState({ resource: uuid }); + if (uuid) setSelectedDoctorUuid(''); + }; + const selectDoctor = (uuid: string) => { + setSelectedDoctorUuid(uuid); + if (selectedResourceUuid) setUrlState({ resource: '' }); + }; const [bookingSlot, setBookingSlot] = useState(null); const [filtersOpen, setFiltersOpen] = useState(false); const [filters, setFilters] = useState(EMPTY_FILTERS); @@ -536,9 +558,12 @@ export default function AppointmentsPage() { : isRepresentation ? '/api/v1/representation/appointments' : '/api/v1/my/appointments'; - const apptQueryKey = ['appointments', apptEndpoint, selectedDate, selectedDoctorUuid]; + const apptQueryKey = ['appointments', apptEndpoint, selectedDate, selectedDoctorUuid, selectedResourceUuid]; const apptParams = new URLSearchParams({ date: selectedDate, limit: '500' }); - if (selectedDoctorUuid) apptParams.set('doctor_uuid', selectedDoctorUuid); + // تب منبع جای تب پزشک را می‌گیرد، نه اینکه رویش سوار شود: «نوبت‌های لیزر CO2» یعنی + // همهٔ نوبت‌های آن دستگاه، از هر پزشکی. + if (selectedResourceUuid) apptParams.set('resource_uuid', selectedResourceUuid); + else if (selectedDoctorUuid) apptParams.set('doctor_uuid', selectedDoctorUuid); const apptQuery = useQuery>({ queryKey: apptQueryKey, @@ -838,7 +863,26 @@ export default function AppointmentsPage() { borderRadius: 'var(--r)', overflow: 'hidden', }}> {showDoctorTabs && ( - + + )} + + {/* منابع مثل پزشکان تب خودشان را دارند: نوبتِ «لیزر CO2» به دستگاه تعلق دارد، + نه به پزشکی که پشتش ایستاده. */} + {bookableResources.length > 0 && ( +
+ منابع + ({ uuid: r.uuid, name: r.name }))} + selected={selectedResourceUuid} + onSelect={selectResource} + showAll={false} + /> +
)}
{viewMode === 'table' ? ( diff --git a/assets/admin/types/index.ts b/assets/admin/types/index.ts index a51450fd..e47b4729 100644 --- a/assets/admin/types/index.ts +++ b/assets/admin/types/index.ts @@ -114,6 +114,8 @@ export interface Appointment { service_section?: { uuid: string; name: string } | null; service_item?: { uuid: string; name: string } | null; staff?: { uuid: string; full_name: string } | null; + /** منبعی که نوبت رویش گرفته شده. null = نوبت‌های پیش از مدل منبع‌محور. */ + resource?: { uuid: string; name: string } | null; visit_price_rials?: number | null; service_items?: { uuid: string; name: string; price_rials?: number | null; service_category?: string | null; insurance_covered?: boolean }[] | null; /** نوع خدمتِ بیمه‌ای و بیمهٔ پایهٔ انتخاب‌شده روی همین نوبت. */ diff --git a/docs/api/appointment.md b/docs/api/appointment.md index 22492f5f..49895ba4 100644 --- a/docs/api/appointment.md +++ b/docs/api/appointment.md @@ -1085,6 +1085,40 @@ New query param `reserve=1` → returns only reserve-list entries; without it on | `service_total_minutes` | int\|null | مدت ثبت‌شده؛ در حالت اسلاتی `null` | | `service_buffer_minutes` | int\|null | بافر مؤثر لحظهٔ ثبت | +### فیلتر و فیلدِ منبع (2026-08) + +صفحهٔ نوبت‌ها برای هر منبع تبِ مستقل دارد، پس فهرست باید بتواند «نوبت‌های همین دستگاه/اتاق» +را بدهد. + +| پارامتر | نوع | توضیح | +|---|---|---| +| `resource_uuid` | string | اختیاری. فقط نوبت‌های همان منبع. با `doctor_uuid` جمع نمی‌شود — تبِ منبع جای تبِ پزشک را می‌گیرد، چون نوبتِ یک دستگاه می‌تواند از چند پزشک باشد | + +| فیلد پاسخ | نوع | توضیح | +|---|---|---| +| `resource` | `{uuid, name}`\|null | منبعی که نوبت رویش گرفته شده. `null` برای نوبت‌های پیش از مدل منبع‌محور — با `leftJoin` گرفته می‌شود تا آن ردیف‌ها از فهرست حذف نشوند | + +منبع تحت `TenantFilter` است: `resource_uuid`ِ محیط دیگر هیچ ردیفی برنمی‌گرداند (۲۰۰ با +فهرست خالی، نه ۴۰۳). + +خروجی واقعی `GET /api/v1/my/appointments?limit=1&resource_uuid=ce070910-…`: + +```json +{ + "success": true, + "data": [ + { + "uuid": "0e5268db-f2a6-4561-86e4-5c6de0270756", + "doctor_name": "امیر کاظمی", + "resource": { "uuid": "ce070910-7038-4f50-9f7a-1b35ec1a67f7", "name": "اتاق ۱" }, + "slot_start": 1783859400, + "status": "completed" + } + ], + "meta": { "totalRecords": 1, "totalPages": 1, "currentPage": 1, "limit": 1 } +} +``` + `service_items` با یک کوئری جدا برای کل صفحه گرفته می‌شود، نه JOIN به کوئری اصلی: JOIN روی collection ردیف‌ها را ضرب می‌کند و صفحه‌بندی را می‌شکند (نوبتی با سه سرویس، سه ردیف می‌شد). N+1 هم نیست. diff --git a/src/Appointment/Controller/MyAppointmentsController.php b/src/Appointment/Controller/MyAppointmentsController.php index 683e62fe..39d78051 100644 --- a/src/Appointment/Controller/MyAppointmentsController.php +++ b/src/Appointment/Controller/MyAppointmentsController.php @@ -317,6 +317,8 @@ class MyAppointmentsController extends BaseController $status = trim((string) $request->query->get('status', '')); $date = trim((string) $request->query->get('date', '')); $doctorUuid = trim((string) $request->query->get('doctor_uuid', '')); + // تب منبع در صفحهٔ نوبت‌ها: «نوبت‌های همین دستگاه/اتاق». همان الگوی doctor_uuid. + $resourceUuid = trim((string) $request->query->get('resource_uuid', '')); // reserve=1 → only reserve-list entries; otherwise the regular slot list. $reserveOnly = $request->query->get('reserve') === '1'; @@ -332,7 +334,8 @@ class MyAppointmentsController extends BaseController 'u.uuid as patient_uuid, u.mobileNumber as patient_mobile, u.realName as patient_name', 'ss.uuid as section_uuid, ss.name as section_name', 'si.uuid as service_uuid, si.name as service_name', - 'st.uuid as staff_uuid, st.fullName as staff_name' + 'st.uuid as staff_uuid, st.fullName as staff_name', + 'r.uuid as resource_uuid, r.name as resource_name' ) ->from(Appointment::class, 'a') ->join('a.doctor', 'd') @@ -341,6 +344,8 @@ class MyAppointmentsController extends BaseController ->leftJoin('a.serviceItem', 'si') ->leftJoin('a.staff', 'st') ->leftJoin('a.clinic', 'cl') + // leftJoin: نوبت‌های پیش از مدل منبع‌محور منبعی ندارند و نباید حذف شوند. + ->leftJoin('a.resource', 'r') ->andWhere('a.isReserve = :reserveOnly') ->setParameter('reserveOnly', $reserveOnly) ->orderBy('a.slotStart', 'ASC'); @@ -404,6 +409,10 @@ class MyAppointmentsController extends BaseController ->setParameter('dayStart', $dayStart) ->setParameter('dayEnd', $dayEnd); } + if ($resourceUuid !== '') { + $qb->andWhere('r.uuid = :resourceUuid')->setParameter('resourceUuid', $resourceUuid); + } + if ($doctorUuid !== '') { $qb->andWhere('d.uuid = :doctorUuid')->setParameter('doctorUuid', $doctorUuid); } @@ -444,6 +453,9 @@ class MyAppointmentsController extends BaseController // تنها آن را بخواند بقیه را نشان نمی‌دهد. 'service_items' => $serviceItemsByAppointment[$a['uuid']] ?? [], 'staff' => $a['staff_uuid'] ? ['uuid' => $a['staff_uuid'], 'full_name' => $a['staff_name']] : null, + // نوبت‌های پیش از مدل منبع‌محور منبعی ندارند؛ `null` یعنی «منبعی ثبت نشده»، + // نه اینکه فیلد را نفرستاده باشیم. + 'resource' => $a['resource_uuid'] ? ['uuid' => $a['resource_uuid'], 'name' => $a['resource_name']] : null, 'clinic_uuid' => $a['clinic_uuid'], 'service_total_minutes' => $a['service_total_minutes'] !== null ? (int) $a['service_total_minutes'] : null, 'service_buffer_minutes' => $a['service_buffer_minutes'] !== null ? (int) $a['service_buffer_minutes'] : null,