# تب «نوبت‌های بعدی» در پروندهٔ بیمار + اصلاح واژهٔ پرسنل ## پروژه `clinicpro` (backend + پنل ادمین). cross-repo نیست. ## زمینه درمان چندجلسه‌ای امروز فقط از یک صفحهٔ سراسری دیده می‌شود: `/admin/treatment-cases`. آنجا فهرست همهٔ پرونده‌های محیط است، نه پروندهٔ یک بیمار. نتیجه: منشی که پروندهٔ یک بیمار را باز کرده (`/admin/patients/{uuid}`) هیچ راهی ندارد ببیند این بیمار چه دوره‌هایی دارد، جلسهٔ بعدش کِی است، و جلسات قبلی چه چیزی ثبت کرده‌اند. `TreatmentScheduler` هم فقط سررسید **جلسهٔ بعدی** را می‌نویسد و بقیه `null` می‌مانند (تصمیم عمدی؛ کامنت خودِ کلاس). پس هیچ‌جا نمی‌شود کل تقویم یک دوره را دید. ## مشکل / هدف سه چیز: 1. **تب «نوبت‌های بعدی» در پروندهٔ بیمار.** دوره‌های همان بیمار، و برای هر دوره کارتِ همهٔ جلسات با تاریخ و ساعتِ محاسبه‌شده. هر کارت دکمهٔ «ثبت نوبت» دارد که همان مودال `NewAppointmentModal` را با همان تاریخ/ساعت باز می‌کند و کاربر می‌تواند تاریخ و ساعت دیگری هم بگذارد. 2. **دیدن جزئیات انجام‌شده.** برای جلسات تمام‌شده باید معلوم باشد چه کسی، روی چه ناحیه‌ای، با چه دستگاهی و با چه خوانده‌هایی (انرژی/پالس/شات) کار کرده. 3. **واژهٔ «اپراتور» → «پرسنل»** در رشته‌های کاربرپسند، و پیش‌فرض شدن پرسنلِ پروتکل در مودال ثبت نوبت. --- ## ⚠ تصمیم معماری که باید قبل از کد روشن باشد خواستهٔ «همهٔ نوبت‌ها را بر اساس طول درمان محاسبه کن» با یک تصمیم ثبت‌شدهٔ پروژه در تضاد ظاهری است. متن خودِ `TreatmentScheduler`: > فقط جلسهٔ **بعدی** بازمحاسبه می‌شود. جلسات دورتر حدسِ قبلی‌شان را نگه می‌دارند تا > نوبتشان برسد — عددی که هنوز به هیچ واقعیتی گره نخورده، بازمحاسبه‌اش دقیق‌ترش نمی‌کند. **راه‌حل انتخابی: محاسبه بشود ولی ذخیره نشود.** - یک سرویس **فقط‌خواندنی** تقویم کل دوره را از روی آخرین لنگرِ واقعی می‌سازد. - `TreatmentSession.due_at` دست‌نخورده می‌ماند و همچنان فقط جلسهٔ بعدی را نگه می‌دارد. - در UI، جلسه‌ای که `due_at` واقعی دارد با جلسه‌ای که فقط تخمین است **متفاوت** نشان داده می‌شود. **چرا این و نه ذخیره کردن همه:** اگر همهٔ سررسیدها ذخیره شوند، هر بار که بیمار دیر می‌آید باید کل زنجیره بازنویسی شود و هر نسخهٔ ذخیره‌شده یک ادعای غلط دربارهٔ آینده است. تخمینِ محاسبه‌شده در لحظهٔ نمایش، همیشه با آخرین واقعیت هم‌خوان است و چیزی برای نگه‌داشتن ندارد. **ریسک این انتخاب:** تاریخ‌های کارت با هر بار باز کردن صفحه ممکن است عوض شوند (اگر جلسه‌ای بین دو بازدید تمام شده باشد). این پذیرفته است و باید در UI با برچسب «تخمینی» اعلام شود. **گزینهٔ جایگزینی که رد شد:** ساختن نوبت‌های `pending` برای کل دوره. رد شد چون جای دستگاه را می‌گیرد و بیمارِ نیامده وقت منبع را می‌سوزاند — همان دلیلی که «رزرو خودکار» قبلاً رد شده بود. --- ## معیار پذیرش - ✅ موفق: `GET /api/v1/treatment-cases?record=` با توکن منشی → ۲۰۰ و فقط پرونده‌های همان بیمار. `GET /api/v1/treatment-case/{uuid}/plan` → ۲۰۰ با آرایهٔ جلسات که هر کدام `planned_at` و `is_estimate` دارند. - ✅ موفق: در `/admin/patients/{uuid}?tab=treatment` کارت هر جلسه تاریخ و ساعت شمسی نشان می‌دهد و دکمهٔ «ثبت نوبت» مودال `NewAppointmentModal` را با همان تاریخ/ساعت و همان بیمار و سرویس باز می‌کند. - ✅ موفق: در مودال ثبت نوبت، وقتی سرویسی با پروتکل فعال انتخاب می‌شود، فیلد «پرسنل» از `TreatmentProtocol.staff` پیش‌فرض پر می‌شود. - ❌ خطا: `GET /api/v1/treatment-case/{uuid}/plan` برای پروندهٔ محیط دیگر → ۴۰۴ با envelope خطا. `?record=` با uuid ناموجود → آرایهٔ خالی، نه ۵۰۰. - ⚠️ مرزی: بیمارِ بدون هیچ دوره → تب «نوبت‌های بعدی» حالت خالیِ طراحی‌شده دارد، نه اسکلتون بی‌پایان. - ⚠️ مرزی: دوره‌ای که همهٔ جلساتش تمام شده → کارت‌ها همه «انجام‌شده» با جزئیات، و هیچ دکمهٔ «ثبت نوبت» فعالی ندارند. - ⚠️ مرزی: جلسه‌ای که نوبت دارد → دکمه‌اش «ثبت نوبت» نیست؛ نوبت موجود نشان داده می‌شود. - ⚠️ مرزی: پروتکلی که هیچ پرسنلی ندارد → فیلد «پرسنل» خالی می‌ماند و فرم قفل نمی‌شود. --- ## فایل‌های مرتبط | فایل | نقش | |------|-----| | `src/Treatment/Controller/TreatmentCaseController.php` | فهرست و جزئیات پرونده؛ اندپوینت‌های جدید اینجا | | `src/Treatment/Repository/TreatmentCaseRepository.php` | `findForTenant` — فیلتر `record` اینجا اضافه می‌شود | | `src/Treatment/Service/TreatmentScheduler.php` | محاسبهٔ سررسید؛ **دست‌نخورده می‌ماند** | | `src/Treatment/Entity/TreatmentCase.php` | `toArray(withSessions)` — امروز نواحی را نمی‌فرستد | | `src/Treatment/Entity/TreatmentSession.php` | `toArray(withAreas)` | | `src/Treatment/Entity/SessionAreaRecord.php` | خوانده‌های دستگاه — منبع «ورک‌فلوی انجام‌شده» | | `assets/admin/pages/PatientDetailPage.tsx` | تب‌های پروندهٔ بیمار | | `assets/admin/components/appointments/NewAppointmentModal.tsx` | مودال ثبت نوبت | | `assets/admin/components/TreatmentCaseEditModal.tsx` | لیبل «اپراتور» | | `assets/admin/pages/TreatmentCasesPage.tsx` | رشتهٔ «اپراتور:» | | `assets/admin/pages/StaffSessionDetailPage.tsx` | رشتهٔ «اپراتور:» | | `docs/api/treatment.md` | سند اندپوینت‌ها | --- ## وضعیت فعلی ### فیلتر پرونده بر اساس بیمار وجود ندارد `TreatmentCaseRepository::findForTenant` فقط محیط، وضعیت، جستجو و بازهٔ تاریخ دارد: ```php public function findForTenant( string $entityType, int $entityId, ?string $status = null, ?string $q = null, ?int $openedFrom = null, ?int $openedTo = null, ): array { $qb = $this->createQueryBuilder('c') ->where('c.entityType = :type') ->andWhere('c.entityId = :id') // ... ``` ### جزئیات پرونده نواحی جلسات را نمی‌فرستد `TreatmentCase::toArray()`: ```php if ($withSessions) { $data['sessions'] = array_map( static fn (TreatmentSession $s): array => $s->toArray(), $this->sessions->toArray(), ); } ``` `TreatmentSession::toArray(bool $withAreas = false)` نواحی را فقط با آرگومان می‌دهد و اینجا فرستاده نمی‌شود. پس «چه کاری روی چه ناحیه‌ای انجام شد» در پاسخ نیست. ### تب‌های پروندهٔ بیمار ```tsx type TabKey = 'services' | 'info' | 'appointments' | 'payments' | 'wallet' | 'notes' | 'callcenter' | 'attach' | 'records'; const TABS: { key: TabKey; label: string; icon: (c: string) => React.ReactNode }[] = [ { key: 'services', label: 'سرویس‌ها', icon: (c) => }, // ... ]; ``` ### فیلد پرسنل در مودال ثبت نوبت — بدون پیش‌فرض ```tsx // اپراتور اختیاری است: خالی گذاشتنش جلسه را در صفِ مشترکِ پرسنلِ مجاز می‌گذارد، // پر کردنش آن را از قبل به یک نفر می‌دهد. const [staffUuid, setStaffUuid] = useState(''); ``` ```tsx ``` ### پروتکل، پرسنل مجاز را از قبل می‌فرستد `TreatmentProtocol::toArray()`: ```php 'staff' => array_map( static fn (TreatmentProtocolStaff $m): array => $m->toArray(), $this->allowedStaff->toArray(), ), ``` یعنی دادهٔ لازم برای پیش‌فرض هست؛ فقط مصرف نمی‌شود. --- ## وظایف ### ۱. فیلتر پرونده بر اساس بیمار به `findForTenant` پارامتر `?string $recordUuid = null` اضافه کن و در کنترلر از `?record=` بخوان. ```php if ($recordUuid !== null && $recordUuid !== '') { $qb->join('c.patientRecord', 'prf') ->andWhere('prf.uuid = :record') ->setParameter('record', $recordUuid); } ``` > اگر `q` هم فرستاده شده باشد، `patientRecord` قبلاً join شده — از alias تکراری > پرهیز کن (یک join مشترک، نه دو تا). **نحوه تست:** ```bash TOKEN=... # 09390039833 / QaTest@1234 curl -sk "https://clinic-pro.ddev.site/api/v1/treatment-cases?record=" \ -H "Authorization: Bearer $TOKEN" ``` باید فقط پرونده‌های همان بیمار برگردد. با `record=` نامعتبر → آرایهٔ خالی. تست PHPUnit در `tests/Treatment/TreatmentCaseEditTest.php` کنار تست‌های `findForTenant`. --- ### ۲. سرویس تقویم دوره (فقط‌خواندنی) `src/Treatment/Service/TreatmentPlanProjector.php` مسئولیت واحد: از روی جلسات یک پرونده، تاریخ **همهٔ** جلسات را بساز. قاعده: - جلسهٔ انجام‌شده → `planned_at = finished_at`، `is_estimate = false` - جلسه‌ای که نوبت دارد → `planned_at = appointment.slot_start`، `is_estimate = false` - جلسه‌ای که `due_at` دارد → `planned_at = due_at`، `is_estimate = false` - بقیه → از آخرین لنگر به بعد، با جمع زدن `offset_days` گام‌ها، `is_estimate = true` ```php final class TreatmentPlanProjector { private const DAY = 86400; /** @return list */ public function project(TreatmentCase $case): array { // جلسات به ترتیب شماره؛ لنگر = آخرین زمان قطعیِ دیده‌شده // برای هر جلسهٔ بی‌لنگر: anchor += offsetDays(stepNumber) * DAY } } ``` **چرا سرویس جدا و نه متد روی `TreatmentScheduler`:** آن کلاس *می‌نویسد* و این فقط *می‌خواند*. قاطی کردنشان یعنی یک کلاس با دو مسئولیت و ریسک اینکه تخمین اشتباهی ذخیره شود (guidelines §۵، اصل S). **نحوه تست:** تست واحد در `tests/Treatment/` — پرونده‌ای با ۴ جلسه بساز، جلسهٔ ۱ را تمام کن، و بررسی کن جلسهٔ ۲ `is_estimate=false` (چون `due_at` گرفته) و جلسات ۳ و ۴ `is_estimate=true` با فاصلهٔ درستِ گام‌هایشان باشند. حالت مرزی: پرونده‌ای که هیچ جلسهٔ تمام‌شده‌ای ندارد و نوبت هم ندارد → همه `is_estimate=true` با لنگرِ `opened_at`. --- ### ۳. اندپوینت تقویم + جزئیات انجام‌شده `GET /api/v1/treatment-case/{uuid}/plan` پاسخ برای هر جلسه: `uuid`، `session_number`، `status`، `planned_at`، `is_estimate`، `appointment` (اگر دارد)، `performed_by`، و **`areas`** با `parameters`، `resource`، `note`، `started_at`، `finished_at`. نواحی همان چیزی است که کاربر «ورک‌فلوی انجام‌شده» می‌نامد. `SessionAreaRecord::toArray()` از قبل همه را دارد؛ فقط باید `withAreas: true` فرستاده شود. **تصمیم:** اندپوینت جدا از `GET /treatment-case/{uuid}` باشد، نه اضافه کردن به آن. دلیل: پاسخ فعلی مصرف‌کنندهٔ دیگری دارد (مودال ویرایش) که نواحی را لازم ندارد و بزرگ‌ترش کردن یعنی هزینهٔ بی‌مصرف روی همان مسیر. **نحوه تست:** ```bash curl -sk "https://clinic-pro.ddev.site/api/v1/treatment-case//plan" -H "Authorization: Bearer $TOKEN" ``` باید همهٔ جلسات با `planned_at` بیایند و جلسهٔ انجام‌شده `areas[].parameters` داشته باشد. پروندهٔ محیط دیگر → ۴۰۴. --- ### ۴. تب «نوبت‌های بعدی» در پروندهٔ بیمار `TabKey` را با `'treatment'` گسترش بده و به `TABS` اضافه کن. کامپوننت جدید `assets/admin/components/patient/PatientTreatmentTab.tsx`: - `GET /api/v1/treatment-cases?record={uuid}` → فهرست دوره‌ها - برای دورهٔ باز، `GET /api/v1/treatment-case/{uuid}/plan` - هر جلسه یک کارت: شمارهٔ جلسه، تاریخ و ساعت شمسی (`formatDateTime`)، وضعیت با `StatusBadge type="treatment-session"`، و برچسب «تخمینی» وقتی `is_estimate` - جلسهٔ انجام‌شده: پرسنل + نواحی + خوانده‌های دستگاه - جلسهٔ بدون نوبت: دکمهٔ «ثبت نوبت» **دکمهٔ «ثبت نوبت» باید همان `NewAppointmentModal` را باز کند** — نه صفحهٔ جدید و نه مودال تازه. همان مودالی که در `/admin/appointments?resource=…` استفاده می‌شود. مودال این propها را می‌گیرد: `slot`، `services`، `date`، `clinicUuid`، `resource`. برای اینکه تاریخ و ساعتِ کارت پیش‌فرض شود، `date` را از `planned_at` بده. > **این را قبل از کد بررسی کن:** مودال امروز `treatment_session_uuid` را نمی‌فرستد > (فقط صفحهٔ `AppointmentCreatePage` می‌فرستد). برای اینکه نوبت به همان جلسه بچسبد، > باید prop تازه‌ای مثل `treatmentSessionUuid` به مودال اضافه شود و در payload برود. > بدون آن، اتصال دوباره به حدسِ سرویس می‌افتد — همان چیزی که در > `SessionBookingLink` صریح شد. **نحوه تست UI:** ```bash node .claude/skills/redesign-page/driver.mjs shot \ "https://clinic-pro.ddev.site/admin/patients/?tab=treatment" \ --out /tmp/plan.png --full --wait 5000 ``` سناریو: بیماری با دورهٔ باز → کارت‌ها دیده شوند؛ کلیک روی «ثبت نوبت» → مودال با تاریخ همان کارت باز شود؛ ثبت → جلسه `booked` شود و کارت به‌روز شود. --- ### ۵. پیش‌فرض شدن پرسنلِ پروتکل در مودال ثبت نوبت در `NewAppointmentModal`، وقتی `pick.serviceUuids` تغییر می‌کند، پروتکل سرویس اول را بخوان و اگر `staff` دارد و کاربر هنوز دستی چیزی انتخاب نکرده، اولین پرسنل را بگذار. ```tsx const protocolQ = useQuery({ queryKey: ['service-protocol', pick.serviceUuids[0]], queryFn: () => api.get(`/api/v1/service-item/${pick.serviceUuids[0]}/treatment-protocol`), enabled: pick.serviceUuids.length > 0, }); ``` **قاعده:** فقط وقتی پیش‌فرض بگذار که کاربر دست نزده باشد (یک فلگ `staffTouched`). وگرنه انتخاب دستیِ منشی با هر تغییر سرویس پاک می‌شود. **نحوه تست:** vitest در `assets/admin/components/appointments/ResourceBookingModal.test.tsx` — mock کردن پاسخ پروتکل با یک پرسنل، انتخاب سرویس، و بررسی اینکه `staff_uuid` در payload همان است. حالت مرزی: پروتکل بدون پرسنل → فیلد خالی و ثبت همچنان ممکن. --- ### ۶. «اپراتور» → «پرسنل» فقط رشته‌های کاربرپسند. چهار مورد: | فایل | خط | رشته | |------|----|------| | `assets/admin/components/appointments/NewAppointmentModal.tsx` | ۲۸۴ | `اپراتور (اختیاری)` | | `assets/admin/components/TreatmentCaseEditModal.tsx` | ۲۱۲ | `اپراتور` | | `assets/admin/pages/TreatmentCasesPage.tsx` | ۳۵۷ | `اپراتور: …` | | `assets/admin/pages/StaffSessionDetailPage.tsx` | ۱۱۶ | `اپراتور: …` | **دست نزن به** `assets/admin/pages/ResourcesPage.tsx:107`: ```tsx description="هر چیزی که ممکن است اشغال باشد: پزشک، اپراتور، اتاق، دستگاه. ظرفیت یعنی تعداد بیمار هم‌زمان." ``` آنجا «اپراتور» یک **نوع منبع** است، نه رکورد `ClinicStaff`. عوض کردنش معنی جمله را خراب می‌کند. اگر مطمئن نیستی، از کاربر بپرس. کامنت‌های کد را هم می‌توانی هماهنگ کنی ولی اولویت ندارد. **نحوه تست:** `npx vitest run` — تستی که به متن «اپراتور» تکیه کرده باشد باید به‌روز شود؛ بعد grep بزن که هیچ رشتهٔ کاربرپسندِ «اپراتور» جز مورد `ResourcesPage` نمانده باشد. --- ## نکات مهم - **`TreatmentScheduler` تغییر نمی‌کند.** تقویم تخمینی فقط خوانده می‌شود و `due_at` هیچ‌وقت از این مسیر نوشته نمی‌شود. اگر خودت را در حال `setDueAt` دیدی، از مسیر خارج شده‌ای. - الگوی به‌کاررفته: **Projection/Read Model** — یک سرویس فقط‌خواندنی که از داده‌های موجود یک نمای مشتق می‌سازد. دلیل انتخاب: نما با هر تغییرِ واقعیت خودش به‌روز است و هیچ داده‌ای برای همگام‌سازی ندارد. - سه حالت داده (Loading / Empty / Error) برای تب جدید اجباری است — الگوی موجود در `TreatmentCasesPage.tsx` را ببین: خطای سرور نباید «دوره‌ای ندارد» خوانده شود. - ارقام با `formatNumber` و تاریخ‌ها با `formatDateTime` (شمسی). ارقام لاتین وسط متن فارسی، نقصِ تکرارشوندهٔ این صفحه‌ها بوده. - وضعیت تب در URL است (`?tab=treatment`) — `PatientDetailPage` از قبل `searchParams` را می‌خواند. - بعد از هر تغییر API، `docs/api/treatment.md` در همان جلسه به‌روز شود. - تنانسی: هر دو اندپوینت جدید باید از `requireCase`/`branches->pair($user)` عبور کنند؛ پروندهٔ محیط دیگر ۴۰۴ می‌گیرد نه ۴۰۳. - migration لازم **نیست** — هیچ Entity تغییر نمی‌کند.