# تب «نوبت‌های بعدی» در پروندهٔ بیمار + اصلاح واژهٔ پرسنل

## پروژه

`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 تغییر نمی‌کند.
