@@ -0,0 +1,370 @@
# تب «نوبتهای بعدی» در پروندهٔ بیمار + اصلاح واژهٔ پرسنل
## پروژه
`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=<record_uuid>` با توکن منشی → ۲۰۰ و فقط
پروندههای همان بیمار. `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 ) = > < TabServices color = { c } / > } ,
// ...
] ;
```
### فیلد پرسنل در مودال ثبت نوبت — بدون پیشفرض
``` tsx
// اپراتور اختیاری است: خالی گذاشتنش جلسه را در صفِ مشترکِ پرسنلِ مجاز میگذارد،
// پر کردنش آن را از قبل به یک نفر میدهد.
const [ staffUuid , setStaffUuid ] = useState ( '' ) ;
```
``` tsx
< label htmlFor = "appt-operator" > ا پ ر ا ت و ر < span className = "opt" > ( ا خ ت ی ا ر ی ) < / span > < / label >
```
### پروتکل، پرسنل مجاز را از قبل میفرستد
`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=<record_uuid>" \
-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<array{session: TreatmentSession, planned_at: ?int, is_estimate: bool}> */
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/<uuid>/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/<uuid>?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 تغییر نمیکند.