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,