` دوم با ``) وقتی `expanded === c.uuid` رندر شود و همهی فیلدهای کامل قرارداد را نشان دهد:
+ - درصد پوشش، فرانشیز، سقف تعهد سالانه
+ - تاریخ شروع و پایان قرارداد (`effective_from` / `effective_to`) با `formatDate` شمسی (`assets/admin/lib/utils.ts`)؛ اگر `effective_to == null` → «بدون تاریخ پایان»
+ - نسخهی قرارداد (`version`) و کد بیمه (`insurance_id`)
+ - وضعیت فعال/غیرفعال
+- انیمیشن باز/بستهشدن نرم باشد (از `--ease` استفاده کن یا یک transition ساده روی ارتفاع/opacity).
+
+**۴.۳ نسخهی موبایل (`md:hidden`):** همان الگوی Expandable روی کارتها — کارت جمعشده خلاصه را نشان دهد و با کلیک جزئیات کامل باز شود.
+
+**۴.۴ Empty state هر Tab:** اگر قراردادی برای Tab فعال نبود، پیام مناسب همان نوع: «هنوز بیمهی پایهای اضافه نکردهاید.» / «هنوز بیمهی تکمیلیای اضافه نکردهاید.» (بهجای پیام عمومی فعلی).
+
+## نکات مهم
+
+- **بدون تغییر backend و بدون migration.** فقط frontend. اگر حین کار حس کردی endpoint کم دارد، اول دوباره بگرد — احتمالاً داده در همان `insurance-pricing` / `tenant-insurances` هست.
+- **SOLID / تکمسئولیتی (قاعده ۱):** ردیف Expandable و کارت موبایل را به کامپوننتهای کوچک جدا کن (مثلاً `ContractRow`, `ContractCard`, `ContractDetails`) تا `TenantInsuranceContracts` متورم نشود. `StatusToggle` و `Row` فعلی را نگهدار/بازاستفاده کن.
+- **توکنهای طراحی:** هیچ رنگ hex هاردکد نکن؛ از `var(--...)` استفاده کن (`--primary`, `--surface-2`, `--border`, `--text-2/3`, `--success`, `--r`, `--ease`). منبع توکنها `assets/admin/styles.css` است.
+- **RTL و فارسی:** همهی رشتهها فارسی، اعداد و مبالغ فارسی از طریق `formatRial`/`formatNumber`/`formatDate`. از `insetInlineStart`/`paddingInlineStart` (نه left/right) مثل کد فعلی.
+- **`insurance_kind` ممکن است `null` باشد** (قراردادهای قدیمی که `kind` نداشتند و `type` کاتالوگشان هم null بوده) — در فیلتر Tab آن را به `'basic'` fallback بده تا گم نشود.
+- **حالت ویرایش:** نوع بیمه در ویرایش تغییر نمیکند؛ مودال در ویرایش `kind` قرارداد را حفظ میکند و لیست کامل `allInsurances` را میدهد (چون `insuranceId` قفل/`isDisabled` است).
+- **تستها (قاعده ۴):** فایل تست موجود `assets/admin/components/TenantInsuranceContracts.test.tsx` و `InsuranceModal.test.tsx` را بهروزرسانی/گسترش بده — سناریوها: (الف) فیلتر Tab قراردادها را درست جدا میکند، (ب) لیست انتخاب افزودن فقط بیمههای همان نوع را دارد، (ج) کلیک روی ردیف جزئیات را باز/بسته میکند، (د) کلیک روی دکمهی ویرایش/سوییچ ردیف را toggle نمیکند (`stopPropagation`)، (ه) `AppointmentSettingsPage.test.tsx`: `FreeVisitPrice` رندر میشود حتی وقتی `uuid` نیست. تستها را با `yarn test` سبز کن.
+- **type check:** `npx tsc --noEmit --project tsconfig.json` بدون خطا.
+- **مصرفکنندهی دیگر `FreeVisitPrice`:** مطمئن شو جایی جز `InsurancePricingPage` آن را import نمیکند (grep) تا انتقال چیزی را نشکند.
diff --git a/.claude/prompt/insurance-shared-calculation.md b/.claude/prompt/insurance-shared-calculation.md
new file mode 100644
index 00000000..d47eaa65
--- /dev/null
+++ b/.claude/prompt/insurance-shared-calculation.md
@@ -0,0 +1,202 @@
+# اصلاح منطق بیمه: یک منبع واحد محاسبه برای پرداخت، فاکتور و Claim
+
+## پروژه
+
+`clinicpro` (Backend Symfony + پنل ادمین React)
+
+پرامپت همتا: `clinicpro/.claude/prompt/claims-dashboard-redesign.md` (بازطراحی صفحه `/admin/claims`) — **اول این پرامپت اجرا شود**، چون داشبورد Claims به فیلدهای محاسباتی این پرامپت وابسته است.
+
+## زمینه
+
+در کلینیک `41e325c4-e825-4067-8438-5d828ecaee09` یک سرویس دارای پوشش بیمه ساخته شده (`/admin/clinic-services/f3e46236-7ddd-49d0-a725-d731c74c24f7`) و برای بیمار `ad0a3d0e-5514-462c-9fcf-20748c1c5e46` ثبت شده است. در صفحه تکمیل پرداخت
+`/admin/patients/ad0a3d0e-5514-462c-9fcf-20748c1c5e46/session/4f66d5c0-028f-424f-bec7-a10857f11c04/pay`
+پوشش بیمه اعمال نمیشود و مبلغ قابل پرداخت بیمار برابر کل مبلغ سرویس نمایش داده میشود.
+
+ریشه مشکل: **دو مسیر محاسباتی مستقل** وجود دارد و `PatientSession` هیچ ستونی برای سهم بیمه ندارد؛ بنابراین breakdown بیمه فقط بعد از ساخت `Invoice` وجود دارد و صفحه پرداخت اصلاً آن را نمیبیند.
+
+## مشکل / هدف
+
+۱. حذف محاسبه inline ویزیت در `PatientService::calculateFinalPrice()` و یکیکردن همهی محاسبات روی `BillingCalculator`.
+۲. ذخیره breakdown بیمه روی `PatientSession` تا صفحه پرداخت، فاکتور، سهم بیمار/بیمه، مانده و وضعیت پرداخت همگی از یک مقدار بخوانند.
+۳. نمایش سهم بیمه پایه/تکمیلی در صفحه پرداخت.
+۴. رفع ناسازگاریهای فرمول مانده و over-payment guard.
+
+## فایلهای مرتبط
+
+| فایل | نقش |
+|------|-----|
+| `src/Billing/Service/BillingCalculator.php` | تنها منبع درست محاسبه سهمها (percent + franchise + ceiling) |
+| `src/Billing/ValueObject/Money.php` | VO پول؛ `sub()` در صفر clamp میشود |
+| `src/Insurance/Service/TenantInsuranceService.php` | `coverageRule()` و `coverageRuleForService()` — resolve قرارداد + override سرویس |
+| `src/Insurance/Entity/TenantInsurance.php` | قرارداد: `coveragePercent`, `franchiseRials`, `annualCeilingRials`, `isActive`, `effectiveFrom/To` |
+| `src/Insurance/Entity/TenantServiceCoverage.php` | override به ازای (قرارداد، serviceItem): `covered`, `coveragePercent`, `franchiseRials`, `ceilingRials` (null = ارث از قرارداد) |
+| `src/ClinicService/Entity/ServiceItem.php` | `insuranceCovered` (گیت bool)، `priceRials`، `insurancePriceRials` (فعلاً dead data) |
+| `src/Patient/Service/PatientService.php` | `calculateFinalPrice()`، `recomputeSettlement()`، `addSessionPayment()`، `updatePayment()` |
+| `src/Patient/Entity/PatientSession.php` | `finalPriceRials`, `servicesTotalRials`, `discountRials`, `getPaidTotalRials()`, `getRemainingRials()` |
+| `src/Billing/Service/InvoiceService.php` | ساخت فاکتور از session |
+| `src/Patient/Controller/PatientController.php` | `POST /api/v1/session/{uuid}/payments` و لیست sessionها |
+| `assets/admin/components/session/PaymentStep.tsx` | UI صفحه پرداخت (مشترک با `NewSessionPage`) |
+| `assets/admin/components/InvoiceSummaryModal.tsx` | مودال فاکتور بیمار |
+
+## وضعیت فعلی
+
+`src/Billing/Service/BillingCalculator.php` (منطق درست):
+
+```php
+$baseShare = $total->percent($base->coveragePercent);
+if ($base->ceilingRials !== null) $baseShare = $baseShare->min(new Money($base->ceilingRials));
+$remaining = $total->sub($baseShare);
+// تکمیلی روی باقیمانده اعمال میشود، نه روی کل
+$suppShare = $remaining->percent($supplementary->coveragePercent);
+...
+$franchise = new Money(($base?->franchiseRials ?? 0) + ($supplementary?->franchiseRials ?? 0));
+$patient = $remaining->add($franchise)->min($total);
+```
+
+`src/Patient/Service/PatientService.php::calculateFinalPrice()` — سرویسها از `BillingCalculator` میآیند اما **ویزیت inline حساب میشود** و آن هم از درصدهای ذخیرهشده روی session، نه از قرارداد:
+
+```php
+$afterBase = $visitPrice * (1 - $baseDiscount / 100);
+$afterSupp = $afterBase * (1 - $suppDiscount / 100);
+$visitShare = (int) round($afterSupp);
+```
+
+`assets/admin/components/session/PaymentStep.tsx:117-122` — کل محاسبه سمت کلاینت:
+
+```ts
+const finalPrice = session.final_price_rials ?? 0;
+const discountRials = session.discount_rials ?? 0;
+const payable = Math.max(0, finalPrice - discountRials);
+```
+
+هیچ فیلد `base_insurance_rials` / `supplementary_rials` در پاسخ session وجود ندارد، پس سهم بیمه اصلاً قابل نمایش نیست.
+
+`InvoiceSummaryModal.tsx:66-73` — مانده در شاخهی session سهم بیمه را نادیده میگیرد:
+
+```ts
+const remaining = session
+ ? Math.max(0, session.final_price_rials - (session.discount_rials ?? 0) - session.paid_total_rials)
+ : inv ? (paid ? 0 : inv.patient_rials) : 0;
+```
+
+## وظایف
+
+### ۱. دیباگ اولیه: چرا پوشش بیمه اعمال نشده؟
+
+قبل از هر تغییر کد، با داده واقعی بررسی کن (روی ddev):
+
+```bash
+ddev exec php bin/console dbal:run-sql "SELECT id, insurance_covered, price_rials, insurance_price_rials FROM service_item WHERE uuid = 'f3e46236-7ddd-49d0-a725-d731c74c24f7'"
+ddev exec php bin/console dbal:run-sql "SELECT * FROM tenant_insurance WHERE entity_type='clinic' AND is_active=1"
+ddev exec php bin/console dbal:run-sql "SELECT * FROM tenant_service_coverage"
+ddev exec php bin/console dbal:run-sql "SELECT uuid, insurance_base_id, insurance_supplementary_id, services_total_rials, final_price_rials, discount_rials FROM patient_session WHERE uuid = '4f66d5c0-028f-424f-bec7-a10857f11c04'"
+```
+
+سه fail-point محتمل را مشخص کن و در گزارش بنویس کدامیک بوده است:
+- `service_item.insurance_covered = 0` → گیت بسته است.
+- `patient_session.insurance_base_id = NULL` → بیمه هنگام ثبت سرویس به session نچسبیده (احتمالاً UI ثبت سرویس بیمه بیمار را ارسال نمیکند).
+- `tenant_insurance` برای این کلینیک وجود ندارد یا `effective_from/to` بازهی تاریخ session را پوشش نمیدهد.
+
+اگر fail-point «بیمه به session نچسبیده» بود، مسیر ثبت سرویس برای بیمار را هم اصلاح کن تا `insurance_base_id`/`insurance_supplementary_id` از بیمهی ثبتشدهی بیمار پر شود.
+
+### ۲. یکیکردن محاسبه ویزیت
+
+در `PatientService::calculateFinalPrice()` محاسبه inline ویزیت را حذف کن و مثل خطوط سرویس از `TenantInsuranceService::coverageRule()` + `BillingCalculator::calculateItem()` استفاده کن — دقیقاً همان چیزی که `InvoiceService.php:48-51` انجام میدهد.
+
+فیلدهای `base_insurance_discount_percent` / `supplementary_discount_percent` روی session را بهعنوان **snapshot** نگه دار (backward compat) اما دیگر ورودی محاسبه نباشند؛ بعد از محاسبه از روی درصدهای قرارداد پرشان کن.
+
+### ۳. ذخیره breakdown بیمه روی PatientSession
+
+سه ستون جدید به `PatientSession` اضافه کن (nullable-not، default 0):
+
+- `baseInsuranceRials`
+- `supplementaryInsuranceRials`
+- `patientShareRials`
+
+قرارداد: `patientShareRials` همان چیزی است که `finalPriceRials` باید باشد (سهم بیمار **قبل** از تخفیف دستی). یعنی:
+
+```
+servicesTotalRials = مجموع مبلغ اصلی همه اقلام (ویزیت + سرویسها)
+baseInsuranceRials + supplementaryInsuranceRials + patientShareRials = servicesTotalRials
+finalPriceRials = patientShareRials
+payable = finalPriceRials - discountRials
+remaining = max(0, payable - paidTotal)
+```
+
+هر جا session ذخیره یا بازمحاسبه میشود این سه ستون هم نوشته شوند. migration لازم است:
+
+```bash
+ddev exec php bin/console make:migration
+ddev exec php bin/console doctrine:migrations:migrate -n
+```
+
+**Backfill:** برای sessionهای موجود، مقدار `patientShareRials = finalPriceRials` و دو ستون بیمه = 0 ست شود تا رفتار قدیمی نشکند.
+
+### ۴. حذف تکرار فرمول مانده و over-payment guard
+
+- `PatientService::updatePayment()` (حدود `:419-421`) که `payable = finalPrice - discount` را inline دوباره میسازد را حذف کن و از `PatientSession::getRemainingRials()` استفاده کن — همان چیزی که `addSessionPayment()` (`:570`) استفاده میکند.
+- در `updatePayment` هنگام ویرایش یک پرداخت موجود، مبلغ همان پرداخت باید از `paidTotal` کسر شود وگرنه ویرایش به سمت بالا اشتباهاً reject میشود. این edge case را تست کن.
+
+### ۵. خروجی API
+
+در `toArray()` مربوط به session (مسیر `GET /api/v1/patient/{uuid}/sessions` و پاسخهای `POST/PATCH /api/v1/session/{uuid}/...`) این فیلدها اضافه شوند:
+
+```json
+{
+ "services_total_rials": 0,
+ "base_insurance_rials": 0,
+ "supplementary_insurance_rials": 0,
+ "patient_share_rials": 0,
+ "final_price_rials": 0,
+ "discount_rials": 0,
+ "paid_total_rials": 0,
+ "remaining_rials": 0,
+ "insurance_base_title": null,
+ "insurance_supplementary_title": null
+}
+```
+
+`remaining_rials` را سرور بدهد تا کلاینت دیگر مانده را خودش نسازد.
+
+### ۶. UI صفحه پرداخت
+
+در `assets/admin/components/session/PaymentStep.tsx`:
+
+- به بخش خلاصه مبالغ (`:196-209`) این ردیفها اضافه شود، **فقط وقتی مقدارشان > 0 است**:
+ - `سهم بیمه پایه` (+ نام بیمه)
+ - `سهم بیمه تکمیلی`
+ - `سهم بیمار`
+- `payable` دیگر client-side ساخته نشود؛ از `remaining_rials` سرور استفاده شود.
+- ترتیب نمایش: هزینه کل خدمات → سهم بیمه پایه → سهم بیمه تکمیلی → سهم بیمار → تخفیف → مبلغ نهایی قابل پرداخت → پرداختشده → مانده.
+
+**احتیاط:** این کامپوننت با `NewSessionPage` (ویزارد ۳ مرحلهای) مشترک است — هر دو مسیر باید تست شوند.
+
+### ۷. اصلاح مودال فاکتور
+
+در `assets/admin/components/InvoiceSummaryModal.tsx`:
+
+- دو شاخهی واگرای «با session» و «بدون session» را یکی کن؛ هر دو باید ستونهای یکسان نشان دهند: جمع خدمات / سهم بیمه پایه / سهم بیمه تکمیلی / سهم بیمار / تخفیف / مبلغ نهایی / پرداختشده / مانده.
+- `remaining` را از `remaining_rials` سرور بگیر، نه از فرمول محلی.
+- منطق «وضعیت واقعی پرداخت مستقل از وضعیت فریزشده فاکتور» (تسویهشده اگر مانده صفر) عمداً وجود دارد — حفظش کن.
+
+## نکات مهم
+
+- تکمیلی روی **باقیمانده بعد از پایه** اعمال میشود، نه روی کل. این قاعده در `BillingCalculator` درست است و نباید تغییر کند.
+- `ServiceItem.insurancePriceRials` فعلاً write-only است و هیچ محاسبهای نمیخواندش. یا آن را بهعنوان «مبلغ ثابت پوشش» وارد `BillingCalculator` کن (اولویت بالاتر از percent) یا از UI و `toArray()` حذفش کن — تصمیم را در گزارش بنویس. حالت نصفهکاره نگهداشتنش قابل قبول نیست.
+- `annualCeilingRials` امروز بهصورت **سقف هر قلم** اعمال میشود در حالی که نامش سقف سالانه است. انباشت سالانهای در کد نیست. رفتار فعلی را تغییر نده اما در کامنت و در گزارش صریح ذکرش کن.
+- مرز ریال/تومان: ورودیهای UI توماناند، API ریال. از `tomanToRial` / `rialToToman` در `lib/utils.ts` استفاده شود (`RIAL_PER_TOMAN = 10`).
+- `Money::sub()` در صفر clamp میشود و مقدار منفی نمیپذیرد — روی مبالغ سهمها به آن تکیه کن، `max(0, ...)` دستی ننویس.
+- قیمت سرویس در session هرچه caller بفرستد ذخیره میشود، ولی فاکتور دوباره از `TariffService::resolvePrice()` برای سال جلالی جاری resolve میکند. این واگرایی را حل کن: session هم باید از `TariffService` قیمت بگیرد.
+- همه controllerها از `BaseController` ارث میبرند؛ پاسخها با `$this->success()` / `$this->paginated()` / `$this->error()`.
+- تاریخها Unix timestamp صحیح؛ نمایش شمسی با `formatDate()`.
+- تست با کاربر `09390039833 / 09390039833` روی `https://clinic-pro.ddev.site`.
+- بعد از تغییر API، فایلهای مربوطه در `clinicpro/docs/api/` بهروز شوند.
+
+## تست پذیرش
+
+۱. سرویس `f3e46236-...` برای بیمار `ad0a3d0e-...` ثبت شود؛ در صفحه `/pay` باید سهم بیمه پایه و سهم بیمار جدا نمایش داده شوند و مبلغ قابل پرداخت = سهم بیمار باشد.
+۲. همان session → مودال فاکتور: اعداد باید **دقیقاً** با صفحه پرداخت یکی باشند.
+۳. پرداخت جزئی ثبت شود → مانده در هر دو صفحه یکسان کم شود.
+۴. پرداخت کامل → وضعیت در هر دو جا «تسویه شده».
+۵. سرویسی بدون پوشش بیمه → سهم بیمه ۰، رفتار قبلی بدون تغییر.
+۶. ویرایش یک پرداخت موجود به مبلغ بالاتر → نباید اشتباهاً «بیش از مانده» reject شود.
diff --git a/.claude/prompt/inventory-unit-select-and-category.md b/.claude/prompt/inventory-unit-select-and-category.md
new file mode 100644
index 00000000..3d303ff4
--- /dev/null
+++ b/.claude/prompt/inventory-unit-select-and-category.md
@@ -0,0 +1,247 @@
+# واحد کالا بهصورت Select + سیستم دستهبندی اصولی کالا (انبارداری)
+
+## پروژه
+
+`clinicpro` (Backend Symfony + پنل ادمین React). صفحه هدف: `/admin/inventory`.
+
+## زمینه
+
+بخش انبارداری (`InventoryPage`) اجازه ایجاد/ویرایش «کالا» را میدهد. دو ضعف طراحی وجود دارد:
+
+1. **واحد (`unit`)** بهصورت متن آزاد وارد میشود (`AddItemModal` فقط یک ` ` متنی است، پیشفرض `'عدد'`). نتیجه: داده ناهمگون («cc»، «سی سی»، «سیسی»، «میلی لیتر»، «ml» و …) که گزارشگیری و یکپارچگی را خراب میکند.
+
+2. **دستهبندی وجود ندارد.** چیزی که امروز بهعنوان «دسته» کار میکند در واقع فیلد متنآزاد `consumable` («مصرفی») است: اندپوینت `GET /api/v1/inventory-categories` مقادیر متمایز همین ستون را برمیگرداند (`InventoryItemRepository::findConsumables`)، و صفحه با `it.consumable === category` فیلتر میکند. این یعنی «دستهبندی» عملاً متن آزاد و بیساختار است.
+
+هدف: هر دو فیلد را به لیستهای استاندارد و **محدودشده (bounded)** تبدیل کنیم که **منبعِ صدقشان Backend** باشد، تا فرانت و بک هرگز از هم جدا نیفتند.
+
+## مشکل / هدف
+
+- `unit`: تبدیل به Select از واحدهای استاندارد و پرکاربرد مطب/کلینیک.
+- افزودن `category`: فیلد دستهبندی واقعی و اصولی، از یک لیست ثابت استاندارد، جایگزینِ نقشِ فیلترِ `consumable`.
+- لیست هر دو باید در Backend تعریف شود و از طریق یک اندپوینت واحد به فرانت داده شود (بدون هاردکد دوباره در فرانت → جلوگیری از drift).
+
+## فایلهای مرتبط
+
+| فایل | نقش | تغییر |
+|------|-----|-------|
+| `src/Inventory/Entity/InventoryItem.php` | Entity کالا | افزودن ستون `category`؛ نگهدارنده لیستهای مجاز |
+| `src/Inventory/Controller/InventoryController.php` | endpointها | endpoint متادیتا + اعتبارسنجی `unit`/`category` |
+| `src/Inventory/Repository/InventoryItemRepository.php` | کوئریها | `findConsumables` → مبتنی بر `category` |
+| `src/Inventory/Service/InventoryService.php` | منطق دامنه | جای مناسب برای منبع لیستها (Vocabulary) |
+| `assets/admin/components/inventory/AddItemModal.tsx` | فرم افزودن/ویرایش | دو ` ` → دو Select |
+| `assets/admin/hooks/useInventory.ts` | data hook | type `category`، کوئری متادیتا |
+| `assets/admin/pages/InventoryPage.tsx` | صفحه | فیلتر بر اساس `category` |
+| `migrations/VersionXX; docs/api/inventory.md` | مهاجرت + مستند | ستون جدید + قرارداد endpoint |
+
+## وضعیت فعلی (کد واقعی)
+
+**Entity — `InventoryItem.php`** (واحد متنآزاد، بدون دسته):
+
+```php
+#[ORM\Column(type: 'string', length: 30)]
+private string $unit = 'عدد';
+
+/** Free-text "مصرفی" classifier from the source modal; doubles as filter group. */
+#[ORM\Column(type: 'string', length: 120, nullable: true)]
+private ?string $consumable = null;
+```
+
+**Controller — اعمال فیلدها بدون اعتبارسنجی مقدار مجاز:**
+
+```php
+if (array_key_exists('unit', $data)) {
+ $unit = trim((string) $data['unit']);
+ $item->setUnit($unit === '' ? 'عدد' : $unit);
+}
+```
+
+**«دستهها» امروز = مقادیر متمایز `consumable`:**
+
+```php
+// InventoryItemRepository::findConsumables
+->select('DISTINCT i.consumable AS consumable')
+->where('i.entityType = :type AND i.entityId = :id AND i.consumable IS NOT NULL AND i.consumable != :empty')
+```
+
+**Modal — واحد بهصورت input متنی:**
+
+```tsx
+const fields = [
+ { key: 'name', label: 'نام کالا', placeholder: 'نام کالا' },
+ { key: 'consumable', label: 'مصرفی', placeholder: 'مصرفی' },
+ { key: 'unit', label: 'واحد', placeholder: 'عدد' }, // ← متن آزاد
+ ...
+];
+```
+
+**صفحه — فیلتر بر اساس `consumable`:**
+
+```tsx
+const [category, setCategory] = useState('');
+const filteredItems = items.filter((it) =>
+ ... && (category === '' || it.consumable === category) // ← consumable نقش دسته
+);
+```
+
+## وظایف
+
+### ۱. تعریف Vocabulary استاندارد در Backend (منبع صدق)
+
+یک منبع واحد برای لیست واحدها و دستهها بساز. جای پیشنهادی: constant روی `InventoryItem` (یا کلاس کوچک `InventoryVocabulary` در `src/Inventory/`). ساختار پیشنهادی: آرایهی `value => label`؛ `value` انگلیسی پایدار (برای ذخیره)، `label` فارسی (برای نمایش). این هم i18n را تمیز نگه میدارد هم داده را پایدار.
+
+> اگر ترجیح میدهی سادهتر بمانی و مقدارِ ذخیرهشده همان برچسب فارسی باشد (همراستا با وضعیت فعلی که `unit` فارسی ذخیره میشود)، میتوانی فقط لیست فارسی مسطح نگه داری. **در این صورت حتماً یک لیست ثابت واحد در Backend داشته باش و فرانت آن را از endpoint بگیرد — نه هاردکد جدا.** تصمیم را در همان session بگیر و در `docs/api/inventory.md` مستند کن.
+
+**واحدهای استاندارد (کلینیک/مطب) — لیست پیشنهادی:**
+
+```
+عدد، جفت، دست، بسته، جعبه، قوطی، تیوب، ویال، آمپول،
+قرص، کپسول، ورق (بلیستر)، ساشه، رول، متر، سانتیمتر،
+سیسی، میلیلیتر، لیتر، میلیگرم، گرم، کیلوگرم، کیسه، عدد استریل
+```
+
+پیشنهاد نهایی مرتب و بدون تکرار (حدود ۱۸–۲۰ واحد). واحدهای پرکاربرد را بالای لیست بگذار (عدد، بسته، ویال، آمپول، سیسی، میلیلیتر).
+
+**دستهبندیهای استاندارد کلینیک/مطب — لیست پیشنهادی:**
+
+```
+دارو
+لوازم مصرفی و تزریقات (سرنگ، سرسوزن، گاز، پنبه)
+لوازم پانسمان و بخیه
+مواد ضدعفونی و استریلیزاسیون
+تجهیزات پزشکی
+بیهوشی و بیحسی
+لوازم زیبایی و پوست (بوتاکس، فیلر، مزو)
+لوازم آزمایشگاهی
+لوازم دندانپزشکی
+ملزومات اداری و مصرفی دفتری
+سایر
+```
+
+این لیستها را در Backend بهصورت constant قابلتوسعه بگذار و در docblock توضیح بده که افزودن گزینه = افزودن به همین آرایه (بدون migration، چون مقدار در ستون string ذخیره میشود).
+
+### ۲. Entity: افزودن ستون `category` + اعتبارسنجی مقدار
+
+- ستون جدید در `InventoryItem`:
+
+```php
+#[ORM\Column(type: 'string', length: 60, nullable: true)]
+private ?string $category = null;
+
+public function getCategory(): ?string { return $this->category; }
+public function setCategory(?string $v): self { $this->category = $v; return $this->touch(); }
+```
+
+- `category` را به `toArray()` اضافه کن.
+- constantهای لیست مجاز (`UNITS`, `CATEGORIES`) را روی همین کلاس (یا Vocabulary) قرار بده و در docblock کلاس، توضیح `consumable` را اصلاح کن (دیگر «doubles as filter group» نیست).
+
+> `consumable` را حذف نکن — سازگاری عقبرو و کلاینت tauri را نشکن. آن را همان فیلد یادداشت/طبقهبندی آزاد باقی بگذار، اما نقش «دسته/فیلتر» را از آن بردار.
+
+### ۳. Controller: endpoint متادیتا + اعتبارسنجی نوشتن
+
+- **endpoint جدید متادیتا** (لیستها را به فرانت بده):
+
+```php
+#[Route('/api/v1/inventory-meta', methods: ['GET'])]
+public function meta(): JsonResponse
+{
+ return $this->success([
+ 'units' => InventoryItem::UNITS, // یا Vocabulary::units()
+ 'categories' => InventoryItem::CATEGORIES,
+ ]);
+}
+```
+
+- در `applyItemFields()`:
+ - `unit`: اگر مقدار در لیست مجاز نبود → یا `ERR_VALIDATION_001` با فیلد `unit`، یا fallback به `'عدد'`. اعتبارسنجی سختگیرانه ترجیح داده میشود (پیام فارسی: «واحد نامعتبر است»).
+ - `category`: کلید جدید؛ خالی → `null`؛ مقدار نامعتبر → `ERR_VALIDATION_001` فیلد `category` («دستهبندی نامعتبر است»).
+
+```php
+if (array_key_exists('unit', $data)) {
+ $unit = trim((string) $data['unit']);
+ if ($unit !== '' && !array_key_exists($unit, InventoryItem::UNITS)) {
+ throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'واحد نامعتبر است', 422);
+ }
+ $item->setUnit($unit === '' ? 'عدد' : $unit);
+}
+if (array_key_exists('category', $data)) {
+ $cat = trim((string) $data['category']);
+ if ($cat !== '' && !array_key_exists($cat, InventoryItem::CATEGORIES)) {
+ throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'دستهبندی نامعتبر است', 422);
+ }
+ $item->setCategory($cat === '' ? null : $cat);
+}
+```
+
+> اگر لیستِ مسطحِ فارسی را انتخاب کردی، `array_key_exists` را با `in_array($v, InventoryItem::UNITS, true)` جایگزین کن. الگوی پاسخها را با `BaseController` (`$this->success/$this->error`) و پرتاب `AppException` همراستا نگه دار.
+
+### ۴. Repository: تغییر منبع فیلتر دسته به `category`
+
+`findConsumables` (یا نام بهتر `findCategories`) باید مقادیر متمایز `category` را برگرداند، نه `consumable`:
+
+```php
+->select('DISTINCT i.category AS category')
+->where('i.entityType = :type AND i.entityId = :id AND i.category IS NOT NULL AND i.category != :empty')
+```
+
+> نکته: با endpoint متادیتا (وظیفه ۳) که کل لیست ثابت را میدهد، فیلترِ صفحه بهتر است از **لیست ثابت کامل** استفاده کند (نه فقط دستههای استفادهشده). اما اگر میخواهی «فقط دستههایی که کالا دارند» را در dropdown فیلتر نشان دهی، همین کوئری اصلاحشده کافی است. تصمیم را در پرامپتاجرا بگیر و ثابت بمان.
+
+### ۵. مهاجرت (Migration)
+
+- `ddev exec php bin/console doctrine:migrations:diff --no-interaction` سپس `migrate`.
+- (اختیاری، توصیهشده) Backfill: اگر مقدار `consumable` فعلی دقیقاً با یکی از دستههای استاندارد یکی بود، در همان migration به `category` منتقل شود؛ در غیر این صورت `category` نال بماند.
+
+### ۶. Frontend — Modal: دو Select بهجای input
+
+- `useInventory` را گسترش بده:
+ - type `InventoryItem` و `ItemPayload`: افزودن `category?: string | null`.
+ - کوئری جدید `metaQuery` روی `GET /api/v1/inventory-meta` (staleTime بالا / `Infinity`، چون تقریباً ثابت است). خروجی: `units`, `categories`.
+- `AddItemModal`:
+ - از کامپوننت طراحیسیستم `SearchableSelect` (`components/ui/`) استفاده کن (react-select زیر آن است) برای `unit` و `category` — هماهنگ با CLAUDE.md.
+ - `unit` الزامی با پیشفرض `عدد`؛ `category` انتخابی (میتواند خالی بماند مگر بخواهی الزامی کنی — طبق خواسته کاربر «هر کالا باید دسته داشته باشد» → **الزامیاش کن** و در `submit` مثل `name` اعتبارسنجی کن: پیام «دستهبندی کالا الزامی است»).
+ - آرایهی `fields` را طوری بازسازی کن که `unit` و `category` از حلقهی input جدا و بهصورت Select رندر شوند (SRP: input متنی جدا از Select).
+ - در حالت ویرایش، مقدار فعلی pre-select شود.
+
+```tsx
+// نمونه
+ ({ value: u.value, label: u.label }))}
+ onChange={(v) => setForm(f => ({ ...f, unit: v }))}
+/>
+ ({ value: c.value, label: c.label }))}
+ onChange={(v) => setForm(f => ({ ...f, category: v }))}
+/>
+```
+
+> ساختار خروجی endpoint (`value/label` یا لیست مسطح فارسی) باید با تصمیم وظیفه ۱ یکی باشد. اگر مسطح فارسی است، `options={meta.units.map(u => ({ value: u, label: u }))}`.
+
+### ۷. Frontend — صفحه: فیلتر بر اساس `category`
+
+`InventoryPage.tsx`:
+
+```tsx
+// قبل:
+(category === '' || it.consumable === category)
+// بعد:
+(category === '' || it.category === category)
+```
+
+- dropdown فیلتر بالای جدول از `meta.categories` (لیست کامل ثابت) یا از `categories` هوک (دستههای استفادهشده) پر شود — طبق تصمیم وظیفه ۴.
+- اگر ستون «دسته» در جدول (`InventoryItemsTable`) وجود ندارد، افزودن ستون «دستهبندی» را در نظر بگیر (نمایش `label` فارسی).
+
+## نکات مهم
+
+- **قرارداد API / کلاینتهای دیگر:** `InventoryItem::toArray()` مصرفکننده دارد؛ افزودن `category` امن است، اما **حذف/تغییر `consumable`** کلاینت `clinic-pro-tauri` (`src/service/response.js`) و مدل tauri را میشکند. فقط **اضافه کن**، حذف نکن.
+- **منبع واحد لیستها:** فرانت هرگز لیست واحد/دسته را هاردکد نکند؛ همیشه از `inventory-meta`. این تنها راه جلوگیری از drift بین بک و فرانت است (CLAUDE.md: قرارداد API).
+- **BaseController pattern:** پاسخها با `$this->success()`؛ خطاها با `AppException(ErrorCodes::ERR_VALIDATION_001, 'پیام فارسی', 422)` که `ExceptionSubscriber` فرمت میکند. کد ولیدیشن فیلددار را با امضای موجود `error(..., 'field')` هماهنگ نگه دار.
+- **رشتههای UI فارسی**، مقدار ذخیرهشده (value) ترجیحاً انگلیسی پایدار.
+- **تستها (الزامی — موفق/خطا/مرزی):**
+ - Backend (`ApiTestCase`): ساخت کالا با `unit`/`category` معتبر → 201؛ با `unit` نامعتبر → 422 فیلد `unit`؛ با `category` نامعتبر → 422؛ خالی گذاشتن category (اگر nullable) → قبول؛ `inventory-meta` لیستها را برمیگرداند.
+ - Frontend (`InventoryPage.test.tsx` موجود + تست Modal): رندر Selectها، الزامی بودن دسته، فیلتر بر اساس `category`.
+- **debug اول:** پیش از ساخت هر چیز، مطمئن شو endpoint موجودی برای متادیتا نیست (نیست — تأیید شد). قاعده «اول بگرد، بعد توسعه، آخر بساز».
+- **مستندسازی:** `docs/api/inventory.md` را در همان session بهروزرسانی کن: endpoint جدید `inventory-meta`، فیلد جدید `category` در بدنه create/update و در پاسخ، و قرارداد اعتبارسنجی.
+- **بعد از تغییر کد:** `graphify update .` (پس از commit).
diff --git a/.claude/prompt/my-payments-ui-redesign.md b/.claude/prompt/my-payments-ui-redesign.md
new file mode 100644
index 00000000..6e187cd7
--- /dev/null
+++ b/.claude/prompt/my-payments-ui-redesign.md
@@ -0,0 +1,294 @@
+# بازطراحی UI/UX صفحه «لیست پرداختها» (`/admin/my-payments`)
+
+## پروژه
+
+`clinicpro` — پنل ادمین React (`assets/admin/`) + یک اندپوینت خلاصه در بکاند Symfony (`src/Billing/`).
+
+## زمینه
+
+صفحهی `/admin/my-payments` ([MyPaymentsPage.tsx](clinicpro/assets/admin/pages/MyPaymentsPage.tsx)) با inline-styleهای دستی و یک `` خام نوشته شده و از design-system پروژه استفاده نمیکند. در همان پنل، صفحهی `/admin/claims` ([ClaimsPage.tsx](clinicpro/assets/admin/pages/ClaimsPage.tsx)) الگوی درست و پختهی یک صفحهی لیست است: `PageHeader` با breadcrumb، ردیف `StatCard`، کارت فیلترها با `field-label`، میانبرهای بازهی زمانی، `DataTable` (سورت + جستجو + skeleton + empty state) و `Pagination`. هدف: همسطحکردن `my-payments` با همان الگو.
+
+## مشکل
+
+وضعیت فعلی صفحه:
+
+1. **بدون design-system** — جدول خام با `th`/`td` inline style بهجای `DataTable`. یعنی: بدون skeleton loading، بدون سورت، بدون empty state استاندارد.
+2. **بدون هیچ آمار خلاصهای** — کاربر هیچ دید کلی از مجموع مبلغ/تعداد/تسویهنشده ندارد (بر خلاف claims که ۴ `StatCard` دارد).
+3. **ستون `status` نمایش داده نمیشود** — با اینکه `PaymentRow.status` (`paid | unsettled`) از API میآید و فیلترش هم در UI هست، در جدول هیچ ستون وضعیتی وجود ندارد. کاربر فیلتر میکند ولی نتیجهاش را نمیبیند.
+4. **فیلترها بدون label و بدون کارت** — یک ردیف شناور بالای صفحه، بدون `field-label`، بدون دکمهی «پاککردن فیلترها»، بدون میانبر «یک ماه اخیر / یک سال اخیر».
+5. **فیلترها در state محلیاند، نه در query string** — رفرش صفحه یا اشتراک لینک، فیلترها و شمارهی صفحه را از بین میبرد. `ClaimsPage` این را با `useSearchParams` حل کرده.
+6. **جستجو فقط کد ملی است** — با `input` دستساز، در حالی که `DataTable` خودش `searchValue`/`onSearchChange` دارد.
+7. **`PersianDateInput` بهجای `PersianDatePicker`** — ناهماهنگ با claims و بدون `height={38}` همتراز با `SearchableSelect`.
+8. **action هدر بیربط است** — دکمهی «اضافه کردن بیمار» در صفحهی پرداختها منطق ندارد.
+
+## فایلهای مرتبط
+
+| فایل | نقش |
+|------|-----|
+| `clinicpro/assets/admin/pages/MyPaymentsPage.tsx` | صفحهای که بازنویسی میشود |
+| `clinicpro/assets/admin/pages/ClaimsPage.tsx` | **الگوی مرجع** — ساختار را از این کپی کن |
+| `clinicpro/assets/admin/hooks/useMyPayments.ts` | `usePayments`، `PaymentRow`، `MY_PAYMENTS_LIMIT` — hook خلاصه اینجا اضافه میشود |
+| `clinicpro/assets/admin/components/ui/DataTable.tsx` | جدول design-system |
+| `clinicpro/assets/admin/components/ui/StatCard.tsx` | کارت آمار (`tone: amber\|violet\|green\|pink`) |
+| `clinicpro/assets/admin/components/ui/StatusBadge.tsx` | بج وضعیت — نیاز به type جدید `invoice` |
+| `clinicpro/assets/admin/components/ui/PersianDatePicker.tsx` | انتخاب تاریخ همراستا با claims |
+| `clinicpro/assets/admin/types/index.ts` | تعریف `InvoiceListStatus` |
+| `clinicpro/src/Billing/Controller/BillingController.php` | اندپوینت `listPayments` (L163) — اندپوینت خلاصه کنارش |
+| `clinicpro/src/Billing/Service/InvoiceService.php` | `tenantInvoiceList` — متد خلاصه کنارش |
+| `clinicpro/docs/api/billing.md` | مستند API (Standing Rule) |
+| `clinicpro/assets/admin/pages/MyPaymentsPage.test.tsx` | تستهای موجود — باید بهروز شوند |
+
+## وضعیت فعلی
+
+`MyPaymentsPage.tsx` (خلاصهی بخشهای مشکلدار):
+
+```tsx
+const th: React.CSSProperties = { textAlign: 'right', padding: '12px 16px', fontWeight: 600 };
+const td: React.CSSProperties = { padding: '12px 16px' };
+
+const [page, setPage] = useState(1);
+const [nationalCode, setNationalCode] = useState('');
+const [status, setStatus] = useState('');
+const [from, setFrom] = useState('');
+const [to, setTo] = useState('');
+// ...
+
+
+
+
+ ردیف
+ نام بیمار
+ کد ملی
+ تاریخ
+ مبلغ پرداختشده
+ عملیات
+
+
+ ...
+```
+
+نوع ردیف (`useMyPayments.ts`) — دقت کن `status` موجود است ولی رندر نمیشود:
+
+```ts
+export type PaymentRowStatus = 'paid' | 'unsettled';
+
+export interface PaymentRow {
+ invoice_uuid: string;
+ patient_uuid: string;
+ patient_name: string | null;
+ national_code: string | null;
+ issued_at: number;
+ amount_rials: number;
+ status: PaymentRowStatus;
+}
+```
+
+---
+
+## وظایف
+
+### ۱. اندپوینت خلاصهی پرداختها (بکاند)
+
+طبق قاعدهی «اول بگرد، بعد توسعه بده، در آخر بساز»: هیچ اندپوینتی خلاصهی مالی tenant را برنمیگرداند (`/api/v1/billing/reports/insurance-debt` فقط بدهی بیمه است، نه پرداختهای بیمار). پس یک اندپوینت جدید لازم است — اما **همان فیلترهای `listPayments` را میپذیرد** تا کارتها با جدول همخوان بمانند.
+
+در `InvoiceService`:
+
+```php
+/**
+ * خلاصهی مالی صورتحسابهای tenant با همان فیلترهای tenantInvoiceList.
+ * @return array{total_rials:int, paid_rials:int, unsettled_rials:int, invoices_count:int}
+ */
+public function tenantInvoiceSummary(string $entityType, int $entityId, array $filters): array
+```
+
+پیادهسازی با یک DQL aggregate (`SUM`/`COUNT` + `CASE WHEN status = 'paid'`), **نه** با بارگذاری همهی ردیفها در PHP. شرطهای فیلتر (`national_code`, `status`, `from`, `to`) را دقیقاً از `tenantInvoiceList` بازاستفاده کن — منطق `where` را در یک متد private مشترک بگذار تا دو نسخه از هم واگرا نشوند (SOLID/DRY).
+
+در `BillingController` کنار `listPayments`:
+
+```php
+#[Route('/api/v1/my/billing/payments/summary', methods: ['GET'])]
+public function paymentsSummary(Request $request, #[CurrentUser] User $user): JsonResponse
+{
+ [$entityType, $entityId] = $this->resolveEntity($user);
+ if ($entityId === null) {
+ return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
+ }
+ // همان استخراج $filters که در listPayments هست
+ return $this->success($this->invoiceService->tenantInvoiceSummary($entityType, $entityId, $filters));
+}
+```
+
+**دقت:** payload را مستقیم پاس بده (`$this->success($summary)`) نه `['data' => $summary]` — در غیر اینصورت فرانت باید `data?.data?.data` بخواند (pitfall نامبرده در CLAUDE.md).
+
+**نکتهی مسیریابی:** روت `/payments/summary` نباید با روتهای پارامتری موجود تداخل کند؛ بعد از افزودن، با `ddev exec php bin/console debug:router | grep billing` تأیید کن.
+
+### ۲. hook خلاصه در فرانت
+
+در `assets/admin/hooks/useMyPayments.ts`:
+
+```ts
+export interface PaymentsSummary {
+ total_rials: number;
+ paid_rials: number;
+ unsettled_rials: number;
+ invoices_count: number;
+}
+
+/** خلاصهی مالی با همان فیلترهای لیست — کارتهای آمار همیشه با جدول همخوان میمانند. */
+export function usePaymentsSummary(filters: Omit) {
+ const qs = new URLSearchParams();
+ if (filters.national_code) qs.set('national_code', filters.national_code);
+ if (filters.status) qs.set('status', filters.status);
+ if (filters.from) qs.set('from', String(filters.from));
+ if (filters.to) qs.set('to', String(filters.to));
+
+ return useQuery>({
+ queryKey: ['payments-summary', filters],
+ queryFn: () => api.get(`/api/v1/my/billing/payments/summary?${qs.toString()}`),
+ });
+}
+```
+
+خواندن در صفحه: `summaryQuery.data?.data`.
+
+### ۳. بج وضعیت صورتحساب
+
+`StatusBadge` هیچ mapی برای `paid | unsettled` ندارد (`paymentMap` مربوط به درگاه است: `pending/success/failed/...`). یک type جدید اضافه کن — map موجود را دستکاری نکن:
+
+در `types/index.ts`:
+
+```ts
+export type InvoiceListStatus = 'paid' | 'unsettled';
+```
+
+در `StatusBadge.tsx`:
+
+```ts
+const invoiceMap: Record = {
+ paid: { color: 'green', label: 'پرداخت شده' },
+ unsettled: { color: 'amber', label: 'تسویه نشده' },
+};
+```
+
+و `'invoice'` را به union پراپ `type` اضافه کن و در بدنه هندل کن.
+
+### ۴. بازنویسی `MyPaymentsPage.tsx` بر اساس الگوی `ClaimsPage`
+
+ساختار نهایی دقیقاً به این ترتیب:
+
+```tsx
+<>
+
+
+ {/* ۴ کارت آمار */}
+
+
+
+
+
+
+
+
+ {/* ردیف فیلترها: وضعیت + از تاریخ + تا تاریخ + میانبرها + پاککردن */}
+ {/* DataTable */}
+ {/* Pagination — فقط وقتی total > MY_PAYMENTS_LIMIT */}
+
+>
+```
+
+**۴-۱ — انتقال state به query string.** `useState`های `page/nationalCode/status/from/to` را با `useSearchParams` جایگزین کن، دقیقاً با همان `setParam` صفحهی claims (که با هر تغییر فیلتر، `page` را حذف میکند):
+
+```tsx
+const [params, setParams] = useSearchParams();
+const search = params.get('search') ?? ''; // کد ملی / نام
+const status = params.get('status') ?? '';
+const from = params.get('from') ?? '';
+const to = params.get('to') ?? '';
+const page = Math.max(1, Number(params.get('page') ?? 1));
+
+const setParam = (patch: Record) => {
+ const next = new URLSearchParams(params);
+ Object.entries(patch).forEach(([k, v]) => (v ? next.set(k, v) : next.delete(k)));
+ if (!('page' in patch)) next.delete('page');
+ setParams(next, { replace: true });
+};
+```
+
+**۴-۲ — جستجو داخل `DataTable`.** `input` دستساز و آیکون ذرهبین را حذف کن؛ بهجایش:
+
+```tsx
+searchValue={search}
+onSearchChange={(v) => setParam({ search: v.replace(/\D/g, '') })}
+searchPlaceholder="کد ملی بیمار"
+```
+
+مقدار بهعنوان `national_code` به `usePayments` میرود (API فقط `national_code` را میشناسد؛ جستجوی نام سمت سرور وجود ندارد — placeholder را همینطور صادقانه بگذار و ادعای جستجوی نام نکن).
+
+**۴-۳ — فیلترها با label، مثل claims.** هر کنترل داخل یک `` با `
`:
+
+- «وضعیت» → `SearchableSelect` با `[{value:'',label:'همه وضعیتها'},{value:'paid',label:'پرداخت شده'},{value:'unsettled',label:'تسویه نشده'}]`، `height={38}`
+- «از تاریخ» / «تا تاریخ» → `PersianDatePicker` با `height={38}` (جایگزین `PersianDateInput`)
+- میانبرها: `` برای «یک ماه اخیر» و «یک سال اخیر» — همان `isoNDaysAgo(30)/isoNDaysAgo(365)` + `todayIso()` صفحهی claims
+- «پاککردن فیلترها» با ` ` — فقط وقتی `hasFilters` true است
+
+**۴-۴ — ستونها با `Column`.** ستون «ردیف» را حذف کن (شمارهی مصنوعی در جدول صفحهبندیشده ارزشی ندارد و فضای مفید میگیرد) و ستون وضعیت را اضافه کن:
+
+```tsx
+const columns: Column[] = [
+ { key: 'patient_name', header: 'بیمار', render: (r) => (
+
+
+
{r.patient_name ?? '—'}
+
+ ) },
+ { key: 'national_code', header: 'کد ملی', render: (r) => (
+ {r.national_code ?? '—'}
+ ) },
+ { key: 'issued_at', header: 'تاریخ', render: (r) => (
+ {formatDate(r.issued_at)} - {formatTime(r.issued_at)}
+ ) },
+ { key: 'amount_rials', header: 'مبلغ', render: (r) => (
+ {formatRial(r.amount_rials)}
+ ) },
+ { key: 'status', header: 'وضعیت', render: (r) => },
+];
+```
+
+`sortable` را روی هیچ ستونی نگذار مگر اینکه اندپوینت `listPayments` واقعاً `sort`/`dir` بپذیرد — سورت غیرفعال بهتر از سورتِ بیاثر است. اگر تصمیم گرفتی سورت اضافه کنی، باید هم در `tenantInvoiceList` و هم در کنترلر پشتیبانی شود و در `docs/api/billing.md` مستند شود.
+
+**۴-۵ — عملیات و حالت خالی.**
+
+```tsx
+actions={(row) => (
+ navigate(`/admin/my-payments/${row.patient_uuid}`)}>
+ جزئیات
+
+)}
+emptyMessage="پرداختی ثبت نشده است."
+loading={listQuery.isLoading}
+```
+
+**۴-۶ — حذف چیزهای زائد.** `th`/`td`ی inline، بلوک `isLoading` دستی، بلوک empty state دستی، `import` های `MagnifyingGlassIcon`/`EyeIcon`/`BanknotesIcon`/`UserPlusIcon`/`PersianDateInput`، ثابت `EMPTY` و دکمهی «اضافه کردن بیمار» از `PageHeader` حذف شوند. `Avatar`، `formatTime` و `dayBound` بمانند.
+
+### ۵. مستندات و تست
+
+- `clinicpro/docs/api/billing.md`: اندپوینت `GET /api/v1/my/billing/payments/summary` را با پارامترهای query و نمونهی پاسخ اضافه کن (Standing Rule).
+- `MyPaymentsPage.test.tsx` را به ساختار جدید بهروز کن: باید render کارتهای آمار، نمایش بج وضعیت، و بهروزرسانی query string با تغییر فیلتر را پوشش دهد. صفحه حالا `useSearchParams` دارد → تست باید داخل `MemoryRouter` رندر شود.
+- تست بکاند برای `tenantInvoiceSummary`: حالت موفق، حالت با فیلتر، و حالت خالی (باید صفر برگرداند نه `null`).
+- اجرا: `ddev exec npx tsc --noEmit --project tsconfig.json` · `ddev exec yarn test` · `ddev exec php bin/phpunit`
+
+## نکات مهم
+
+- **الگو را از `ClaimsPage` کپی کن، طراحی جدید نساز.** همان توکنها (`var(--gap)`, `var(--r-lg)`), همان کلاسها (`card`, `btn ghost sm`, `btn primary sm`, `field-label`), همان چیدمان.
+- تاریخها Unix ثانیهاند. `dayBound(from,false)` / `dayBound(to,true)` را برای مرز روز نگه دار — API مقدار خام روز را نمیفهمد.
+- **همیشه `SearchableSelect`، هرگز `` بومی** (قاعدهی پروژه).
+- کارتهای آمار باید فیلترهای فعال را منعکس کنند: `usePaymentsSummary` همان `national_code/status/from/to` را میگیرد. اگر خلاصه بدون فیلتر بماند، عدد کارت با جمع جدول نمیخواند و کاربر گمراه میشود.
+- **Edge case:** وقتی `summaryQuery` هنوز loading است یا خطا داده، کارتها باید `formatRial(0)` نشان دهند نه `NaN`/`undefined` — با `?? 0` پیش از فرمت.
+- **Edge case:** فیلتر `status=paid` باعث میشود `unsettled_rials` صفر شود؛ این درست است، نه باگ.
+- **Edge case:** `patient_name` و `national_code` nullable هستند → `'—'`.
+- RTL و اعداد فارسی: `formatRial`/`formatNumber`/`formatDate` از `lib/utils` — عدد خام رندر نکن. کد ملی و تاریخ با `dir="ltr"`.
+- `Pagination` فقط وقتی `total > MY_PAYMENTS_LIMIT` رندر شود (مثل claims).
diff --git a/.claude/prompt/normalize-persian-digits.md b/.claude/prompt/normalize-persian-digits.md
new file mode 100644
index 00000000..ba763601
--- /dev/null
+++ b/.claude/prompt/normalize-persian-digits.md
@@ -0,0 +1,304 @@
+# نرمالسازی ارقام فارسی/عربی در همه فیلدهای عددی
+
+## پروژه
+
+`clinicpro` (پنل ادمین React + یک لایه دفاعی در backend). لایه backend همه کلاینتها را پوشش میدهد — `nobat724_front` و `clinic-pro-tauri` هم از همان `/api/v1/...` استفاده میکنند، پس نیازی به پرامپت جدا برای آنها نیست.
+
+## زمینه
+
+کاربر فارسیزبان با کیبورد فارسی، عدد را با ارقام فارسی (`۰-۹`) یا عربی (`٠-٩`) تایپ میکند. ابزار نرمالسازی از قبل در پروژه هست (`toEnglishDigits` در `assets/admin/lib/utils.ts`) و چند کامپوننت (`MobileInput`، `DigitInput`، `PriceInput`، `Input` با prop `numeric`) از آن استفاده میکنند — ولی **اکثر فیلدهای عددی پنل از هیچکدام استفاده نمیکنند**.
+
+دو نوع خرابی متفاوت رخ میدهد و باید هر دو در ذهن باشد:
+
+- **`type="number"`** → مرورگر مقدار را نامعتبر میداند و `e.target.value` رشتهٔ **خالی** برمیگرداند. یعنی کاربر عدد را میبیند ولی فیلد خالی/صفر ذخیره میشود — **باگ از دست رفتن داده**، نه مقدار غلط.
+- **`type="text"` / `type="tel"`** → ارقام فارسی دستنخورده تا دیتابیس میروند. مثلاً شماره موبایل `۰۹۱۲...` ذخیره میشود و بعداً هیچوقت با `09...` مچ نمیشود.
+
+نقطهٔ شروع گزارش کاربر: فرم «افزودن منشی» — هم موبایل و هم کد ملی از نوع دوماند و مستقیم به API میروند.
+
+## مشکل / هدف
+
+۱. فرم منشی (موبایل + کد ملی) ارقام فارسی را بدون تبدیل ارسال میکند.
+۲. حدود ۵۰ فیلد عددی دیگر در پنل همین مشکل را دارند.
+۳. هیچ محافظ سمت backend وجود ندارد (فقط یک endpoint نرمالسازی میکند).
+۴. چند پیادهسازی تکراری از همان تابع تبدیل در فایلهای مختلف پخش شده است.
+
+## فایلهای مرتبط
+
+| فایل | نقش |
+|------|-----|
+| `assets/admin/lib/utils.ts:105-135` | `toEnglishDigits`، `sanitizeMobileInput`، `iranMobileSchema` |
+| `assets/admin/components/ui/Input.tsx:19-25` | prop `numeric` — پیادهسازی درست، **صفر مصرفکننده** |
+| `assets/admin/components/ui/MobileInput.tsx` | فیلد موبایل |
+| `assets/admin/components/ui/DigitInput.tsx` | فیلد فقطرقم با `maxDigits` |
+| `assets/admin/components/ui/PriceInput.tsx` | فیلد مبلغ با جداکننده |
+| `assets/admin/pages/MySecretariesPage.tsx:226-266, 437, 441` | فرم منشی + `DefaultTextField` خام |
+| `assets/admin/pages/RepresentationProfilePage.tsx:21-26` | `toLatinDigits` تکراری — باید حذف شود |
+| `assets/admin/components/inventory/AddItemModal.tsx:29` | wrapper محلی `digits()` |
+| `src/Shared/Util/PersianText.php:31-34` | نرمالساز backend — فقط در یک controller استفاده شده |
+| `src/Doctor/Controller/DoctorClaimController.php:95-99` | تنها مصرفکنندهٔ فعلی `PersianText` روی ارقام |
+
+## وضعیت فعلی
+
+### ابزار موجود — `assets/admin/lib/utils.ts:105-116`
+
+```ts
+// تبدیل ارقام فارسی/عربی به انگلیسی + حذف هر کاراکتر غیرعددی.
+export function toEnglishDigits(input: string): string {
+ if (!input) return '';
+ return input
+ .replace(/[۰-۹]/g, (d) => String(d.charCodeAt(0) - 0x06f0))
+ .replace(/[٠-٩]/g, (d) => String(d.charCodeAt(0) - 0x0660));
+}
+
+export function sanitizeMobileInput(input: string): string {
+ return toEnglishDigits(input).replace(/\D/g, '').slice(0, 11);
+}
+```
+
+> کامنت بالای `toEnglishDigits` غلط است — این تابع کاراکتر غیرعددی را حذف **نمیکند**، فقط ارقام را ترجمه میکند. کامنت را اصلاح کن.
+
+### الگوی درستِ موجود — `assets/admin/components/ui/Input.tsx:19-25`
+
+```tsx
+const handleChange = numeric
+ ? (e: React.ChangeEvent) => {
+ const latin = toEnglishDigits(e.target.value);
+ if (latin !== e.target.value) e.target.value = latin;
+ onChange?.(e);
+ }
+ : onChange;
+```
+
+### فرم منشی — `assets/admin/pages/MySecretariesPage.tsx:437, 441`
+
+```tsx
+ setField("telephone", v)} disabled={disabled || mode === "edit"} />
+...
+ setField("national_code", v)} disabled={disabled} />
+```
+
+`DefaultTextField` (`:226-266`) یک ` ` خام بدون `type`/`inputMode`/`dir` است و مقدار را عیناً پاس میدهد. مقدار در `:773-775` و `:803` بدون هیچ پردازشی ارسال میشود. این فرم اصلاً Zod schema ندارد.
+
+### الگوی درست schema — `assets/admin/lib/utils.ts:125-135`
+
+```ts
+export const iranMobileSchema = z
+ .string()
+ .transform((v) => toEnglishDigits(v).replace(/\D/g, ''))
+ .refine((v) => IRAN_MOBILE_RE.test(v), 'شماره موبایل باید ۱۱ رقم و با 09 شروع شود');
+```
+
+### schemaهایی که ارقام فارسی را رد میکنند (چون `\d` فقط ASCII است)
+
+```ts
+// components/PatientRecordInfoForm.tsx:20
+national_code: z.string().trim().regex(/^\d{10}$/, 'کد ملی باید ۱۰ رقم باشد').or(z.literal('')),
+
+// pages/PatientRecordFormPage.tsx:21-22
+national_code: z.string().regex(/^\d{10}$/, 'کد ملی باید ۱۰ رقم باشد'),
+mobile: z.string().regex(/^09\d{9}$/, 'شماره تماس نامعتبر است'),
+```
+
+### backend — `src/Shared/Util/PersianText.php:31-34`
+
+```php
+$text = strtr($text, array_combine(
+ ['۰','۱','۲','۳','۴','۵','۶','۷','۸','۹','٠','١','٢','٣','٤','٥','٦','٧','٨','٩'],
+ ['0','1','2','3','4','5','6','7','8','9','0','1','2','3','4','5','6','7','8','9'],
+));
+```
+
+فقط در `DoctorClaimController` روی ارقام استفاده شده. بقیهٔ endpointها (منشی، بیمار، پرسنل، کلینیک، سرویس، اشتراک، حساب بانکی) ارقام فارسی را بدون تغییر در دیتابیس مینویسند.
+
+## وظایف
+
+### ۱. تکمیل ابزارهای مشترک در `lib/utils.ts`
+
+- کامنت غلط `toEnglishDigits` را اصلاح کن.
+- اینها را اضافه کن:
+
+```ts
+/** فقط ارقام لاتین، با محدودیت طول اختیاری. */
+export function digitsOnly(input: string, maxLen?: number): string {
+ const d = toEnglishDigits(input).replace(/\D/g, '');
+ return maxLen ? d.slice(0, maxLen) : d;
+}
+
+/** برای z.coerce.number() که روی ارقام فارسی NaN میدهد. */
+export const persianSafeNumber = (schema: z.ZodNumber) =>
+ z.preprocess((v) => (typeof v === 'string' ? toEnglishDigits(v) : v), schema);
+
+export const IRAN_NATIONAL_CODE_RE = /^\d{10}$/;
+
+export const iranNationalCodeSchema = z
+ .string()
+ .transform((v) => digitsOnly(v, 10))
+ .refine((v) => IRAN_NATIONAL_CODE_RE.test(v), 'کد ملی باید ۱۰ رقم باشد');
+
+export const iranNationalCodeOptionalSchema = z
+ .string()
+ .transform((v) => digitsOnly(v, 10))
+ .refine((v) => v === '' || IRAN_NATIONAL_CODE_RE.test(v), 'کد ملی نامعتبر است');
+```
+
+تستها را در `assets/admin/lib/utils.test.ts` اضافه کن (کنار تستهای موجود `toEnglishDigits` در خطوط ۱۱۸-۱۳۶): ورودی فارسی، عربی، مخلوط، خالی، و رشتهٔ دارای کاراکتر غیرعددی.
+
+### ۲. حذف پیادهسازیهای تکراری
+
+- `assets/admin/pages/RepresentationProfilePage.tsx:21-26` → تابع محلی `toLatinDigits` را حذف و با `toEnglishDigits` جایگزین کن (مصرف در `:158` و `:211`).
+- `assets/admin/components/inventory/AddItemModal.tsx:29` → `digits()` محلی را با `digitsOnly` مشترک جایگزین کن.
+
+### ۳. فرم منشی — نقطهٔ شروع گزارش کاربر
+
+در `assets/admin/pages/MySecretariesPage.tsx`:
+
+- موبایل (`:437`) → `` (یا `DigitInput` با `maxDigits={11}`).
+- کد ملی (`:441`) → ``.
+- **یا** سادهتر و کمریسکتر: به `DefaultTextField` یک prop `numeric?: boolean` و `maxDigits?: number` اضافه کن که داخلش `digitsOnly` صدا بزند، سپس روی این دو فیلد `numeric` بگذار. اگر این راه را رفتی، `type="tel"`، `inputMode="numeric"` و `dir="ltr"` را هم ست کن.
+- در `:773-775` و `:803` هم قبل از ارسال `digitsOnly` بزن (دفاع لایهای — کاربر میتواند paste کند).
+- این فرم schema ندارد؛ حداقل `iranMobileSchema` و `iranNationalCodeOptionalSchema` را روی همین دو فیلد اعمال کن تا خطای فارسی معنادار نشان داده شود.
+
+### ۴. مهاجرت همهٔ فیلدهای عددی
+
+فهرست کامل زیر لیست کار است. برای هر مورد:
+
+- فیلد پول/مبلغ → ``
+- فیلد شمارهٔ ملی/موبایل/کارت/شبا/کد پستی → ``
+- بقیه (درصد، مدت، تعداد، وزن، سطح) → ` ` یا `type="text" inputMode="numeric"` + `digitsOnly` در `onChange`
+- **هیچ فیلد `type="number"` جدیدی نساز** و موجودها را به `type="text" inputMode="numeric"` تبدیل کن، وگرنه مشکل «مقدار خالی» باقی میماند.
+- اگر فیلد با React Hook Form `register` شده، `setValueAs` یا `onChange` سفارشی لازم است:
+
+```tsx
+ digitsOnly(String(v ?? '')) })}
+/>
+```
+
+#### منشی
+| فایل:خط | فیلد |
+|---|---|
+| `MySecretariesPage.tsx:437` | `telephone` |
+| `MySecretariesPage.tsx:441` | `national_code` |
+
+#### پرسنل
+| فایل:خط | فیلد |
+|---|---|
+| `pages/StaffPage.tsx:265` | `phone` |
+| `pages/StaffPage.tsx:269` | `national_code` |
+
+#### بیماران
+| فایل:خط | فیلد |
+|---|---|
+| `pages/PatientRecordFormPage.tsx:129` | `national_code` |
+| `pages/PatientRecordFormPage.tsx:132` | `mobile` |
+| `components/PatientRecordInfoForm.tsx:117` | `national_code` — فقط prop `numeric` را به ` ` اضافه کن |
+| `components/PatientRecordInfoForm.tsx:184` | `postal_code` — همان |
+| `pages/MyPatientsPage.tsx:1441, 1452, 1464` | `visit_price_rials`، دو فیلد درصد تخفیف |
+
+#### کلینیک/پزشک (تلفن ثابت — موبایلها از قبل درستاند)
+| فایل:خط | فیلد |
+|---|---|
+| `pages/ClinicsPage.tsx:278` | `telephone` |
+| `pages/ClinicDetailPage.tsx:368` | `telephone` |
+| `pages/ClinicFormPage.tsx:65` | `telephone` |
+| `pages/DoctorDetailPage.tsx:652` | `telephone` |
+
+#### نوبت
+| فایل:خط | فیلد |
+|---|---|
+| `components/NewAppointmentDrawer.tsx:318` | `duration` |
+| `pages/AppointmentCreatePage.tsx:437` | `duration` |
+| `components/AppointmentFiltersModal.tsx:90` | `nationalCode` (فیلتر جستجو — بدون تبدیل هیچوقت مچ نمیشود) |
+
+#### زمانبندی
+| فایل:خط | فیلد |
+|---|---|
+| `components/schedule/ScheduleSection.tsx:458` | `rest_interval` |
+| `components/schedule/ScheduleSection.tsx:465` | `time_to_rest` |
+| `components/schedule/ScheduleSection.tsx:705` | `buffer_minutes` |
+| `components/schedule/ScheduleSection.tsx:751` | `booking_window_value` |
+
+#### مبلغ / درصد
+| فایل:خط | فیلد |
+|---|---|
+| `components/FreeVisitPrice.tsx:65` | قیمت ویزیت |
+| `components/session/CreateStep.tsx:414, 441, 445` | قیمت ویزیت، دو درصد بیمه |
+| `components/InsuranceModal.tsx:164, 168, 172` | `coverage`، `franchise`، `ceiling` |
+| `components/ServiceInsuranceModal.tsx:130` | درصد پوشش |
+| `components/DiscountTab.tsx:242, 247, 297` | `value` (درصد)، `priority`، `min_visit_count` |
+| `pages/ClinicServicesPage.tsx:529` | `duration_minutes` — placeholder فعلی `"مثلاً: ۵۰"` با ارقام فارسی است و کاربر را به اشتباه میاندازد؛ اصلاحش کن |
+| `pages/SmsWalletPage.tsx:531` | `amount_rials` |
+| `pages/RepresentationSettlementPage.tsx:130` | `amount` |
+
+#### تنظیمات / ادمین
+| فایل:خط | فیلد |
+|---|---|
+| `pages/SettingsPage.tsx:319, 325, 356, 373, 399, 405, 495` | ساعت لغو، ساعت یادآوری، درصد کمیسیون، درصد مالیات، سه فیلد مبلغ |
+| `pages/LogsPage.tsx:257` | روزهای نگهداری لاگ |
+| `pages/CategoriesPage.tsx:352, 574, 702, 827` | `weight` (چهار جا) |
+| `pages/AdminSubscriptionPage.tsx:274, 278, 323, 327, 333` | `level`، `max_secretaries`، `duration_months`، `price_rials`، `sort_order` |
+
+#### نمایندگان
+| فایل:خط | فیلد |
+|---|---|
+| `pages/RepresentationsPage.tsx:251` | `commission_percent` |
+| `pages/RepresentationDetailPage.tsx:490` | `commission_percent` |
+
+#### بانکی — هیچکدام تبدیل ندارند
+| فایل:خط | فیلد |
+|---|---|
+| `components/paymentMethods/BankAccountFormModal.tsx:91` | `cardNumber` → `DigitInput maxDigits={16}` |
+| `components/paymentMethods/BankAccountFormModal.tsx:~95` | `accountNumber` |
+| `components/paymentMethods/BankAccountFormModal.tsx:99` | `shabaNumber` → شبا حرف `IR` دارد؛ `digitsOnly` خام آن را خراب میکند. فقط `toEnglishDigits` بزن و حروف را نگهدار |
+
+#### فقط یکدستسازی (از قبل درست کار میکنند)
+`pages/LoginPage.tsx:242, 280, 330` و `components/ui/NotificationMobileCard.tsx:128` از `sanitizeMobileInput` استفاده میکنند — به `` مهاجرت بده، اولویت پایین.
+
+### ۵. اصلاح schemaهای Zod
+
+- `components/PatientRecordInfoForm.tsx:20` و `pages/PatientRecordFormPage.tsx:21-22` → با `iranNationalCodeSchema` / `iranMobileSchema` جایگزین کن.
+- همهٔ `z.coerce.number()`ها را با `persianSafeNumber(z.number()...)` بپوشان: `AdminSubscriptionPage.tsx:35, 36, 45, 46, 48`؛ `ClinicServicesPage.tsx:28, 31, 32`؛ `RepresentationsPage.tsx:28`؛ `SmsWalletPage.tsx:25`؛ `MyPatientsPage.tsx:70-74`.
+
+### ۶. لایه دفاعی backend
+
+یک نرمالسازی سطح-request بساز تا هیچ کلاینتی (پنل ادمین، `nobat724_front`، `clinic-pro-tauri`) نتواند ارقام فارسی وارد دیتابیس کند.
+
+پیشنهاد: `src/Shared/EventSubscriber/NumericFieldNormalizerSubscriber.php` روی `kernel.request` که برای درخواستهای `/api/v1/**` با بدنهٔ JSON، مقدار کلیدهای شناختهشده را با `PersianText::normalize` تبدیل کند:
+
+```php
+private const NUMERIC_KEYS = [
+ 'mobile', 'mobile_number', 'telephone', 'phone', 'notification_mobile',
+ 'national_code', 'postal_code', 'card_number', 'account_number', 'sheba', 'iban',
+ 'price_rials', 'amount_rials', 'amount', 'free_visit_price_rials',
+ 'duration_minutes', 'commission_percent', 'coverage', 'franchise', 'ceiling',
+];
+```
+
+نکات:
+- بازگشتی روی آرایههای تودرتو اعمال شود (مثلاً `insurances[].patient_share_rials`).
+- مقدار فقط ترجمهٔ رقم شود؛ **حذف کاراکتر غیرعددی نکن** (شبا حرف دارد، تلفن ثابت خط تیره).
+- فقط روی `string` اعمال شود، `int`/`bool`/`null` دستنخورده بماند.
+- اگر تشخیص دادی subscriber بیش از حد گسترده است و ریسک دارد، جایگزین کمریسکتر: `PersianText::normalize` را در همان چند controller حساس (منشی، بیمار، پرسنل، حساب بانکی) دستی صدا بزن و در گزارش بگو کدام مسیر را رفتی و چرا.
+
+تست backend در `tests/Shared/` بنویس: POST با موبایل فارسی → مقدار ذخیرهشده لاتین است.
+
+### ۷. تست و مستندات
+
+- `ddev exec yarn test` برای تستهای `lib/utils.test.ts`
+- `ddev exec npx tsc --noEmit` و `ddev exec yarn dev`
+- `ddev exec php bin/phpunit tests/Shared`
+- اگر subscriber ساختی، رفتار جدید را در `docs/api/README.md` (یا فایل مناسب `docs/api/`) بهعنوان یک قاعدهٔ سراسری مستند کن: «ارقام فارسی/عربی در فیلدهای عددی سمت سرور نرمال میشوند».
+
+## نکات مهم
+
+- **`type="number"` دشمن این کار است.** با ارقام فارسی مقدار خالی برمیگرداند و هیچ `onChange` هندلری نجاتش نمیدهد. تبدیل به `type="text" inputMode="numeric"` بخش اجباری هر مورد است، نه اختیاری.
+- شبا (`IR` + ۲۴ رقم) و تلفن ثابت (`021-1234...`) کاراکتر غیرعددی معتبر دارند — روی اینها فقط `toEnglishDigits` بزن نه `digitsOnly`.
+- `PriceInput` از قبل خروجی `number` میدهد؛ جایگزینی مستقیم `type="number"` با آن ممکن است تایپ فرم را عوض کند — امضای `onChange` را چک کن.
+- ` ` از قبل ساخته شده و تست نشده چون هیچ مصرفکنندهای ندارد؛ بعد از اولین استفاده حتماً دستی تست کن.
+- فیلدهایی که با RHF `register` شدهاند با دستکاری مستقیم `e.target.value` درست کار نمیکنند مگر `setValueAs` یا `Controller` استفاده شود.
+- RTL: فیلدهای عددی باید `dir="ltr"` داشته باشند تا عدد وارونه نمایش داده نشود.
+- از کلاسهای CSS موجود استفاده کن (`input`، `field`، `cp-input`)؛ کتابخانه جدید اضافه نکن.
+- این تغییر بزرگ و پرتکرار است — **قابلیتبهقابلیت پیش برو** و بعد از هر گروه `tsc` و build بگیر، نه یکجا.
diff --git a/.claude/prompt/require-visit-price-setting.md b/.claude/prompt/require-visit-price-setting.md
new file mode 100644
index 00000000..d734b6ed
--- /dev/null
+++ b/.claude/prompt/require-visit-price-setting.md
@@ -0,0 +1,204 @@
+# گزینه «الزامی کردن هزینه ویزیت» در تنظیمات نوبتدهی
+
+## پروژه
+
+`clinicpro` (backend + پنل ادمین React)
+
+## زمینه
+
+در صفحه `/admin/appointment-settings` کامپوننت `FreeVisitPrice` مبلغ «قیمت ویزیت آزاد» را از `GET /api/v1/insurance-pricing` میخواند و با `PUT` همان endpoint ذخیره میکند (ذخیره در `EntityInsurancePricing` با `insurance_id = NULL`). این مبلغ در گام «ایجاد سرویس» ثبت مراجعه (`CreateStep.tsx`) بهعنوان مقدار پیشفرض «قیمت ویزیت» استفاده میشود، اما هیچکدام از فرمها آن را الزامی نمیکنند و صفحه ثبت نوبت (`AppointmentCreatePage.tsx`) اصلاً فیلد هزینه ویزیت ندارد. کلینیکهایی که میخواهند هیچ مراجعه/نوبتی بدون هزینه ویزیت ثبت نشود، ابزاری برای اجبار آن ندارند.
+
+## مشکل / هدف
+
+یک تنظیم boolean با عنوان **«الزامی کردن هزینه ویزیت»** (بههمراه متن راهنما) به صفحه appointment-settings اضافه شود که وقتی فعال است:
+
+1. «قیمت ویزیت آزاد» الزامی شود (بدون مقدار > 0 ذخیره تنظیمات ممکن نباشد) — هم در UI هم در backend.
+2. در گام «ایجاد سرویس» ثبت مراجعه (`/admin/patients//session/new`)، فیلد «قیمت ویزیت» الزامی شود؛ بدون مقدار > 0 ثبت نشود — UI + backend.
+3. در صفحه ثبت نوبت (`/admin/appointments/new`) فیلد جدید «هزینه ویزیت» اضافه شود (**تصمیم تأییدشده توسط کاربر**): با فلگ فعال الزامی، با فلگ غیرفعال اختیاری؛ مقدار پیشفرض از «قیمت ویزیت آزاد».
+
+وقتی غیرفعال است، همه این فیلدها اختیاری بمانند (رفتار فعلی). وضعیت الزامی/اختیاری باید در UI واضح باشد (ستاره `*` روی label + پیام خطای فارسی زیر فیلد).
+
+> «صدور فاکتور سرویس» در این پنل همان گام ۱ ویزارد ثبت مراجعه است (`CreateStep`) که `POST /api/v1/patient/{uuid}/session` را صدا میزند؛ گامهای پرداخت/جزییات (`PaymentStep`/`DetailsStep`) قیمت ویزیت را فقط از session ساختهشده میخوانند و فیلد ورودی ندارند. پس الزام فاکتور با اعتبارسنجی همین گام + گارد backend پوشش داده میشود.
+
+## فایلهای مرتبط
+
+| فایل | نقش |
+|------|-----|
+| `src/Insurance/Entity/EntityInsurancePricing.php` | محل ذخیره «قیمت ویزیت آزاد» (ردیف `insurance_id = NULL`) — ستون جدید فلگ اینجا اضافه میشود |
+| `src/Insurance/Controller/InsuranceController.php` | `GET`/`PUT /api/v1/insurance-pricing` — expose و اعتبارسنجی فلگ |
+| `src/Patient/Service/PatientService.php` | `createSession()` — اعتبارسنجی الزامی بودن `visit_price_rials` |
+| `src/Appointment/Entity/Appointment.php` | ستون جدید `visit_price_rials` (nullable) |
+| کنترلر ایجاد نوبت (`POST /api/v1/admin/appointment` و `/api/v1/my/appointment`) | پذیرش و اعتبارسنجی `visit_price_rials` — با `ddev exec php bin/console debug:router \| grep appointment` پیدا کن |
+| `assets/admin/components/FreeVisitPrice.tsx` | UI تنظیم قیمت ویزیت آزاد — toggle + helper text + الزامی شدن قیمت |
+| `assets/admin/components/session/CreateStep.tsx` | گام «ایجاد سرویس» — الزامی شدن «قیمت ویزیت» |
+| `assets/admin/pages/AppointmentCreatePage.tsx` | صفحه ثبت نوبت — فیلد جدید «هزینه ویزیت» |
+| `docs/api/insurance.md`، `docs/api/patient.md`، `docs/api/appointment.md` | بهروزرسانی مستندات (Standing Rule) |
+
+## وضعیت فعلی
+
+`EntityInsurancePricing` فقط مبلغ دارد، فلگ ندارد:
+
+```php
+#[ORM\Column(name: 'patient_share_rials', type: 'integer')]
+private int $patientShareRials = 0;
+
+public function isFreeVisit(): bool { return $this->insuranceId === null; }
+```
+
+`InsuranceController::saveInsurancePricing` بدون هیچ اعتبارسنجی upsert میکند:
+
+```php
+if (array_key_exists('free_visit_price_rials', $data)) {
+ $this->upsertPricing($entityType, $entityId, null, (int) $data['free_visit_price_rials']);
+}
+```
+
+`PatientService::createSession` مقدار صفر را میپذیرد:
+
+```php
+$session->setVisitPriceRials((int) ($data['visit_price_rials'] ?? 0));
+```
+
+`CreateStep.tsx` قیمت ویزیت را اختیاری میگیرد (پیشفرض از free visit، ولی صفر هم ثبت میشود):
+
+```tsx
+const [visitPrice, setVisitPrice] = useState('0');
+// ...
+const freeVisit = (pricingData as any)?.data?.free_visit_price_rials ?? 0;
+useEffect(() => {
+ if (freeVisit > 0 && (!visitPrice || visitPrice === '0')) setVisitPrice(String(freeVisit));
+}, [freeVisit]);
+// ...
+قیمت ویزیت (تومان)
+ setVisitPrice(e.target.value)} />
+```
+
+`AppointmentCreatePage.tsx` payload فقط بیعانه دارد، هزینه ویزیت ندارد:
+
+```tsx
+...(depositRequired ? { deposit_required: true, deposit_amount_rials: depositRials } : {}),
+```
+
+و `Appointment` entity ستون قیمت ویزیت ندارد (فقط `deposit_amount_rials`).
+
+## وظایف
+
+### ۱. Backend — فلگ `require_visit_price` روی EntityInsurancePricing
+
+ستون boolean جدید روی همان ردیف free-visit (بدون endpoint جدید — توسعه endpoint موجود، طبق قاعده ۲):
+
+```php
+#[ORM\Column(name: 'require_visit_price', type: 'boolean', options: ['default' => false])]
+private bool $requireVisitPrice = false;
+
+public function isRequireVisitPrice(): bool { return $this->requireVisitPrice; }
+public function setRequireVisitPrice(bool $v): self { $this->requireVisitPrice = $v; $this->updatedAt = time(); return $this; }
+```
+
+- `toArray()` هم `require_visit_price` را اضافه کن.
+- Migration: `ddev exec php bin/console doctrine:migrations:diff` سپس `migrate`.
+- در `EntityInsurancePricingRepository` (یا متد کمکی در سرویس مشترک) یک lookup ساده: فلگ فعال است اگر ردیف free-visit موجود و `requireVisitPrice === true`.
+
+### ۲. Backend — `GET`/`PUT /api/v1/insurance-pricing`
+
+در `getInsurancePricing`: کنار `free_visit_price_rials`، کلید `require_visit_price` (از ردیف free-visit، پیشفرض `false`) برگردان.
+
+در `saveInsurancePricing`:
+
+```php
+$requireVisitPrice = null;
+if (array_key_exists('require_visit_price', $data)) {
+ $requireVisitPrice = (bool) $data['require_visit_price'];
+}
+$price = array_key_exists('free_visit_price_rials', $data) ? (int) $data['free_visit_price_rials'] : /* مقدار فعلی ردیف free-visit یا 0 */;
+
+// اگر فلگ (جدید یا ذخیرهشده قبلی) فعال است، قیمت باید > 0 باشد
+$effectiveFlag = $requireVisitPrice ?? /* فلگ ذخیرهشده فعلی */;
+if ($effectiveFlag && $price <= 0) {
+ return $this->error(ErrorCodes::ERR_VALIDATION_001, 'با فعال بودن «الزامی کردن هزینه ویزیت»، قیمت ویزیت آزاد الزامی است', 422, 'free_visit_price_rials');
+}
+```
+
+سپس upsert موجود + ست کردن فلگ روی ردیف free-visit. (اگر فقط فلگ ارسال شود و ردیف free-visit وجود نداشته باشد، ردیف با قیمت 0 ساخته نشود مگر فلگ false باشد — سناریوی مرزی تست شود.)
+
+### ۳. Backend — الزامی شدن `visit_price_rials` در ثبت session
+
+در `PatientService::createSession` (یا کنترلر آن، هر جا اعتبارسنجیهای مشابه انجام میشود)، قبل از ساخت session:
+
+```php
+$requireVisit = $this->pricingRepo->findOneForInsurance($entityType, $entityId, null)?->isRequireVisitPrice() ?? false;
+if ($requireVisit && (int) ($data['visit_price_rials'] ?? 0) <= 0) {
+ throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'هزینه ویزیت الزامی است', 422);
+}
+```
+
+(الگوی خطا را با بقیه اعتبارسنجیهای همین مسیر هماهنگ کن — اگر کنترلر `$this->error()` برمیگرداند همان الگو.)
+
+### ۴. Backend — فیلد `visit_price_rials` روی Appointment
+
+- ستون nullable روی `Appointment`:
+
+```php
+#[ORM\Column(name: 'visit_price_rials', type: 'integer', nullable: true)]
+private ?int $visitPriceRials = null;
+```
+
+- getter/setter + افزودن به `toArray()` (کنار `deposit_amount_rials`).
+- Migration جدید.
+- در endpointهای ایجاد نوبت (`POST /api/v1/admin/appointment` و `POST /api/v1/my/appointment` — کنترلر مربوطه را با `debug:router` پیدا کن): `visit_price_rials` را از payload بپذیر و ست کن. اعتبارسنجی: فلگ را برای entity پزشکِ نوبت (doctor) resolve کن؛ اگر فعال بود و مقدار ارسالی `<= 0` بود → خطای 422 با پیام فارسی «هزینه ویزیت الزامی است».
+- توجه: `resolveEntity` در `InsuranceController` بر اساس کاربر جاری است؛ برای ایجاد نوبت توسط admin/منشی، فلگ باید بر اساس **پزشک نوبت** (و در نبود قیمتگذاری پزشک، کلینیک مرتبط — همان ترتیبی که `BillingCalculator`/pricing فعلی استفاده میکند) خوانده شود، نه کاربر لاگینشده.
+
+### ۵. Frontend — `FreeVisitPrice.tsx`
+
+- Toggle «الزامی کردن هزینه ویزیت» (همان الگوی سوییچ `AppointmentCreatePage` خطوط ۴۳۷–۴۵۰) زیر فیلد قیمت.
+- Helper text (متن راهنما) زیر toggle با استایل `fontSize:12, color:'var(--text-3)'` — متن پیشنهادی:
+ «با فعال شدن این گزینه، وارد کردن هزینه ویزیت در تنظیمات، ثبت مراجعه (سرویس)، فاکتور سرویس و ثبت نوبت الزامی میشود و بدون آن امکان ذخیره وجود ندارد.»
+- interface را گسترش بده: `interface Pricing { free_visit_price_rials: number; require_visit_price: boolean }` و state محلی برای toggle.
+- `saveMut` هر دو کلید را بفرستد: `{ free_visit_price_rials, require_visit_price }`.
+- اعتبارسنجی client-side: اگر toggle فعال و قیمت خالی/صفر → دکمه ذخیره خطا بدهد (پیام خطای فارسی زیر فیلد + `toast.error`)، درخواست ارسال نشود.
+- وقتی toggle فعال است، label قیمت با `*` قرمز: «قیمت (تومان) *».
+
+### ۶. Frontend — `CreateStep.tsx`
+
+- query `insurance-pricing` از قبل هست؛ فلگ را بخوان:
+
+```tsx
+const requireVisit = (pricingData as any)?.data?.require_visit_price ?? false;
+```
+
+- label: `قیمت ویزیت (تومان){requireVisit && ' *'}` (ستاره قرمز).
+- در `submit()` قبل از mutate:
+
+```tsx
+if (requireVisit && visit <= 0) {
+ toast.error('هزینه ویزیت الزامی است');
+ return;
+}
+```
+
+- پیام خطای inline زیر فیلد وقتی الزامی و خالی/صفر (state خطا که با تغییر مقدار پاک شود).
+
+### ۷. Frontend — `AppointmentCreatePage.tsx`
+
+- query جدید: `useQuery({ queryKey: ['insurance-pricing'], queryFn: () => api.get('/api/v1/insurance-pricing') })` → `freeVisit` و `requireVisit`.
+- بخش جدید «هزینه ویزیت» (بعد از «بیعانه» یا کنار آن): `PriceInput` با state `visitPriceRials`، مقدار اولیه از `freeVisit` (با `useEffect` مشابه CreateStep، فقط وقتی کاربر دستی تغییر نداده).
+- label با `*` وقتی `requireVisit` فعال است؛ helper کوتاه «هزینه ویزیت این نوبت (تومان)».
+- payload: `...(visitPriceRials > 0 || requireVisit ? { visit_price_rials: visitPriceRials } : {})` — و شرط `valid` را گسترش بده: `&& (!requireVisit || visitPriceRials > 0)` تا دکمه «ثبت اطلاعات» بدون مقدار غیرفعال بماند.
+- پیام inline قرمز زیر فیلد وقتی الزامی و صفر.
+
+### ۸. تستها و مستندات
+
+- **PHPUnit:** ذخیره تنظیمات با فلگ فعال و قیمت 0 → 422؛ با قیمت معتبر → 200؛ ثبت session با فلگ فعال بدون `visit_price_rials` → 422، با مقدار → 201؛ فلگ غیرفعال → رفتار قبلی (صفر مجاز)؛ ایجاد نوبت با/بدون فلگ.
+- **Vitest:** `FreeVisitPrice` — toggle فعال + قیمت خالی → خطا و عدم ارسال؛ `CreateStep` — الزامی بودن قیمت ویزیت با فلگ (mock query)؛ سناریوی فلگ غیرفعال بدون تغییر رفتار.
+- `docs/api/insurance.md` (کلید جدید `require_visit_price` در GET/PUT insurance-pricing + خطای 422)، `docs/api/patient.md` (اعتبارسنجی جدید session)، `docs/api/appointment.md` (فیلد جدید `visit_price_rials`) — همه در همین سشن.
+
+## نکات مهم
+
+- **واحدها:** backend ریال (`_rials`)، UI تومان — تبدیل با `rialToToman`/`tomanToRial` (الگوی `FreeVisitPrice`). `PriceInput` مقدار ریالی نگه میدارد (الگوی `depositRials`) — سازگاری واحد را در AppointmentCreatePage دوبار چک کن.
+- **Envelope:** پاسخ insurance-pricing با `$this->success([...])` تکسطح است و frontend فعلی با `(data as any)?.data` میخواند — همین الگو را حفظ کن، double-nesting نساز.
+- گارد اصلی backend است؛ اعتبارسنجی UI فقط تجربه کاربری. هر سه endpoint (insurance-pricing PUT، session POST، appointment POST) باید مستقل از UI مقدار را رد کنند.
+- edge case: فلگ فعال ولی ردیف free-visit حذف/بدون قیمت → ثبت مراجعه باید 422 بدهد نه crash؛ `?->` و پیشفرض `false` رعایت شود.
+- edge case: کاربر با نقش admin (بدون پروفایل پزشک) در appointments/new — query insurance-pricing ممکن است 403 بدهد (`resolveEntity` کاربر جاری)؛ در این حالت فلگ را `false` فرض کن و فیلد اختیاری بماند، یا فلگ را بر اساس پزشک انتخابشده از endpoint مناسب بخوان — هنگام پیادهسازی بررسی و مستند کن.
+- دو migration جدا (EntityInsurancePricing، Appointment) یا یکی — خروجی `migrations:diff` را قبل از migrate بازبینی کن.
+- رشتههای UI فارسی؛ کد/کامیت انگلیسی.
+- سوییچ toggle را از الگوی موجود کپی کن؛ کامپوننت جدید عمومی نساز مگر تکرار سوم.
diff --git a/.claude/prompt/service-based-booking.md b/.claude/prompt/service-based-booking.md
new file mode 100644
index 00000000..42e73965
--- /dev/null
+++ b/.claude/prompt/service-based-booking.md
@@ -0,0 +1,217 @@
+# نوبتدهی بر اساس مدت سرویس (Service-based booking) — Backend + Admin
+
+## پروژه
+
+`clinicpro` (Backend Symfony + پنل ادمین React).
+**Cross-repo:** بخش نوبتدهی آنلاین در `nobat724_front` است → پرامپت همتا: `nobat724_front/.claude/prompt/service-based-online-booking.md` (این پرامپت اول اجرا شود؛ قرارداد endpointها را همانجا مصرف میکنند).
+
+## زمینه
+
+الان نوبتدهی «اسلاتی» است: در `WeeklySchedule.setting` (JSON) برای هر روز یک یا چند `session` تعریف میشود و `SlotCalculatorService::buildSessionSlots()` بازهٔ session را با گام ثابت `duration_per_patient` به اسلاتهای هماندازه میشکند. مدت هر نوبت مستقل از نوع خدمت است.
+
+هدف: افزودن حالت دوم «نوبتدهی بر اساس سرویس»، بهطوریکه مدت هر نوبت از `ServiceItem.durationMinutes` (که **الان هم در Entity هست ولی در محاسبهٔ نوبت استفاده نمیشود**) بیاید، نه از گام ثابت. حالت اسلاتی باید دستنخورده بماند و حالت جدید فقط یک گزینهٔ قابلانتخاب باشد.
+
+خبر خوب: بیشتر زیرساخت موجود است و نباید بازساخته شود:
+- `ServiceItem.durationMinutes` (`service_items.duration_minutes`, nullable) — مدت هر سرویس.
+- `Appointment.serviceItem` / `serviceSection` / `staff` (ManyToOne) — از قبل روی نوبت هست.
+- `Appointment.isReserve` (bool) — **همان «نوبت آزاد»** است (در سایت «نوبت رزرو»). day-level، اسلات اشغال نمیکند، فقط منشی ثبت میکند. **بازسازی نکن؛ از همین استفاده کن.**
+- `Holiday` و `DateOverride` entities — تعطیلات و استثناها از قبل هستند.
+- `AppointmentRepository::isSlotTaken()` **از قبل overlap واقعیِ بازهای میزند** (`a.slotStart < :slotEnd AND a.slotEnd > :slotStart`) — برای نوبتهای متغیرالطول هم درست کار میکند.
+
+## هدف / spec انگلیسی
+
+Add a per-doctor booking mode `slot | service` stored in `WeeklySchedule` meta. In `service` mode:
+- Working hours per weekday come from the existing `sessions` windows (`start_time`/`end_time`), but `duration_per_patient` is ignored; appointment length = sum of selected services' `durationMinutes` + optional `buffer_minutes`.
+- A new endpoint returns candidate start times: first-fit free gaps inside each session window that fit the requested duration, treating existing bookings (interval-overlap) as busy.
+- Booking accepts service items, derives `slot_end = slot_start + Σ durationMinutes + buffer`, and inserts atomically without overlap.
+
+## فایلهای مرتبط
+
+| فایل | نقش | تغییر |
+|------|-----|-------|
+| `src/Appointment/Entity/WeeklySchedule.php` | متای برنامهٔ هفتگی | افزودن `booking_mode` + `buffer_minutes` به `DEFAULT_META` و `setMeta()` |
+| `src/Appointment/Service/SlotCalculatorService.php` | محاسبهٔ زمان | افزودن مسیر service-based (متد جدید `getServiceStartTimes`) |
+| `src/Appointment/Repository/AppointmentRepository.php` | `isSlotTaken` / `bookAtomically` | افزودن قفلِ per-doctor برای حالت سرویس (توضیح در نکات) |
+| `src/Appointment/Controller/AppointmentController.php` | endpoint اسلات + book | endpoint جدید سرویس + پذیرش سرویس در `book()` |
+| `src/Appointment/Controller/MyAppointmentsController.php` | ثبت توسط منشی | پذیرش سرویس/مدت در ایجاد نوبت منشی |
+| `src/ClinicService/Entity/ServiceItem.php` | مدت + نمایش در نوبتدهی | افزودن فیلد `bookable` (bool) — **migration لازم** — `durationMinutes` از قبل هست |
+| `src/ClinicService/Controller/ClinicServiceController.php` (createItem L143, updateItem L188) | POST/PATCH سرویس | پذیرش `bookable` کنار `duration_minutes` موجود |
+| `src/ClinicService/Repository/ServiceItemRepository.php` | کوئری سرویس | افزودن `findBookableByEntity`/شمارش سرویسهای bookable برای enforcement |
+| `docs/api/appointment.md`, `docs/api/appointment-settings.md` | مستندات | بهروزرسانی همزمان (Standing Rule) |
+| `assets/admin/pages/DoctorDetailPage.tsx` (`WeeklyScheduleTab`, ~L1231؛ SessionConfig L92, defaults L304) | ویرایشگر برنامهٔ هفتگی | افزودن سوییچ حالت + فیلد بافر؛ در حالت سرویس مخفیکردن `duration_per_patient` |
+| `assets/admin/pages/AppointmentSettingsPage.tsx` | «مدیریت نوبت دهی» | همان `WeeklyScheduleTab` را render میکند — خودکار سوییچ را میگیرد |
+| `assets/admin/components/NewAppointmentDrawer.tsx` | فرم ثبت نوبتِ منشی | در حالت سرویس: پیشنهاد زمانهای خالی بهجای ورود دستی ساعت |
+| `assets/admin/pages/ClinicServicesPage.tsx` (617 خط) | مدیریت سرویسها | مطمئن شو فیلد «مدت (دقیقه)» برای هر ServiceItem قابلویرایش است |
+
+## وضعیت فعلی (کد واقعی)
+
+### مدت خدمت — هست ولی استفاده نمیشود
+```php
+// src/ClinicService/Entity/ServiceItem.php:58
+#[ORM\Column(name: 'duration_minutes', type: 'integer', nullable: true)]
+private ?int $durationMinutes = null; // getter L87, setter L124, در toArray L153
+```
+
+### متای برنامهٔ هفتگی
+```php
+// src/Appointment/Entity/WeeklySchedule.php:18
+public const DEFAULT_META = [
+ 'online_booking_enabled' => true,
+ 'booking_window_value' => 1,
+ 'booking_window_unit' => 'month',
+];
+// setMeta() (L76) فقط سه کلید بالا را whitelist میکند
+```
+
+### ساخت اسلاتِ ثابت (حالت فعلی = slot mode)
+```php
+// src/Appointment/Service/SlotCalculatorService.php:225 buildSessionSlots()
+$dur = (int)($session['duration_per_patient'] ?? 20) * 60; // گام ثابت
+while ($currentSec + $dur <= $endSec) { ... $currentSec += $dur; }
+```
+
+### overlap واقعی از قبل درست است
+```php
+// src/Appointment/Repository/AppointmentRepository.php:91 isSlotTaken()
+->andWhere('a.slotStart < :slotEnd')
+->andWhere('a.slotEnd > :slotStart') // interval overlap — نه exact key
+```
+
+### book() فعلی فقط slot_start/slot_end میگیرد
+```php
+// src/Appointment/Controller/AppointmentController.php:224
+$slotStart = (int)($data['slot_start'] ?? 0);
+$slotEnd = (int)($data['slot_end'] ?? 0);
+// ... new Appointment($doctor, $user, $slotStart, $slotEnd)
+```
+
+## وظایف
+
+### ۱. متای WeeklySchedule: افزودن `booking_mode` و `buffer_minutes`
+
+در `WeeklySchedule.php`:
+```php
+public const MODE_SLOT = 'slot';
+public const MODE_SERVICE = 'service';
+
+public const DEFAULT_META = [
+ 'online_booking_enabled' => true,
+ 'booking_window_value' => 1,
+ 'booking_window_unit' => 'month',
+ 'booking_mode' => self::MODE_SLOT, // پیشفرض = رفتار فعلی
+ 'buffer_minutes' => 0,
+];
+```
+در `setMeta()` این دو کلید را هم whitelist کن (validate: `booking_mode ∈ {slot,service}`، `buffer_minutes` = `max(0, (int))`). چون Entity تغییر نمیکند (فقط محتوای JSON)، **migration لازم نیست**؛ ولی `getMeta()` با `array_merge(DEFAULT_META, ...)` مقدار پیشفرض را به رکوردهای قدیمی میدهد — این backward-compat را حفظ میکند.
+
+### ۲. SlotCalculatorService: مسیر service-based
+
+متد جدید که برای یک مدت مشخص (به دقیقه) زمانهای شروعِ ممکن را برمیگرداند. از `buildAllSessions()` موجود استفاده کن تا window/holiday/override/booking-window همه رعایت شوند، ولی بهجای اسلاتِ ثابت، gap-packing کن:
+
+```php
+/**
+ * زمانهای شروعِ ممکن برای نوبتی به طول $durationMinutes (+ بافر) در یک روز.
+ * first-fit: داخل هر session، از ابتدای window شروع میکند، بازههای اشغالشده
+ * (نوبتهای موجود) را رد میکند و اولین جای پیوستهٔ کافی را پیشنهاد میدهد.
+ *
+ * @return array[] [{start, end, start_time, end_time, location_id}]
+ */
+public function getServiceStartTimes(Doctor $doctor, string $date, int $durationMinutes): array
+{
+ $buffer = (int)($this->getBookingMeta($doctor)['buffer_minutes'] ?? 0);
+ $needSec = ($durationMinutes + $buffer) * 60;
+ if ($needSec <= 0) return [];
+
+ $sessions = $this->buildAllSessions($doctor, $date); // window/holiday/override رعایت میشود
+ $now = time();
+ $result = [];
+
+ foreach ($sessions as $session) {
+ // مرزهای واقعی window از start_time/end_time همان session
+ // (نه از اسلاتهای ثابتِ ساختهشده)
+ $winStart = $dayStart + parseTime(session.start_time);
+ $winEnd = $dayStart + parseTime(session.end_time);
+ $busy = بازههای اشغالشدهٔ [winStart, winEnd) از AppointmentRepository (فقط SLOT_BLOCKING + pending زنده)؛
+ // پیمایش با گام مناسب (مثلاً بافر یا ۵ دقیقه) و بررسی عدم تداخل با $busy:
+ for ($t = $winStart; $t + $needSec <= $winEnd; ) {
+ $end = $t + $needSec;
+ if ($t >= $now && !overlapsAny($t, $end, $busy)) {
+ $result[] = ['start'=>$t, 'end'=>$t + $durationMinutes*60, /* بافر جزو نمایش نیست */
+ 'start_time'=>gmdate('H:i',...), 'location_id'=>session.location_id];
+ $t = $end; // بعد از این نوبت + بافر ادامه بده
+ } else {
+ $t = پرش به انتهای بازهٔ اشغالشدهٔ متداخل، یا + گام کوچک;
+ }
+ }
+ }
+ return $result;
+}
+```
+
+نکات پیادهسازی:
+- برای گرفتن نوبتهای موجودِ یک روز، یک متد repository اضافه کن (مثلاً `findBusyIntervals(Doctor, int $dayStart, int $dayEnd): array` که `[slotStart, slotEnd]` نوبتهای blocking + pendingِ زنده و **غیر-reserve** را برمیگرداند). `isReserve=true` هیچ بازهای اشغال نمیکند.
+- `slot_end` ذخیرهشده = `start + durationMinutes*60` (بدون بافر)؛ بافر فقط فاصلهٔ بین نوبتها را در پیشنهاد ایجاد میکند (تا نوبت بعدی زودتر از `end+buffer` پیشنهاد نشود). این تصمیم را در docstring بنویس تا edge سازگار بماند.
+- اگر هیچ جای کافی نبود، آرایهٔ خالی برگردان (کنترلر پیام مناسب میدهد).
+
+### ۳. Endpoint جدید: زمانهای خالی بر اساس سرویس
+
+در `AppointmentController` (عمومی، مثل `/appointment-slots`):
+```
+GET /api/v1/appointment-service-slots?doctor_uuid=..&date=YYYY-MM-DD&service_item_uuids[]=..&service_item_uuids[]=..
+```
+- مدت = مجموع `durationMinutes` سرویسهای دادهشده (اگر سرویسی `durationMinutes` نداشت → خطای ۴۲۲ «مدت سرویس تعریف نشده»).
+- خروجی با envelope استاندارد:
+```json
+{ "success": true, "data": {
+ "doctor_uuid": "...", "date": "YYYY-MM-DD",
+ "total_duration_minutes": 45, "buffer_minutes": 5,
+ "start_times": [ { "start": 1750000000, "end": 1750002700, "start_time": "15:00", "location_id": 12 } ]
+} }
+```
+- اگر پزشک در حالت `slot` است، این endpoint میتواند خطای ۴۲۲ «این پزشک در حالت نوبتدهی سرویس نیست» بدهد یا خالی برگرداند — تصمیم را مستند کن.
+- `ServiceItem` repository از قبل هست (`ServiceItemRepository::findByUuid`).
+
+### ۴. book() و MyAppointmentsController: پذیرش سرویس
+
+در `AppointmentController::book()` و `MyAppointmentsController` (POST `/api/v1/my/appointment`):
+- ورودی جدید اختیاری: `service_item_uuids: string[]` (و/یا `service_item_uuid` تکی که الان هم پذیرفته میشود).
+- اگر پزشک `service` mode است و سرویس داده شده: `slot_end` را از `slot_start + Σ durationMinutes*60` **در سمت سرور** محاسبه کن (به `slot_end` کلاینت اعتماد نکن) و همان serviceItem را روی نوبت set کن.
+- حالت `slot` دقیقاً مثل الان بماند (از `slot_end` کلاینت استفاده کن).
+- قبل از insert، در همان تراکنش `isSlotTaken` (که overlap واقعی میزند) کافی است برای صحت منطقی؛ ولی **race concurrency** را ببین نکتهٔ زیر.
+
+### ۴.۵ نشان «نمایش در نوبتدهی» روی سرویس + اجبار در حالت سرویس
+
+پزشک ممکن است نخواهد همهٔ سرویسها در نوبتدهی نمایش داده شوند. پس:
+
+- **`ServiceItem`:** فیلد جدید `bookable` (bool, default `false`, ستون `bookable`) = «نمایش در نوبتدهی». getter/setter + در `toArray()`. **migration بساز و اجرا کن** (این تنها Entity change است).
+- **`ClinicServiceController` (createItem L143, updateItem L188):** `bookable` را مثل `duration_minutes` بپذیر (`if (array_key_exists('bookable', $data)) $item->setBookable((bool)$data['bookable']);`).
+- **`ServiceItemRepository`:** متد `countBookableByEntity($entityType, $entityId): int` (یا `findBookable...`) برای enforcement.
+- **فیلتر نوبتدهی:** endpoint `appointment-service-slots` و `book()`/منشی فقط سرویسهای `bookable=true` را بپذیرند؛ سرویس غیر-bookable → ۴۲۲ «این سرویس برای نوبتدهی فعال نیست».
+- **اجبار حالت سرویس:** در `AppointmentSettingsController::createSchedule`/`updateSchedule`، وقتی `meta.booking_mode === service` و هیچ سرویسِ `bookable` برای آن پزشک/کلینیک وجود ندارد → ۴۲۲ «برای نوبتدهی سرویسی حداقل یک سرویس با «نمایش در نوبتدهی» لازم است». (سرویسها به entity کلینیک/پزشک وصلاند از طریق `ServiceSection.entityType/entityId` — همان resolve موجود در ClinicServiceController.)
+
+### ۵. پنل ادمین
+
+- **`WeeklyScheduleTab` (DoctorDetailPage.tsx):** بالای ویرایشگر یک سوییچ «نوبتدهی اسلاتی / بر اساس سرویس» + فیلد «بافر بین نوبتها (دقیقه)» اضافه کن که به `meta.booking_mode` و `meta.buffer_minutes` map شود (همراه schedule در همان POST/PATCH `weekly-schedule` ذخیره میشود؛ `meta` از قبل پشتیبانی میشود). در حالت سرویس، فیلد `duration_per_patient` هر session را مخفی/غیرفعال کن (چون بیاثر است) و فقط ساعت شروع/پایان window و آدرس بماند.
+- **`ClinicServicesPage.tsx`:** برای هر ServiceItem دو کنترل: فیلد «مدت (دقیقه)» → `duration_minutes` و سوییچ «نمایش در نوبتدهی» → `bookable`. هر دو در POST/PATCH `/service-item` ارسال شوند.
+- **`WeeklyScheduleTab`:** وقتی حالت «سرویس» انتخاب شد و پزشک هیچ سرویسِ bookable ندارد، پیام/لینک به صفحهٔ سرویسها نشان بده و اجازهٔ ذخیره نده (backend هم ۴۲۲ میدهد).
+- **`NewAppointmentDrawer.tsx`:** الان منشی دستی `duration` + ساعت شروع/پایان وارد میکند (L70-74, L194-208). در حالت سرویس پزشک:
+ - بعد از انتخاب یک/چند سرویس، `service_item_uuids[]` را به endpoint جدید بفرست و لیست «زمانهای خالی پیشنهادی» را نمایش بده؛ منشی یکی را انتخاب میکند (بهجای ورود دستی ساعت). `slot_start/slot_end` از انتخاب پر میشود.
+ - اگر هیچ زمانی نبود پیام «امروز جای خالی برای این سرویس نیست» + امکان رفتن به روز بعد.
+ - مسیر «نوبت آزاد» (`isReserve=true`, L28/L92) دستنخورده بماند — بدون زمان، فقط منشی.
+ - در حالت اسلاتی، همان رفتار فعلی (ورود دستی/اسلات) حفظ شود.
+
+### ۶. مستندات و تست
+
+- `docs/api/appointment.md`: endpoint `GET /appointment-service-slots` + پارامترهای جدید `book`.
+- `docs/api/appointment-settings.md`: کلیدهای متای جدید `booking_mode`, `buffer_minutes`.
+- تستهای PHPUnit (موفق + خطا + مرزی): `getServiceStartTimes` (پر شدن، gap بین دو نوبت، عدم جای کافی)، محاسبهٔ `slot_end` سمت سرور، عدم تداخل، حفظ رفتار slot mode. تست Vitest برای سوییچ حالت و جریان جدید Drawer.
+
+## نکات مهم
+
+- **⚠️ race در حالت سرویس (مهمترین edge):** unique constraint روی `active_slot_key = "doctorId:slotStart"` است — یعنی فقط دو نوبت با **شروع دقیقاً یکسان** را در سطح DB میگیرد. در حالت اسلاتی چون شروعها روی گرید ثابتاند، هر تداخل ⇒ شروع یکسان ⇒ constraint میگیرد. اما در حالت سرویس، دو درخواست همزمانِ «۱۵:۰۰ به مدت ۳۰د» و «۱۵:۲۰ به مدت ۳۰د» شروعِ متفاوت دارند، پس `activeSlotKey` متفاوت است و constraint نمیگیرد؛ هر دو `isSlotTaken` را خالی میبینند و هر دو insert میشوند → **تداخل**. راهحل: در `bookAtomically` **در حالت سرویس** قبل از `isSlotTaken`، یک قفلِ per-doctor بگیر تا رزروهای یک پزشک سریالایز شوند — یا pessimistic lock روی ردیف `Doctor` (`$em->lock($doctor, LockMode::PESSIMISTIC_WRITE)`) یا MySQL `GET_LOCK("appt:doctor:{id}")`/`RELEASE_LOCK`. حالت اسلاتی را تغییر نده (همان unique-key کافی است).
+- **حفظ حالت اسلاتی:** هیچ رفتار موجودی نباید تغییر کند وقتی `booking_mode = slot`. مسیر جدید فقط شاخهٔ `service`.
+- **نوبت آزاد = `isReserve` موجود، نه type جدید.** بازسازی نکن. در تقویم روز از قبل با پرچم متمایز است (`ReserveAppointmentsPage.tsx` + فیلتر `?reserve=1` در `my/appointments`). فقط مطمئن شو بازهای اشغال نمیکند (`refreshActiveSlotKey` وقتی `isReserve` → key null است).
+- **تغییر حالت نباید نوبتهای قبلی را خراب کند:** نوبتهای ثبتشده `slot_start/slot_end` مطلق (Unix) دارند و مستقل از حالتاند؛ سوییچ حالت فقط روی محاسبهٔ نوبتهای جدید اثر دارد. این را در docstring/تست تثبیت کن.
+- **ویرایش/لغو و آزادسازی زمان:** از قبل کار میکند — لغو → `transitionTo(cancelled_*)` → `refreshActiveSlotKey` → key null → `isSlotTaken` دیگر آن بازه را busy نمیبیند. `update`/`rescheduleTo` هم موجود است. فقط مطمئن شو مسیر service اینها را نمیشکند.
+- **الگوهای پروژه:** کنترلرها از `BaseController` ارث میبرند؛ پاسخ با `$this->success()/error()`؛ timestampها Unix `int`؛ رشتههای UI فارسی؛ کد/کامیت انگلیسی. هر session فعال در schedule باید `location_id` داشته باشد (`validateSessionsHaveLocation`) — در حالت سرویس هم حفظ شود.
+- **قاعدهٔ ۲ (اول بگرد بعد بساز):** `durationMinutes`، `serviceItem`، `isReserve`، `Holiday`، `DateOverride`، overlapِ `isSlotTaken` همه موجودند؛ فقط متای mode/buffer + یک متد محاسبه + یک endpoint + وصلکردن UI اضافه میشود.
diff --git a/.claude/prompt/service-management-refinements.md b/.claude/prompt/service-management-refinements.md
new file mode 100644
index 00000000..7acecec1
--- /dev/null
+++ b/.claude/prompt/service-management-refinements.md
@@ -0,0 +1,259 @@
+# اصلاحات بخش مدیریت سرویسها (بیمه، ورودیهای عددی، صفحه جزئیات)
+
+## پروژه
+
+`clinicpro` — پنل ادمین React (`assets/admin/`) + یک اندپوینت جدید در backend (`src/ClinicService/`).
+cross-repo نیست؛ سایت عمومی این بخش را مصرف نمیکند.
+
+## زمینه
+
+بخش «مدیریت سرویسها» (`/admin/clinic-services`) الان یک صفحه واحد است: نمای بخشها → نمای کارتهای سرویس، و
+همهٔ عملیات (ویرایش، تعرفهٔ سالانه، پوشش بیمه) در مودال باز میشود. سه اشکال دارد:
+
+1. **دو جای تنظیم بیمه:** در مودال ویرایش سرویس یک سوییچ «این خدمت شامل بیمه میشود» + «قیمت تقریبی با بیمه» وجود دارد،
+ در حالی که تنظیمات واقعی بیمه (درصد پوشش، فرانشیز، سقف، به تفکیک هر بیمهگر) در `ServiceInsuranceModal` است.
+ کاربر دو منبع حقیقت میبیند.
+2. **NaN با کیبورد فارسی:** فیلد «درصد پوشش» در `ServiceInsuranceModal` مقدار خام را `Number()` میکند؛ رقم فارسی → `NaN`.
+3. **صفحهٔ جزئیات ندارد:** کلیک روی سرویس هیچ کاری نمیکند؛ همهچیز در مودال پراکنده است.
+
+## فایلهای مرتبط
+
+| فایل | نقش |
+|------|-----|
+| `assets/admin/pages/ClinicServicesPage.tsx` | صفحهٔ اصلی (۶۳۴ خط): نمای بخشها + کارت سرویسها + مودال ایجاد/ویرایش سرویس |
+| `assets/admin/components/ServiceInsuranceModal.tsx` | مودال پوشش بیمه به تفکیک قرارداد بیمهگر (منشأ باگ NaN، خط ۱۳۴) |
+| `assets/admin/components/ServiceTariffModal.tsx` | مودال تعرفههای سالانه |
+| `assets/admin/components/ui/PriceInput.tsx` | ورودی مبلغ (تبدیل رقم را درست انجام میدهد ولی رفتار ویرایش ناقص است) |
+| `assets/admin/lib/forms.ts` | `numericField()` / `latinDigitsField()` — wrapper صحیح برای RHF |
+| `assets/admin/lib/utils.ts` | `toEnglishDigits`, `digitsOnly`, `rialToToman`, `tomanToRial`, `formatRial` |
+| `assets/admin/components/ui/DigitInput.tsx` | ورودی فقط-رقم برای state معمولی (غیر RHF) |
+| `assets/admin/App.tsx` | جدول route ها (خط ۲۴۸: `clinic-services`) |
+| `src/ClinicService/Controller/ClinicServiceController.php` | اندپوینتهای سرویس/بخش/تعرفه |
+| `src/ClinicService/Entity/ServiceItem.php` | Entity + `toArray()` (خط ~۱۵۰) |
+| `docs/api/clinicservice.md` (یا معادلش) | مستندات API که باید در همین session بهروز شود |
+
+## وضعیت فعلی
+
+### ۱) سوییچ بیمه در مودال سرویس — `ClinicServicesPage.tsx:547-591`
+
+```tsx
+{/* بیمه */}
+
+
+
+
+
این خدمت شامل بیمه میشود
+
نشانهی سریع برای فهرست سرویسها
+
+
+
+
+
+ {itemForm.watch('insurance_covered') && (
+
// قیمت تقریبی با بیمه
+ )}
+
+```
+
+### ۲) باگ NaN — `ServiceInsuranceModal.tsx:129-135`
+
+```tsx
+ setDraft((d) => ({
+ ...d,
+ coverage_percent: e.target.value === '' ? null : Number(e.target.value), // ← «۲۰» ⇒ NaN
+ }))}
+/>
+```
+
+`placeholder="ارث"` هم غلط تایپی است (باید «ارث از قرارداد» باشد).
+
+### ۳) `PriceInput` — `ui/PriceInput.tsx:31-42`
+
+```tsx
+const [display, setDisplay] = useState(...);
+useEffect(() => { setDisplay(value !== '' && Number(value) > 0 ? formatDisplay(Number(value), latin) : ''); }, [value, latin]);
+
+const handleChange = (e) => {
+ const raw = toEnglishDigits(e.target.value).replace(/[^0-9]/g, '');
+ const num = raw === '' ? 0 : Math.max(min, parseInt(raw, 10));
+ onChange(num);
+ setDisplay(num > 0 ? formatDisplay(num, latin) : '');
+};
+```
+
+تبدیل رقم درست است، ولی: مقدار `0` همیشه به رشتهٔ خالی تبدیل میشود (کاربر نمیتواند صفر را ببیند/بنویسد)،
+`Math.max(min, …)` هنگام تایپ رقمِ اول مقدار را به `min` میپراند، و واحد (تومان) در خود فیلد دیده نمیشود
+در حالی که label میگوید «قیمت پایه (تومان)» ولی مقدار ذخیرهشده ریال است (`tomanToRial` در mutation).
+
+### ۴) نبود صفحهٔ جزئیات
+
+`App.tsx:248` فقط یک route دارد:
+
+```tsx
+ } />
+```
+
+backend هم اندپوینت «یک سرویس با uuid» ندارد؛ فقط `GET /api/v1/service-items` (همه) و
+`GET /api/v1/service-items/{sectionUuid}` (بهتفکیک بخش).
+
+## وظایف
+
+> ترتیب اجرا مهم است: ۲ → ۳ → ۱ → ۴ → ۵. اول ابزار عددی درست شود، بعد UI روی آن بنا شود.
+
+### ۱. حذف تنظیمات بیمه از مودال ایجاد/ویرایش سرویس
+
+- بلاک «بیمه» (`ClinicServicesPage.tsx:547-591`) کامل حذف شود؛ `insurance_covered` و `insurance_price_rials`
+ از `itemSchema`، از `openEditItem`/`openCreateItem` و از payload های `createItem`/`editItem` حذف شوند.
+- **backend را تغییر نده:** ستونهای `insurance_covered` / `insurance_price_rials` روی `ServiceItem` باقی میمانند
+ (دادهٔ قدیمی + استفاده در جای دیگر). فقط دیگر از این فرم ارسال نمیشوند. اندپوینتها `isset()`-based هستند
+ (`ClinicServiceController.php:183,227`) پس نبودِ فیلد در body مشکلی ایجاد نمیکند.
+- بهجای آن، در همان محلِ حذفشده یک اشارهٔ کوتاه بگذار که کاربر را به مدیریت بیمه هدایت کند — یک باکس اطلاع
+ با همان استایل باکس راهنمای موجود در `ServiceInsuranceModal.tsx:196-202` (`background: var(--primary-soft)`)
+ و یک دکمهٔ `btn sm` که همان `setInsuranceItem(item)` را باز میکند. در حالت «سرویس جدید» (هنوز uuid ندارد)
+ فقط متن راهنما نمایش داده شود، بدون دکمه.
+- کارت سرویس (`ClinicServicesPage.tsx:390-392`) که `item.insurance_covered` را نشان میدهد باید بهجای فیلد
+ حذفشده، وضعیت واقعی بیمه را از پوششهای ثبتشده نشان دهد یا اگر داده در دسترس نیست، آن ردیف حذف شود.
+ **سادهترین راه سازگار: ردیف «سهم بیمار (بیمه)» از کارت حذف شود** و اطلاعات بیمه فقط در صفحهٔ جزئیات (وظیفهٔ ۴) بیاید.
+
+### ۲. رفع ریشهای NaN در ورودیهای عددی
+
+هیچ فیلد عددی نباید مستقیم `Number(e.target.value)` بزند. یک ابزار مشترک در `lib/utils.ts` اضافه کن:
+
+```ts
+/**
+ * رشتهٔ ورودی کاربر (با ارقام فارسی/عربی، کاما، فاصله) را به عدد امن تبدیل میکند.
+ * هرگز NaN برنمیگرداند؛ ورودی نامعتبر ⇒ null.
+ */
+export function parseUserNumber(raw: string | number | null | undefined): number | null {
+ if (raw == null || raw === '') return null;
+ const s = toEnglishDigits(String(raw)).replace(/[,\s٫٬]/g, '');
+ if (!/^-?\d*\.?\d+$/.test(s)) return null;
+ const n = Number(s);
+ return Number.isFinite(n) ? n : null;
+}
+
+/** همان، با clamp اختیاری — برای درصد (۰..۱۰۰) و مقادیر غیرمنفی. */
+export function parseUserNumberClamped(raw: string | number | null | undefined, min: number, max: number): number | null {
+ const n = parseUserNumber(raw);
+ return n == null ? null : Math.min(max, Math.max(min, n));
+}
+```
+
+سپس:
+
+- **`ServiceInsuranceModal.tsx:129-135`** — فیلد «درصد پوشش» بازنویسی شود: مقدار نمایشی را در یک state رشتهای
+ نگه دار (تا کاربر بتواند فیلد را خالی کند یا در حال تایپ باشد)، و مقدارِ ذخیرهشونده را با
+ `parseUserNumberClamped(v, 0, 100)` بساز. `placeholder="ارث"` → `placeholder="ارث از قرارداد"`.
+ فرانشیز و سقف قبلاً `PriceInput` هستند و بعد از وظیفهٔ ۳ خودبهخود درست میشوند.
+- **`ClinicServicesPage.tsx:530`** — `duration_minutes` از `numericField()` استفاده میکند (درست است)؛ اما چون
+ `z.coerce.number()` روی رشتهٔ خالی `0` میدهد، schema به `z.coerce.number().min(0).optional().or(z.literal(''))`
+ یا یک `preprocess` تبدیل شود تا «خالی» به `undefined` نگاشت شود، نه صفر.
+- **سراسر پنل** — این موارد بررسی و اصلاح شوند (نتیجهٔ grep روی `assets/admin`):
+ - `components/ImageCropModal.tsx:59` — `Number(e.target.value)` روی ` `؛ چون range همیشه
+ مقدار لاتین میدهد بیخطر است؛ فقط تأیید کن و دست نزن.
+ - `components/paymentMethods/PosFormModal.tsx:88,92` — «شماره ترمینال» و «شماره حساب» ورودی آزادند و رقم فارسی
+ را همانطور ذخیره میکنند؛ باید به `DigitInput` تبدیل شوند.
+ - فایلهای دارای `z.coerce.number()`: `ClinicServicesPage.tsx`, `SmsWalletPage.tsx`, `AdminSubscriptionPage.tsx`,
+ `RepresentationsPage.tsx`, `MyPatientsPage.tsx` — در هرکدام مطمئن شو input متناظر با `numericField(register(...))`
+ یا `PriceInput` رندر میشود، نه `register(...)` خام. هرجا خام بود اصلاح کن.
+- **تست:** برای `parseUserNumber` تست واحد بنویس (`lib/utils.test.ts` یا فایل جدید) با موارد:
+ `'۲۵' → 25`، `'٢٥' → 25`، `'1,200' → 1200`، `'' → null`، `'abc' → null`، `'۱۲.۵' → 12.5`، `'-۳' → -3`.
+ و یک تست کامپوننتی برای فیلد درصد پوشش که با تایپ `'۲۵'` مقدار `25` میدهد و هرگز `NaN` نمایش نمیدهد.
+
+### ۳. اصلاح `PriceInput` (نمایش و ورود مبلغ)
+
+`ui/PriceInput.tsx` بازنویسی شود با این رفتار:
+
+- ارقام فارسی/عربی و کاما و فاصله در ورودی پذیرفته و نرمال شوند (از `parseUserNumber` استفاده کن).
+- **پیست** (paste) با متن مثل `«۸۵,۰۰۰ تومان»` باید به `85000` تبدیل شود، نه خطا.
+- مقدار `0` نباید به رشتهٔ خالی تبدیل شود مگر کاربر خودش پاک کرده باشد؛ تفکیک «خالی» از «صفر» لازم است
+ (state داخلی رشتهای + `onChange(number)`).
+- `Math.max(min, …)` نباید حین تایپ اعمال شود (clamp فقط `onBlur`).
+- نمایش با جداکنندهٔ هزارگان `fa-IR` (رفتار فعلی) حفظ شود؛ `direction: ltr` و `text-align: left` بماند.
+- یک `suffix` اختیاری اضافه شود (`suffix="تومان"`) تا واحد داخل فیلد دیده شود؛ در
+ `ClinicServicesPage.tsx:480` و همهٔ کاربردهای مبلغ استفاده شود.
+- مقدار ذخیرهشده همیشه عدد معتبر باشد (هرگز `NaN`/`undefined`).
+- تست موجود اگر هست بهروز شود؛ اگر نیست تست واحد بنویس (تایپ فارسی، پیست با واحد، صفر، خالی، clamp روی blur).
+
+> مراقب باش: label «تومان» است ولی مقدار API ریال است (`tomanToRial` در `createItem`/`editItem`
+> و `rialToToman` در `openEditItem`). این نگاشت را تغییر نده.
+
+### ۴. صفحهٔ اختصاصی جزئیات سرویس
+
+**Backend — یک اندپوینت جدید (تنها موردی که واقعاً لازم است):**
+
+اندپوینت «یک سرویس با uuid» وجود ندارد؛ گرفتن کل لیست و فیلتر سمت کلاینت با refresh مستقیم روی صفحهٔ جزئیات
+شکننده است. اضافه کن:
+
+```php
+#[Route('/api/v1/service-item/{uuid}', methods: ['GET'])]
+public function getItem(string $uuid, #[CurrentUser] User $user): JsonResponse
+{
+ // همان الگوی مالکیت/tenant که در updateItem (خط ۲۰۵) استفاده شده
+ // خروجی: $this->success($item->toArray()) ← بدون nest اضافه
+}
+```
+
+- `ServiceItem::toArray()` باید `section` (uuid + name)، `created_at`، `updated_at`، `staff_members`،
+ `bookable`، `duration_minutes` را داشته باشد؛ اگر ندارد اضافه کن.
+- `docs/api/` مربوطه در همین session بهروز شود (قانون استاندارد پروژه).
+- migration لازم نیست (تغییر schema نداریم).
+
+**Frontend:**
+
+- فایل جدید `assets/admin/pages/ServiceDetailPage.tsx`.
+- route جدید در `App.tsx` کنار route فعلی، با همان `RoleRoute roles={['doctor', 'clinic']} blockClinicScope`:
+ ` `.
+- در `ClinicServicesPage.tsx` کلیک روی بدنهٔ کارت سرویس → `navigate('/admin/clinic-services/' + item.uuid)`.
+ منوی ⋮ و سوییچها باید `e.stopPropagation()` داشته باشند تا ناوبری اتفاق نیفتد (الگوی موجود در کارت بخشها، خط ۲۶۰).
+- محتوای صفحه:
+ | بخش | منبع داده |
+ |------|-----------|
+ | اطلاعات پایه (نام، بخش، وضعیت فعال، نمایش در نوبتدهی، زمان متوسط، پرسنل) | `GET /api/v1/service-item/{uuid}` |
+ | قیمت پایه | همان (نمایش با `formatRial`) |
+ | تعرفههای سالانه | `GET /api/v1/service-items/{uuid}/tariffs` (موجود) |
+ | بیمههای مرتبط و پوشش | `GET /api/v1/billing/tenant-insurances` + `.../{uuid}/service-coverage` — همان کوئریهای `ServiceInsuranceModal` |
+ | تاریخ ایجاد / آخرین ویرایش | `created_at` / `updated_at` با `formatDateTime` (شمسی) |
+- **کالاهای مرتبط:** الان هیچ رابطهای بین `ServiceItem` و `src/Inventory/` وجود ندارد
+ (`InventoryPackage` polymorphic است با `entity_type`/`entity_id` ولی هیچجا با `service_item` پر نمیشود).
+ بنابراین در این پرامپت **این سکشن ساخته نشود**. اگر لازم شد، بهعنوان کار جدا مطرح کن و در گزارش پایانی
+ بنویس چه چیزی لازم است (رابطهٔ جدید + endpoint + UI).
+- **لاگ تغییرات:** هیچ زیرساخت audit-log برای `ServiceItem` وجود ندارد. این سکشن هم ساخته نشود؛
+ بهجایش فقط «تاریخ ایجاد» و «آخرین ویرایش» نمایش داده شود. در گزارش پایانی ذکر کن.
+
+### ۵. UI/UX صفحهٔ جزئیات — بدون طراحی جدید
+
+**اجباری:** هیچ تم/طرح/کامپوننت جدیدی ساخته نشود. صفحه دقیقاً با Layout و Design System فعلی پنل پیاده شود:
+
+- از `components/ui/PageHeader` برای عنوان + breadcrumb («سرویسها ‹ {نام بخش} ‹ {نام سرویس}») + دکمهٔ اقدام.
+- از `card` / `card-pad` / `card-title-row` / `section-title` / `muted` / `badge` / `field` / `field-label`
+ و توکنهای `styles.css` (`--surface`, `--border`, `--primary`, `--r`, `--gap`) استفاده شود. **هیچ hex هاردکد.**
+- تبها با همان الگوی `className="seg"` که در `pages/ClinicAppointmentSettingsPage.tsx:74-85` استفاده شده.
+- انتخابها با `SearchableSelect` (نه `` بومی)، تأییدها با `ConfirmDialog`، مبالغ با `PriceInput`،
+ تاریخها با `formatDate`/`formatDateTime` شمسی، اعداد با `formatNumber`.
+- ویرایش سرویس، تعرفه و پوشش بیمه از همین صفحه در دسترس باشند با **همان مودالهای موجود**
+ (`ServiceTariffModal`، `ServiceInsuranceModal`) — مودال جدید ساخته نشود.
+- حالتهای loading / empty / error با همان الگوی موجود در `ClinicServicesPage.tsx` (متن `در حال بارگذاری...`،
+ کارت خالی با آیکون Heroicon).
+- RTL و رشتههای فارسی؛ آیکونها فقط Heroicons v2 outline.
+
+## نکات مهم
+
+- **ترتیب:** ابزار عددی (وظیفهٔ ۲ و ۳) اول؛ بعد UI. اگر اول UI بسازی، باگ NaN را در صفحهٔ جدید تکرار میکنی.
+- **قانون API:** اندپوینت جدید فقط `GET /api/v1/service-item/{uuid}` است و دلیلش بالا نوشته شده. هیچ اندپوینت
+ دیگری ساخته نشود — تعرفه و پوشش بیمه اندپوینت آماده دارند.
+- **پاسخها:** `$this->success($item->toArray())` — بدون `['data' => ...]` که باعث double-nest میشود.
+ توجه: اندپوینتهای billing (`tenant-insurances`, `service-coverage`) **double-nested هستند** و در کد فعلی با
+ `(data as any)?.data?.data` خوانده میشوند؛ همان الگو را در صفحهٔ جدید تکرار کن.
+- **مالکیت/tenant:** الگوی چک مالکیت را از `updateItem` (`ClinicServiceController.php:205`) کپی کن؛ کاربر نباید
+ بتواند سرویس tenant دیگر را ببیند. یک تست خطا برای این حالت لازم است (۴۰۳/۴۰۴).
+- **تست (قانون پروژه، بدون استثنا):** هر وظیفه تست موفق + خطا + مرزی داشته باشد و تستها اجرا و سبز شوند:
+ - PHPUnit برای اندپوینت جدید: سرویس موجود، uuid ناموجود، سرویس متعلق به tenant دیگر.
+ - Vitest برای `parseUserNumber`, `PriceInput`, فیلد درصد پوشش، و رندر `ServiceDetailPage` (mock شدهٔ کوئریها).
+ - تست موجود `pages/ClinicServicesPage.test.tsx` بعد از حذف بلاک بیمه احتمالاً میشکند — بهروز شود.
+- **بررسی نهایی:** `ddev exec php bin/phpunit`، `yarn test`، `npx tsc --noEmit --project tsconfig.json`.
+- در گزارش پایانی صریح بنویس چه چیزی ساخته نشد و چرا (کالاهای مرتبط، لاگ تغییرات).
diff --git a/.claude/prompt/session-autofill-on-appointment-confirm.md b/.claude/prompt/session-autofill-on-appointment-confirm.md
new file mode 100644
index 00000000..ba0c80ef
--- /dev/null
+++ b/.claude/prompt/session-autofill-on-appointment-confirm.md
@@ -0,0 +1,124 @@
+# پرکردن خودکار پرونده (Session) هنگام قطعی شدن نوبت: تاریخ/ساعت مراجعه + آیتمهای هزینه + جمع کل
+
+## پروژه
+
+`clinicpro` (backend Symfony + پنل ادمین React). تک-ریپو.
+
+> تست: پنل ادمین با `09390039833 / 09390039833`. اجرا داخل ddev.
+
+## زمینه
+
+وقتی نوبت به وضعیت **قطعی/تأیید (`confirmed`)** تغییر میکند، `AppointmentController` تابع `PatientService::autoCreateOnAppointmentConfirm()` را صدا میزند تا برای بیمار یک پرونده (`PatientSession`) بسازد. اما مسیر auto-create فقط `new PatientSession($record, $appointment)` میسازد و ذخیره میکند — **هیچ دادهی هزینه یا زمان مراجعهای ثبت نمیشود**. در نتیجه پروندهی ساختهشده از نوبت: `session_at = null` (به created_at برمیگردد)، `visit_price_rials = 0`، `services_total_rials = 0`، `final_price_rials = 0` و لیست سرویسها خالی است — درحالیکه خودِ `Appointment` هم `visit_price_rials` و هم `serviceItems` را دارد. این یک باگ است.
+
+مسیر دیگر (ویزارد دستیِ ثبت مراجعه `createSession()`) همهی اینها را درست پر میکند و **الگوی مرجع** است.
+
+## هدف
+
+مسیر auto-create پرونده از نوبت باید مثل ویزارد، اینها را خودکار و **بدون ورود دستی قیمت** پر کند:
+1. **تاریخ/ساعت مراجعه** (`session_at`) از زمان واقعی نوبت (`Appointment::getSlotStart()`).
+2. **آیتمهای هزینهی سرویس** بهصورت تفکیکشده: برای هر سرویسِ نوبت یک `SessionService` با قیمت snapshot از خود سرویس (`ServiceItem::getPriceRials()`).
+3. **هزینه ویزیت** (`visit_price_rials`) از نوبت (و اگر نوبت مقدار نداشت، از تنظیم «قیمت ویزیت آزاد»).
+4. **جمع کل**: `services_total_rials` = مجموع خطوط سرویس؛ `final_price_rials` = `services_total_rials + visit_price_rials`.
+5. نمایش در UI پرونده: تاریخ/ساعت مراجعه بهعنوان اولین اطلاعات + جدول تفکیکشدهی هزینهها (ویزیت + هر سرویس) + جمع کل.
+
+## فایلهای مرتبط
+
+| فایل | نقش |
+|------|-----|
+| `src/Patient/Service/PatientService.php` | `autoCreateOnAppointmentConfirm()` (L94-135) و `autoCreateForEntity()` (L119-135) — **محل باگ**؛ `createSession()` (L137-237) الگوی مرجع |
+| `src/Patient/Entity/PatientSession.php` | Setterها: `setSessionAt()` (L204)، `setVisitPriceRials()` (L190)، `setServicesTotalRials()` (L193)، `setFinalPriceRials()` (L194)، `addService()` (L121) |
+| `src/Patient/Entity/SessionService.php` | خط هزینه؛ constructor قیمت را از `ServiceItem::getPriceRials()` snapshot میکند (L44-53)؛ `getLineTotalRials()` (L62) |
+| `src/ClinicService/Entity/ServiceItem.php` | `getPriceRials()` (L87) — منبع قیمت سرویس |
+| `src/Appointment/Entity/Appointment.php` | `getSlotStart()`، `getVisitPriceRials()` (L251)، `getServiceItems()` (L235، ManyToMany `appointment_service_items`) |
+| `src/Insurance/Service/VisitPriceRequirementResolver.php` | resolve تنظیم ویزیت (L22-37) — منبع fallback قیمت ویزیت آزاد |
+| `src/Insurance/Entity/EntityInsurancePricing.php` | ردیف free-visit (`insurance_id = null`): `getPatientShareRials()` (L54) = قیمت ویزیت آزاد پیشفرض |
+| `assets/admin/components/session/DetailsStep.tsx` | نمایش خلاصه پرونده — الان `session_at` و جدول تفکیکی ندارد |
+| `docs/api/patient.md`، `docs/api/appointment.md` | بهروزرسانی مستندات (Standing Rule) |
+
+## وضعیت فعلی (باگ)
+
+`src/Patient/Service/PatientService.php` — auto-create فقط میسازد و ذخیره میکند، بدون هیچ دادهای:
+
+```php
+private function autoCreateForEntity(string $entityType, int $entityId, Appointment $appointment, int $createdById): void
+{
+ if (!$this->subscriptionService->hasFeature($entityType, $entityId, 'patient_records')) {
+ return;
+ }
+ $patient = $appointment->getUser();
+ $record = $this->recordRepo->findByEntityAndUser($entityType, $entityId, $patient);
+ if ($record === null) {
+ $record = new PatientRecord($entityType, $entityId, $patient, 'system', $createdById);
+ $this->recordRepo->save($record);
+ }
+ $session = new PatientSession($record, $appointment); // ← هیچچیز دیگر ست نمیشود
+ $this->sessionRepo->save($session);
+}
+```
+
+الگوی مرجع در `createSession()` (چطور باید پر شود) — خطوط کلیدی:
+
+```php
+// visit price
+$session->setVisitPriceRials((int) ($data['visit_price_rials'] ?? 0));
+// session_at (زمان مراجعه)
+if (!empty($data['session_at'])) { $session->setSessionAt((int) $data['session_at']); }
+// خطوط سرویس + مجموع
+foreach ($data['services'] as $s) {
+ $item = $this->serviceItemRepo->findByUuid($s['uuid']);
+ $session->addService(new SessionService($session, $item, $staff, (int)($s['qty'] ?? 1)));
+}
+$session->setServicesTotalRials($servicesTotal);
+$session->setFinalPriceRials($servicesTotal + $visitPrice + ...);
+```
+
+## وظایف
+
+### ۱. پرکردن پروندهی auto-create از روی نوبت (backend)
+
+در `autoCreateForEntity()` بعد از ساخت `$session` و **قبل از** `save()`، از `$appointment` پر کن:
+
+```php
+$session = new PatientSession($record, $appointment);
+
+// ۱) زمان مراجعه = زمان واقعی نوبت
+$session->setSessionAt($appointment->getSlotStart());
+
+// ۲) هزینه ویزیت: از نوبت، fallback به «قیمت ویزیت آزاد» تنظیمات
+$visitPrice = $appointment->getVisitPriceRials()
+ ?? $this->resolveFreeVisitPrice($entityType, $entityId);
+$session->setVisitPriceRials($visitPrice ?? 0);
+
+// ۳) خطوط سرویس تفکیکشده (قیمت snapshot از خود سرویس)
+$servicesTotal = 0;
+foreach ($appointment->getServiceItems() as $item) {
+ $line = new SessionService($session, $item, null, 1); // قیمت از ServiceItem::getPriceRials()
+ $session->addService($line);
+ $servicesTotal += $line->getLineTotalRials();
+}
+
+// ۴) جمع کل
+$session->setServicesTotalRials($servicesTotal);
+$session->setFinalPriceRials($servicesTotal + ($visitPrice ?? 0));
+
+$this->sessionRepo->save($session);
+```
+
+- `resolveFreeVisitPrice($entityType, $entityId)`: یک helper که ردیف free-visit (`insurance_id = null`) را برای این doctor/clinic از `pricingRepo->findOneForInsurance(TYPE_DOCTOR|TYPE_CLINIC, $entityId, null)` میخواند و `getPatientShareRials()` را برمیگرداند (یا null). از منطق موجود `VisitPriceRequirementResolver` الگو بگیر.
+- **توجه به مسیر دوگانه**: `autoCreateOnAppointmentConfirm` این متد را هم برای `doctor` و هم (در صورت وجود) `clinic` صدا میزند، پس ممکن است **دو پرونده** ساخته شود (یکی برای پزشک، یکی برای کلینیک). این رفتار فعلی است؛ آن را تغییر نده، فقط هر دو را درست پر کن.
+- **قیمت دستی وارد نشود** — همیشه از `ServiceItem::getPriceRials()` و تنظیم ویزیت خوانده شود.
+
+### ۲. نمایش تاریخ/ساعت مراجعه + جدول هزینه تفکیکی در UI پرونده (frontend)
+
+در `assets/admin/components/session/DetailsStep.tsx`:
+- **اولین اطلاعات**: «تاریخ و ساعت مراجعه» از `session_at` (شمسی با `formatDateTime`/`formatDate` — Unix timestamp صحیح). اگر `session_at` خالی بود، از `created_at`.
+- **جدول هزینهی تفکیکشده**: یک ردیف «ویزیت: {formatRial(visit_price_rials)}» وقتی `visit_price_rials > 0`، سپس هر خط سرویس از آرایهی `services` (`service_name` + `line_total_rials`)، و در انتها «جمع کل: {final_price_rials}». از `toArray()` پرونده که `services`، `visit_price_rials`، `services_total_rials`، `final_price_rials`، `session_at` را میدهد استفاده کن.
+
+## نکات مهم
+
+- تاریخها Unix timestamp صحیح (`setSessionAt(int)`), نمایش شمسی با `formatDate`/`formatDateTime`.
+- قیمتها ریالی ذخیره؛ نمایش با `formatRial`. تبدیل تومان↔ریال با `tomanToRial`/`rialToToman`.
+- تغییری در `Appointment` یا schema لازم نیست (فقط خواندن)؛ **بدون migration** مگر بخواهی ستون audit اضافه کنی (لازم نیست).
+- تست: یک نوبت را از `pending` به `confirmed` ببر (`PATCH /api/v1/appointment/{uuid}/status`) و بررسی کن پروندهی ساختهشده `session_at`، `visit_price_rials`، خطوط سرویس و `final_price_rials` درست دارد (endpoint `GET /api/v1/patient/{recordUuid}/sessions`).
+- بعد از تغییر، `docs/api/patient.md` را اگر خروجی session تغییر معنایی کرد بهروز کن.
+- این پرامپت **پیشنیاز منطقی** پرامپت `discount-rules-engine.md` است (تخفیف روی `final_price_rials` اعمال میشود که اینجا درست میشود).
diff --git a/.claude/prompt/session-edit-payment-crud-audit-log.md b/.claude/prompt/session-edit-payment-crud-audit-log.md
new file mode 100644
index 00000000..a7e691f8
--- /dev/null
+++ b/.claude/prompt/session-edit-payment-crud-audit-log.md
@@ -0,0 +1,107 @@
+# ویرایش سرویسهای مراجعه + ویرایش/حذف پرداخت + Audit Log مالی جامع
+
+## پروژه
+
+`clinicpro` (backend Symfony + پنل ادمین React). تک-ریپو.
+
+> تست: پنل ادمین با `09390039833 / 09390039833`. اجرا داخل ddev. قرارداد پول: ذخیره/API ریال، UI تومان (`tomanToRial`/`rialToToman`). تاریخ Unix.
+
+## زمینه
+
+مراجعه (`PatientSession`) فقط **ایجاد** میشود؛ پس از ثبت، امکان ویرایش سرویسها/کالاها/قیمت ویزیت/بیمه وجود ندارد و پرداختهای ثبتشده نه ویرایش میشوند نه حذف. کاربر میخواهد بتواند همهی اینها را ویرایش کند، **اما** هر تغییر مالی/خدماتی باید در یک **Audit Log** ثبت و قابلمشاهده باشد (چه کسی، چه چیزی، کِی، مقدار قبل/بعد، نوع عملیات).
+
+## مشکل / هدف
+
+1. ویرایش کامل یک مراجعه پس از ثبت: سرویسها (افزودن/حذف/تعداد)، کالاهای مصرفی، قیمت ویزیت، بیمه/درصدها، یادداشت، تاریخ مراجعه — با محاسبهی مجدد `services_total_rials`/`final_price_rials`.
+2. ویرایش و حذف پرداختهای ثبتشده، با نگهداشتن سازگاری `paid_total`/`remaining`/`payment_method`/`paid_at`.
+3. **Audit Log جامع** برای همهی تغییرات مالی/خدماتی: مقدار قبل، مقدار بعد، کاربر، تاریخ/زمان، نوع عملیات (create/update/delete). نمایش تاریخچه در UI.
+
+## فایلهای مرتبط
+
+| فایل | نقش |
+|------|-----|
+| `src/Patient/Service/PatientService.php` | `createSession()` (create-only، L165-265)، `addSessionPayment()` (L338-386)، `calculateFinalPrice()` (L63-93) |
+| `src/Patient/Controller/PatientController.php` | `updateSession` PATCH (L1037-1092، فیلدهای محدود)، `addSessionPayment` POST (L1100-1122)؛ **بدون** endpoint ویرایش/حذف payment و ویرایش services |
+| `src/Patient/Entity/SessionService.php` | خط سرویس؛ **immutable** (بدون setter)؛ constructor snapshot قیمت |
+| `src/Patient/Entity/SessionConsumable.php` | خط کالا؛ immutable |
+| `src/Patient/Entity/SessionPayment.php` | پرداخت؛ فقط setter برای createdBy/Name؛ `METHODS` (L18)؛ toArray (L74-84) |
+| `src/Patient/Repository/SessionServiceRepository.php` / `SessionConsumableRepository.php` / `SessionPaymentRepository.php` | فقط `save()` — **بدون `remove()`** |
+| `src/Patient/Entity/PatientSession.php` | `getPaidTotalRials()` (L164-170)، `getRemainingRials()` (L173-176)؛ collections با `cascade:['remove']` |
+| `src/Appointment/Entity/AppointmentEvent.php` + Repository + endpoint | **الگوی مرجع audit** (id, uuid, FK, type, title, actor_user_id, actor_name, reason, created_at؛ `findByAppointmentUuid`؛ `GET /appointment/{uuid}/events`) |
+| `src/Settlement/Service/WalletService.php` | `resolveActorName(?User)` (L34-43) — نام نمایشی کاربر |
+| `src/Shared/Constant/ErrorCodes.php` | `ERR_SESSION_NOT_FOUND`, `ERR_SESSION_PAYMENT_INVALID`, `ERR_SESSION_PAYMENT_EXCEEDS` (L64-66) |
+| `assets/admin/components/SessionServiceCard.tsx` | dropdown «...» (L101-114) — محل افزودن «ویرایش» + «تاریخچه تغییرات» |
+| `assets/admin/pages/PatientDetailPage.tsx` | تب services (L225-248)؛ کارتها؛ `sessionsQ` |
+| `assets/admin/components/session/PaymentStep.tsx` | ردیف پرداختها (L250-270) — محل ویرایش/حذف + audit |
+| `assets/admin/components/session/CreateStep.tsx` | فرم ثبت (submit body L233-245) — **الگوی فرم ویرایش** |
+| `assets/admin/pages/NewSessionPage.tsx` | ویزارد ثبت — قابل بازاستفاده برای ویرایش |
+| `docs/api/patient.md` | مستندات |
+
+## وضعیت فعلی
+
+`updateSession` فیلدهای محدود میپذیرد (notes/archived/discount/paid_at/payment_method) — نه services/consumables/visit_price:
+
+```php
+// PatientController::updateSession (خلاصه)
+if (isset($data['notes'])) { $session->setNotes($data['notes']); }
+if (array_key_exists('archived', $data)) { $session->setArchived((bool)$data['archived']); }
+// discount_rule_uuid / discount_type / paid_at / payment_method ...
+// ← هیچ services / consumables / visit_price_rials
+```
+
+`createSession` تنها جایی است که `SessionService`/`SessionConsumable` ساخته و مجموع محاسبه میشود (create-only). پرداخت فقط `POST` دارد؛ `grep payments/{` صفر → نه PATCH نه DELETE.
+
+## وظایف
+
+### ۱. Entity + migration — `SessionAuditLog` (الگوی AppointmentEvent)
+
+`src/Patient/Entity/SessionAuditLog.php` (جدید):
+- ستونها: `id`, `uuid`, `session` (ManyToOne `PatientSession`, `onDelete: CASCADE`), `field` string (مثل `visit_price_rials`, `services`, `consumables`, `payment`, `discount`), `operation` string (`create`|`update`|`delete`), `old_value` text nullable, `new_value` text nullable, `actor_user_id` int nullable, `actor_name` string nullable, `note` string nullable, `created_at` int.
+- constructor `(PatientSession $session, string $field, string $operation)`؛ fluent `setActor(?int,?string)`, `setValues(?string $old, ?string $new)`, `setNote(?string)`.
+- `toArray()`: `field`, `operation`, `old_value`, `new_value`, `actor_name`, `note`, `created_at`.
+- Repository `SessionAuditLogRepository` با `save()` و `findBySessionUuid(string $uuid): array` (array hydration، مرتب بر `created_at DESC`).
+- migration.
+
+> مقادیر قبل/بعد را بهصورت رشتهی خوانا ذخیره کن (مثلاً برای پول ریال عددی؛ برای لیست سرویسها یک خلاصه مثل «تزریق ژل ×۱، لیزر ×۲» یا JSON فشرده). ثبات مهمتر از فرمت است.
+
+### ۲. Service — ثبت audit + منطق ویرایش/حذف
+
+در `PatientService`:
+- helper `logSessionChange(PatientSession $s, string $field, string $op, ?string $old, ?string $new, ?User $actor, ?string $note = null)` که `SessionAuditLog` میسازد و ذخیره میکند (`actor_name` با `walletService->resolveActorName`).
+- **`updateSessionServices(PatientSession $s, array $data, User $actor)`**: سرویسها/کالاها/قیمت ویزیت/بیمه را جایگزین کند:
+ - snapshot مقادیر قبل (visit_price, services خلاصه, consumables خلاصه, services_total, final_price).
+ - سرویسهای قبلی را `remove` (به `SessionServiceRepository` متد `remove()` اضافه کن)، سپس از `$data['services']` دوباره بساز (مثل createSession).
+ - همین برای consumables (`SessionConsumableRepository::remove()`).
+ - `setVisitPriceRials`, بیمه/درصدها، `session_at`, notes را ست کن.
+ - با `calculateFinalPrice(...)` + مجموع کالاها، `services_total_rials`/`final_price_rials` را بازمحاسبه کن (دقیقاً مثل createSession L213-240).
+ - برای هر فیلدِ تغییرکرده یک `logSessionChange(... 'update' ...)` با old/new بزن.
+- **`updatePayment(SessionPayment $p, array $data, User $actor)`**: `method`/`amount_rials`/`paid_at` را ویرایش کند (setterها را به `SessionPayment` اضافه کن). سقف: مجموع پرداختها نباید از `final - discount` بیشتر شود. audit با field=`payment`, op=`update`, old/new = مبلغ قبل/بعد.
+- **`deletePayment(SessionPayment $p, User $actor)`**: پرداخت را `remove` (به `SessionPaymentRepository::remove()`). audit op=`delete`, old=مبلغ.
+- **بازمحاسبهی فیلدهای کششده**: بعد از ویرایش/حذف پرداخت، اگر `getRemainingRials() > 0` بود `payment_method='pending'` و `paid_at=null`؛ اگر صفر شد `payment_method`/`paid_at` را ست کن. (چون `getPaidTotalRials()` از collection زنده جمع میزند ولی `payment_method`/`paid_at` کشاند.)
+- **wallet edge**: اگر پرداخت `wallet` بود، ویرایش/حذف باید تراکنش کیف پول را جبران کند (بازگشت/کسر تفاوت). اگر جبران خارج از scope است، **حذف/ویرایش پرداخت wallet را مسدود کن** (خطای ۴۲۲ با پیام فارسی) تا مغایرت مالی ایجاد نشود — این سادهتر و امنتر است؛ در پرامپت این گزینه را انتخاب کن مگر بازگشت کیف پول ساده باشد.
+
+### ۳. Controller — endpointهای جدید
+
+در `PatientController` (extends BaseController، owner-scope مثل `updateSession`):
+- **ویرایش services**: `updateSession` را گسترش بده تا اگر `services`/`consumables`/`visit_price_rials`/insurance آمد، `patientService->updateSessionServices()` صدا زده شود؛ یا یک route جداگانه `PATCH /api/v1/session/{uuid}/services`. (گسترش `updateSession` تمیزتر است.)
+- **ویرایش پرداخت**: `PATCH /api/v1/session/{uuid}/payments/{paymentUuid}` → `updatePayment`.
+- **حذف پرداخت**: `DELETE /api/v1/session/{uuid}/payments/{paymentUuid}` → `deletePayment`.
+- **تاریخچه**: `GET /api/v1/session/{uuid}/audit-log` → `$this->success($auditRepo->findBySessionUuid($uuid))`.
+- گارد: پرداخت باید متعلق به همان session باشد؛ session متعلق به owner (`ownsRecord`).
+
+### ۴. Frontend — فرم ویرایش + کنترل پرداخت + نمایش تاریخچه
+
+1. **منوی کارت** (`SessionServiceCard.tsx` dropdown L101-114): افزودن آیتمهای «ویرایش» و «تاریخچه تغییرات» (props جدید `onEdit(session)`, `onViewAudit(session)`).
+2. **فرم ویرایش**: از `CreateStep` بازاستفاده کن (یا کامپوننت مشترک) در حالت edit؛ با دادهی فعلی session پر شود و به `PATCH /session/{uuid}` (با body مثل CreateStep L233-245) بفرستد. مسیر `session/{uuid}/edit` یا مودال.
+3. **ردیف پرداخت** (`PaymentStep.tsx` L250-270): برای هر پرداخت آیکون ویرایش (مودال کوچک: مبلغ تومان + روش + تاریخ → `PATCH .../payments/{uuid}`) و حذف (`ConfirmDialog` → `DELETE`). بعد از هر عملیات `invalidate()`.
+4. **تاریخچه تغییرات**: یک مودال/بخش که `GET /session/{uuid}/audit-log` را میخواند و هر رکورد را نشان میدهد: نوع عملیات (ایجاد/ویرایش/حذف — رنگبندی)، فیلد، مقدار قبل → بعد، کاربر، تاریخ/زمان شمسی (`formatDateTime`). مرتب نزولی.
+
+## نکات مهم
+
+- **همهی مسیرهای تغییر باید audit بزنند**: ویرایش سرویس، کالا، قیمت ویزیت، مبلغ سرویسها، ویرایش/حذف پرداخت، تخفیف. حتی `applyDiscount`/`applyDiscountRule` موجود را هم به `logSessionChange` مجهز کن (field=`discount`).
+- تاریخها Unix؛ پول ریال (ذخیره) / تومان (UI). لیستهای admin array hydration.
+- Entity جدید + ستونها → migration (diff سپس migrate؛ خطوط drift نامرتبط را از migration پاک کن).
+- سازگاری مالی: بعد از هر ویرایش/حذف پرداخت، `paid_total`/`remaining`/`is_paid`/`paid_at` باید درست بمانند (بازمحاسبهی فیلدهای کششده).
+- **wallet**: تصمیم امن = مسدودکردن ویرایش/حذف پرداخت `wallet` مگر جبران کیف پول پیاده شود.
+- مستندات: `docs/api/patient.md` — endpointهای جدید (services edit، payment PATCH/DELETE، audit-log GET) با method/path/permission/body/response/errors.
+- این فیچر بزرگ و حساس مالی است — هر وظیفه (۱..۴) جدا پیاده، تست (شامل مسیر خطا/مرزی) و کامیت شود. Backend اول. بعد از کد `graphify update .` (بعد کامیت).
diff --git a/.claude/prompt/session-payment-currency-and-service-archive.md b/.claude/prompt/session-payment-currency-and-service-archive.md
new file mode 100644
index 00000000..57b380f5
--- /dev/null
+++ b/.claude/prompt/session-payment-currency-and-service-archive.md
@@ -0,0 +1,167 @@
+# رفع باگ واحد پول پرداخت + اطلاعات پرداختها + منوی سرویس + آرشیو مراجعات
+
+## پروژه
+
+`clinicpro` (backend Symfony + پنل ادمین React). تک-ریپو.
+
+> تست: پنل ادمین با `09390039833 / 09390039833`. اجرا داخل ddev.
+> قرارداد واحد پول پروژه (`utils.ts`): **واحد ذخیره/API = ریال**، **واحد نمایش/ورودی UI = تومان**. تبدیل با `tomanToRial` (×۱۰) و `rialToToman` (÷۱۰). نمایش با `formatRial(rial)` که خودش ÷۱۰ میکند و « تومان» میچسباند.
+
+## زمینه
+
+صفحه پرداخت مراجعه (`/admin/patients/{uuid}/session/{sessionUuid}/pay`) و صفحه خدمات بیمار (`/admin/patients/{uuid}?tab=services`) چند مشکل/کمبود دارند: باگ واحد پول در ثبت پرداخت (تومان بهعنوان ریال ذخیره میشود → یک صفر کم)، نمایش ناقص پرداختهای ثبتشده، نبود منوی عملیات روی هر مراجعه، و نبودِ قابلیت آرشیو مراجعات اشتباه.
+
+## فایلهای مرتبط
+
+| فایل | نقش |
+|------|-----|
+| `assets/admin/components/session/PaymentStep.tsx` | فرم پرداخت — **باگ واحد پول** (L83 discount fixed، L94 payment) + ردیف پرداختها (L252-260) |
+| `assets/admin/components/session/DetailsStep.tsx` | خلاصه مراجعه — ردیف پرداختها (L103-111) |
+| `assets/admin/lib/utils.ts` | `tomanToRial`/`rialToToman` (L4-11)، `formatRial` (L8)، `formatDateTime` (L38-46) |
+| `src/Patient/Entity/SessionPayment.php` | `toArray()` (L74-84) از قبل `paid_at` + `created_by_name` دارد — backend درست است |
+| `assets/admin/components/SessionServiceCard.tsx` | کارت مراجعه — آیکون «...» تزئینی (L81)، دکمه footer «مشاهده فاکتور»/«تکمیل پرداخت» (L99-119)، type `SessionPaymentEntry` (L5-11) |
+| `assets/admin/pages/PatientDetailPage.tsx` | تب services (L213-235)، fetch لیست (L140-150)، `viewInvoice` (L85-93)، `InvoiceSummaryModal` (L254) |
+| `src/Patient/Entity/PatientSession.php` | Entity مراجعه — **ستون `archived` ندارد** (باید افزوده شود)؛ `toArray()` (L221-265) |
+| `src/Patient/Repository/PatientSessionRepository.php` | `findByRecord` (L22-32) + `countByRecord` (L34-42) — بدون فیلتر archived |
+| `src/Patient/Controller/PatientController.php` | `sessions` GET (L912-934)، `updateSession` PATCH (L1036)، `sessionWithBilling` (L973-990) |
+| `docs/api/patient.md` | بهروزرسانی مستندات (Standing Rule) |
+
+---
+
+## تسک ۱ — رفع باگ واحد پول در ثبت پرداخت و تخفیف ثابت
+
+### وضعیت فعلی (باگ — frontend خالص)
+
+`PaymentStep.tsx` مبلغ تومانِ ورودی را **بدون** `tomanToRial` تحت کلید `amount_rials` میفرستد؛ backend همهجا ریال فرض میکند (`getRemainingRials`, wallet withdraw) و درست است. پس تومان خام بهعنوان ریال ذخیره میشود → یک صفر کم (÷۱۰ در نمایش).
+
+```tsx
+// L91-94 — payment
+const submitPayment = (method: string) => {
+ if (amount <= 0) return;
+ payMut.mutate({ method, amount_rials: amount, paid_at: isoToUnix(paymentDate) }); // ← amount تومان است
+};
+
+// L80-83 — discount (فقط حالت fixed مبلغ است؛ percent درصد است)
+const applyDiscount = () => {
+ if (!discountType || discountValue <= 0) return;
+ discountMut.mutate({ discount_type: discountType, discount_value: discountValue }); // ← fixed تومان است
+};
+```
+
+`utils.ts`: `tomanToRial = (t) => Math.round(t * 10)`.
+
+### وظایف
+
+1. در `submitPayment`، مبلغ را قبل از ارسال به ریال تبدیل کن:
+```tsx
+payMut.mutate({ method, amount_rials: tomanToRial(amount), paid_at: isoToUnix(paymentDate) });
+```
+2. در `applyDiscount`، فقط برای `discount_type === 'fixed'` مقدار را به ریال تبدیل کن (percent درصد است، تبدیل نشود):
+```tsx
+const value = discountType === 'fixed' ? tomanToRial(discountValue) : discountValue;
+discountMut.mutate({ discount_type: discountType, discount_value: value });
+```
+3. `tomanToRial` را از `../../lib/utils` import کن.
+4. **backend را تغییر نده** — تبدیل در backend باعث double-convert مسیر percent و سایر callerهای درست میشود (تخفیف دستی از قبل در `PatientService::applyDiscount` روی ریال کار میکند و از UI صفحه دیگر هم درست میآید؛ فقط این صفحه باگ دارد).
+
+### نکات
+
+- **edge case**: تخفیف بر اساس قانون (`discount_rule_uuid`) مبلغ را از backend میگیرد (نه UI) — دست نزن.
+- بعد از fix، یک پرداخت ۵۰۰٬۰۰۰ تومانی ثبت کن و تأیید کن در «پرداختشدهها» و مانده، مبلغ درست (۵۰۰٬۰۰۰ تومان) نمایش داده میشود، نه ۵۰٬۰۰۰.
+
+---
+
+## تسک ۲ — نمایش کامل پرداختهای ثبتشده (تاریخ/ساعت + ثبتکننده)
+
+### وضعیت فعلی
+
+`SessionPayment::toArray()` از قبل `paid_at` (Unix) و `created_by_name` را میدهد و type frontend (`SessionPaymentEntry`) هم دارد. اما ردیف نمایش فقط روش + مبلغ را نشان میدهد:
+
+```tsx
+// PaymentStep.tsx L252-260 و DetailsStep.tsx L103-111 (مشابه)
+{payments.map((p) => (
+
+ ...{METHOD_LABELS[p.method] ?? p.method}
+ مبلغ : {formatRial(p.amount_rials)}
+
+))}
+```
+
+### وظایف
+
+در **هر دو** `PaymentStep.tsx` و `DetailsStep.tsx`، ردیف پرداخت را کامل کن تا علاوه بر روش و مبلغ، اینها را هم نشان دهد:
+- **تاریخ و ساعت پرداخت**: `formatDateTime(p.paid_at)` (شمسی + HH:MM؛ از `utils.ts` import کن). اگر `paid_at` خالی بود، از `p.created_at`.
+- **ثبتکننده**: `p.created_by_name` (اگر موجود) — مثلاً «ثبت: {created_by_name}».
+
+چیدمان تمیز بماند (مثلاً خط دوم کوچکتر و کمرنگ زیر روش/مبلغ).
+
+### نکات
+
+- `formatDateTime` ورودی Unix ثانیه میگیرد؛ `paid_at`/`created_at` هر دو Unix صحیحاند.
+- backend تغییر نمیکند — داده از قبل موجود است.
+
+---
+
+## تسک ۳ — منوی «...» روی هر مراجعه: «مشاهده فاکتور» + «آرشیو»
+
+### وضعیت فعلی
+
+در `SessionServiceCard.tsx` آیکون `FilesServiceMore` (L81) **تزئینی** است — بدون onClick/منو. «مشاهده فاکتور» فقط بهصورت دکمه footer وقتی `paid` است وجود دارد؛ «آرشیو» اصلاً نیست.
+
+### وظایف
+
+1. آیکون «...» را به **dropdown trigger** تبدیل کن (منوی کوچک با کلیک، بستهشدن با کلیک بیرون). آیتمها:
+ - **مشاهده فاکتور** → همان `onViewInvoice(session)` که کارت از prop میگیرد (منطق `viewInvoice` در `PatientDetailPage` L85-93؛ اگر `invoice_uuid` نبود، ابتدا صادر و بعد باز میشود).
+ - **آرشیو** (یا «خروج از آرشیو» اگر `session.archived`) → یک prop جدید `onArchive(session, archived: boolean)` که کارت صدا میزند؛ در `PatientDetailPage` به mutation آرشیو (تسک ۴) وصل شود.
+2. props جدید کارت: `onArchive?: (session: SessionCardData, archived: boolean) => void`. type `SessionCardData` را با `archived?: boolean` گسترش بده.
+3. از الگوی dropdown موجود پروژه استفاده کن (اگر کامپوننت منوی مشترک هست از آن؛ وگرنه یک منوی ساده با `Portal`/absolute + بستن با کلیک بیرون، همراستا با بقیه).
+
+### نکات
+
+- «مشاهده فاکتور» در منو نباید دکمه footer را حذف کند مگر بخواهی یکدست کنی — کافی است در منو هم باشد.
+- برای مراجعهی بدون فاکتور، «مشاهده فاکتور» همان مسیر صدور idempotent را طی میکند (رفتار فعلی `viewInvoice`).
+
+---
+
+## تسک ۴ — آرشیو مراجعات (backend + UI فیلتر)
+
+### وضعیت فعلی
+
+`PatientSession` هیچ ستون `archived`/`status`/`deleted_at` ندارد. `findByRecord`/`countByRecord` بدون فیلتر همه را برمیگردانند. `sessions` GET پارامتر فیلتر ندارد.
+
+### وظایف (Backend اول)
+
+1. **Entity + migration**: به `PatientSession` ستون `archived` (bool، default false) و `archived_at` (int nullable، Unix) اضافه کن؛ getter/setter (`isArchived`, `setArchived(bool)` که `archived_at = archived ? time() : null` را ست کند). در `toArray()` کلید `archived` را expose کن. `make:migration` + `migrate`.
+2. **Repository**: `findByRecord`/`countByRecord` یک پارامتر فیلتر بگیرند: `all` | `active` | `archived` (پیشفرض `active`). `active` → `s.archived = false`، `archived` → `s.archived = true`، `all` → بدون شرط.
+3. **`sessions` GET**: پارامتر query `filter` (پیشفرض `active`) را بخوان و به repo بده. پس **پیشفرض آرشیوها نمایش داده نشوند**.
+4. **endpoint آرشیو**: در `updateSession` (`PATCH /api/v1/session/{uuid}`) پذیرش فیلد `archived` (bool) → `session->setArchived((bool)$data['archived'])`. (یا اگر تمیزتر است یک route اختصاصی `PATCH /api/v1/session/{uuid}/archive`.) owner-scope مثل بقیهی `updateSession`.
+
+### وظایف (Frontend)
+
+5. **دکمه/فیلتر نمایش آرشیو**: در تب services (`PatientDetailPage.tsx` L213-235) دکمه فیلتر تزئینی موجود (`TurnsFilter`) را فعال کن یا یک segmented/دکمه «نمایش آرشیو» اضافه کن؛ یک state `filter: 'active' | 'all' | 'archived'` (پیشفرض `active`). query key و URL شامل filter شود:
+```tsx
+const [filter, setFilter] = useState<'active'|'all'|'archived'>('active');
+const sessionsQ = useQuery({
+ queryKey: ['patient-sessions', uuid, filter],
+ queryFn: () => api.get(`/api/v1/patient/${uuid}/sessions?filter=${filter}`),
+ enabled: !!uuid,
+});
+```
+6. **اکشن آرشیو**: `onArchive` (تسک ۳) به یک mutation وصل شود که `PATCH /api/v1/session/{uuid}` با `{ archived: true/false }` میزند و `['patient-sessions', uuid]` را invalidate میکند. toast مناسب («مراجعه آرشیو شد» / «از آرشیو خارج شد»).
+7. کارت آرشیوشده در حالت نمایش آرشیو یک نشانهی بصری داشته باشد (مثلاً badge «آرشیو» یا کمرنگ).
+
+### نکات
+
+- تاریخها Unix صحیح؛ لیستهای admin طبق قانون. تغییر Entity → migration.
+- **سوابق حفظ شود**: آرشیو فقط مخفی میکند (soft)، حذف نیست؛ فاکتور و پرداختها دستنخورده میمانند.
+- بعد از تغییر API، `docs/api/patient.md` را بهروز کن (پارامتر `filter` روی `sessions`، فیلد `archived` روی `updateSession`/entity).
+- edge case: آرشیو کردن مراجعهی تسویهشده مجاز است (فقط مخفی میشود)؛ گزارشهای مالی نباید آرشیوها را از سابقه حذف کنند (فقط لیست پیشفرض این صفحه فیلتر شود).
+
+---
+
+## قوانین عمومی
+
+- کنترلرها از `BaseController`؛ پاسخها `$this->success()`/`$this->paginated()`/`$this->error()`.
+- تاریخها Unix؛ قیمتها ریال (ذخیره/API)، تومان (UI) با `tomanToRial`/`rialToToman`.
+- TanStack Query + الگوهای موجود؛ selectها `SearchableSelect`.
+- هر تسک جدا تست و کامیت شود. Backend اول در تسک ۴. بعد از کد، `graphify update .` (بعد کامیت).
diff --git a/.claude/prompt/settings-clinic-doctors-tab.md b/.claude/prompt/settings-clinic-doctors-tab.md
new file mode 100644
index 00000000..d18f312b
--- /dev/null
+++ b/.claude/prompt/settings-clinic-doctors-tab.md
@@ -0,0 +1,206 @@
+# انتقال مدیریت کلینیک و پزشکان کلینیک به یک تب مجزا در تنظیمات (نقشمحور)
+
+## پروژه
+
+`clinicpro` (پنل ادمین React + یک اصلاح کوچک permission در Backend Symfony — همان ریپو، cross-repo نیست).
+
+## زمینه
+
+کاربری که بهعنوان **مدیر/مالک کلینیک** ثبتنام میکند `primaryRole === 'clinic'` میگیرد (برچسب «مالک کلینیک»). امروز در منوی تنظیمات یک تب به نام **«مدیریت مطب»** وجود دارد (`key: 'clinic'`) که به `/admin/my-clinic` میرود؛ آن صفحه بلافاصله به `/admin/clinics/{dbUuid}` = `ClinicDetailPage` **ریدایرکت** میکند. یعنی با کلیک روی تب تنظیمات، کاربر از پوستهی تنظیمات (`SettingsLayout`) خارج میشود و به یک صفحهی جنریک ادمین (همان صفحهای که مدیرکل برای هر کلینیک میبیند) پرتاب میشود. این صفحه هم مدیریت اطلاعات کلینیک و هم مدیریت پزشکانِ کلینیک (لیست/دعوت/تعلیق/حذف دعوتنامه/جداسازی پزشک) را در خود دارد.
+
+دو مشکل:
+1. مدیریت پزشکانِ کلینیک بهجای اینکه یک تب مستقل و تمیز داخل تنظیمات باشد، داخل یک صفحهی بزرگ ادمین قاطی شده و کاربر را از تنظیمات بیرون میبرد.
+2. تب «مدیریت مطب» در **sidebar دسکتاپِ تنظیمات** (`PurchaseSubscriptionSidebar`) اصلاً نقشمحور نیست و برای همه (از جمله پزشک مهمان) نمایش داده میشود — این در `SettingsLayout.test.tsx` هم بهعنوان رفتار فعلی ثبت شده.
+
+## هدف
+
+1. یک **تب مجزا** در تنظیمات به نام **«پزشکان کلینیک»** ساخته شود که کل مدیریت کلینیک و پزشکانِ کلینیک را **داخل پوستهی تنظیمات** (`SettingsLayout`) در دسترس بگذارد.
+2. تب فعلی **«مدیریت مطب»** بهطور کامل از هر دو منوی تنظیمات حذف شود (موبایل: `SETTINGS_MENU`؛ دسکتاپ: `PurchaseSubscriptionSidebar`).
+3. تمام امکانات مدیریت کلینیک + پزشکانِ کلینیک از همان تب جدید در دسترس باشد.
+4. کنترل دسترسی نقشمحور:
+ * **مدیر کلینیک** (`primaryRole === 'clinic'`): افزودن، ویرایش، حذف/جداسازی و مدیریت کامل پزشکانِ کلینیک.
+ * **پزشک** (`primaryRole === 'doctor'`): این تب مدیریتی را **اصلاً نبیند** و به تنظیمات مدیریتی کلینیک دسترسی نداشته باشد؛ فقط بخشهای مربوط به خودش (پروفایل/نوبتدهی/دعوتنامههای دریافتی خودش که جای دیگری است).
+
+## فایلهای مرتبط
+
+| فایل | نقش |
+|------|-----|
+| `assets/admin/components/layout/SettingsLayout.tsx` | منبع حقیقت `SETTINGS_MENU` (منوی موبایل) + `menuForRole()` + پوستهی تنظیمات |
+| `assets/admin/components/layout/PurchaseSubscriptionSidebar.tsx` | sidebar دسکتاپِ تنظیمات؛ `NAV_ITEMS` مستقل و **بدون نقشگِیت** |
+| `assets/admin/pages/MyClinicPage.tsx` | تب فعلی «مدیریت مطب» → فقط ریدایرکت به `ClinicDetailPage` |
+| `assets/admin/pages/ClinicDetailPage.tsx` | صفحهی جنریک ادمین؛ بلوک «پزشکان + دعوتنامهها» (خطوط ~۴۷۶–۹۹۰) منبع کد قابلاستخراج |
+| `assets/admin/App.tsx` | جدول route؛ `RoleRoute` (خط ۱۱۷) و route `my-clinic` (خط ۱۹۱) |
+| `assets/admin/stores/authStore.ts` | `primaryRole`, `dbUuid`, `context` (`ContextItem.scope`) |
+| `assets/admin/components/layout/SettingsLayout.test.tsx` | تست رفتار فعلی نمایش «مدیریت مطب» |
+| `assets/admin/pages/SettingsMenuPage.test.tsx` | تست منوی موبایل |
+| `assets/admin/components/layout/Sidebar.tsx` (خط ~۲۰۶) | لینک `/admin/my-clinic` در نویگیشن اصلی |
+| `src/Clinic/Controller/ClinicController.php` (خط ۳۵۵–۳۵۶) | endpoint جداسازی پزشک — **`ROLE_ADMIN` only** |
+| `src/ClinicInvitation/Controller/ClinicInvitationController.php` | endpointهای دعوت/لیست/تعلیق/حذف دعوتنامه (`IS_AUTHENTICATED_FULLY`) |
+
+## وضعیت فعلی
+
+### منوی تنظیمات موبایل — `SettingsLayout.tsx`
+
+```tsx
+export const SETTINGS_MENU: SettingsMenuItem[] = [
+ { key: 'subscription', label: 'خرید اشتراک', icon: CreditCardIcon, to: '/admin/subscription' },
+ { key: 'doctor', label: 'مدیریت پزشک', icon: UserIcon, to: '/admin/profile', roles: ['doctor'] },
+ { key: 'appointment', label: 'مدیریت نوبت دهی', icon: CalendarDaysIcon, to: '/admin/appointment-settings', roles: ['doctor'] },
+ { key: 'clinic', label: 'مدیریت مطب', icon: BuildingOffice2Icon, to: '/admin/my-clinic', roles: ['clinic'] },
+ // ...
+];
+export function menuForRole(role: string | null | undefined): SettingsMenuItem[] {
+ return SETTINGS_MENU.filter((i) => !i.roles || (role != null && i.roles.includes(role)));
+}
+```
+
+### sidebar دسکتاپ — `PurchaseSubscriptionSidebar.tsx` (بدون نقشگِیت!)
+
+```tsx
+const NAV_ITEMS: NavItem[] = [
+ // ...
+ { key: 'clinic', label: 'مدیریت مطب', to: '/admin/my-clinic' }, // برای همهی نقشها دیده میشود
+ // ...
+];
+export default function PurchaseSubscriptionSidebar({ active }: { active: string }) { /* هیچ نقشی نمیگیرد */ }
+```
+
+### تب فعلی — `MyClinicPage.tsx` (فقط ریدایرکت، از تنظیمات خارج میشود)
+
+```tsx
+function MyClinicPageContent() {
+ const { dbUuid, fetchMe } = useAuthStore();
+ const navigate = useNavigate();
+ useEffect(() => {
+ if (dbUuid) navigate(`/admin/clinics/${dbUuid}`, { replace: true }); // ← به ClinicDetailPage میپرد
+ // ...
+ }, [dbUuid, fetchMe, navigate]);
+ // ...
+}
+```
+
+### route فعلی — `App.tsx:191`
+
+```tsx
+ } />
+```
+
+### endpointهای موجود مدیریت پزشکانِ کلینیک (از `ClinicDetailPage.tsx`)
+
+```
+GET /api/v1/clinic/doctor-list/{uuid} لیست پزشکان کلینیک
+GET /api/v1/admin/clinic/{uuid}/invitations?limit=50 لیست دعوتنامهها
+POST /api/v1/admin/clinic/{uuid}/invite-doctor دعوت پزشک (InviteDoctorModal)
+POST /api/v1/admin/clinic/invitation/{invUuid}/resend ارسال مجدد
+PATCH /api/v1/admin/clinic/invitation/{invUuid}/status تعلیق/فعال
+DELETE /api/v1/admin/clinic/invitation/{invUuid} حذف دعوتنامه
+DELETE /api/v1/admin/clinic/{uuid}/doctor/{doctorUuid} جداسازی پزشک ← ROLE_ADMIN only ⚠️
+```
+
+### مشکل permission در Backend — `ClinicController.php:355`
+
+```php
+#[Route('/api/v1/admin/clinic/{clinicUuid}/doctor/{doctorUuid}', methods: ['DELETE'])]
+#[IsGranted('ROLE_ADMIN')] // ← مالک کلینیک نمیتواند پزشک را جدا کند
+public function detachDoctor(...) { ... }
+```
+
+## وظایف
+
+### ۱. Backend — اجازهی جداسازی پزشک به مالک کلینیک
+
+هدف task شماره ۴ این است که مدیر کلینیک بتواند پزشک را حذف/جدا کند، ولی endpoint جداسازی الان `ROLE_ADMIN` است.
+
+- در `src/Clinic/Controller/ClinicController.php` متد `detachDoctor` (خط ۳۵۵): گارد `#[IsGranted('ROLE_ADMIN')]` را به `#[IsGranted('IS_AUTHENTICATED_FULLY')]` تغییر بده و **داخل متد/سرویس یک بررسی مالکیت** اضافه کن: کاربر فعلی یا ادمین باشد یا مالک همان کلینیک (`clinicUuid`). اگر نه → `throw new AppException(ErrorCodes::ERR_FORBIDDEN, null, 403)`.
+- **اول بگرد**: احتمالاً همین الگوی بررسی مالکیت در `invite-doctor`/`invitations` (که `IS_AUTHENTICATED_FULLY` هستند) در سرویس `ClinicInvitation` وجود دارد — همان helper را دوباره استفاده کن، کد جدید ننویس.
+- endpointهای دعوتنامه را هم بررسی کن که مالک کلینیک (نه فقط ادمین) بتواند صدایشان بزند؛ اگر بررسی مالکیت ندارند، همان helper را اضافه کن.
+- پس از تغییر، فایل `docs/api/clinic.md` (و در صورت لزوم مستندِ ClinicInvitation) را در همین session بهروزرسانی کن (Standing Rule).
+- تست: PHPUnit برای سه حالت — مالک کلینیک (موفق)، پزشک/کاربر غیرمالک (۴۰۳)، ادمین (موفق).
+
+> اگر بررسی مالکیت روی این endpointها از قبل بهشکل کامل وجود دارد، فقط گارد `ROLE_ADMIN` را شل کن و دلیلش را در توضیح PR/commit بنویس.
+
+### ۲. استخراج بلوک مدیریت پزشکان به یک کامپوننت مشترک (SOLID)
+
+`ClinicDetailPage.tsx` بلوک «پزشکان + دعوتنامهها» را در خطوط ~۸۴۷–۹۹۰ دارد (tab پزشکان/دعوتنامهها، دعوت، ارسال مجدد، تعلیق، حذف دعوتنامه، جداسازی پزشک، `InviteDoctorModal`، `ConfirmDialog` جداسازی). این منطق نباید کپی شود.
+
+- یک کامپوننت جدید بساز: `assets/admin/components/ClinicDoctorsManager.tsx` با prop `clinicUuid: string` و `readOnly?: boolean`.
+- تمام state/queryها/mutationهای مربوط به `doctorsQ`, `invitationsQ`, `resendInvMut`, `changeInvStatusMut`, `deleteInvMut`, `detachDoctorMut`, `inviteOpen`, `detachDoctorConfirm` را به این کامپوننت منتقل کن (از `ClinicDetailPage` بردار).
+- `ClinicDetailPage.tsx` را ریفکتور کن تا همین کامپوننت مشترک را با `clinicUuid={uuid}` رندر کند (رفتار صفحهی ادمین نباید تغییر کند).
+- endpointها و envelopeها دقیقاً همانهای فعلی (`data?.data ?? raw`).
+
+### ۳. صفحهی تنظیماتِ تب جدید — `ClinicDoctorsPage.tsx`
+
+- فایل جدید: `assets/admin/pages/ClinicDoctorsPage.tsx`.
+- `dbUuid` مالک کلینیک را از `useAuthStore` بگیر (اگر خالی بود `fetchMe()` مثل `MyClinicPage`). سپس **داخل** `SettingsLayout` رندر کن — نه ریدایرکت:
+
+```tsx
+export default function ClinicDoctorsPage() {
+ const { dbUuid, fetchMe } = useAuthStore();
+ useEffect(() => { if (!dbUuid) fetchMe(); }, [dbUuid, fetchMe]);
+ return (
+
+ {dbUuid
+ ?
+ :
+
در حال بارگذاری اطلاعات کلینیک...
+
}
+
+ );
+}
+```
+
+- اگر میخواهی ویرایش اطلاعات کلینیک (نام/تلفن/تخصصها/بیمه/گالری/آدرس) هم زیر همین تب باشد (task: «تمام امکانات مدیریت کلینیک»)، یک دکمه/لینک «ویرایش اطلاعات کلینیک» به همان `ClinicDetailPage` بگذار یا آن بلوکها را هم به کامپوننت مشترک اضافه کن. **پیشنهاد:** برای این iteration فقط مدیریت پزشکان + دعوت را داخل تب بیاور و ویرایش اطلاعات کلینیک را با یک لینک به صفحهی موجود نگهدار تا صفحهی تنظیمات سبک بماند؛ اگر کاربر مدیریت کامل خواست، در وظیفهی جدا انجام شود.
+
+### ۴. route جدید + حذف route قدیمی — `App.tsx`
+
+- route جدید (بهجای/کنار `my-clinic`) با گارد نقش:
+
+```tsx
+
+
+
+ }
+/>
+```
+
+- `RoleRoute` قبلاً `roles=['clinic']` را چک میکند و با `blockClinicScope` پزشکِ مهمان در scope کلینیک را هم رد میکند — همین برای task شماره ۴ کافی است (پزشک به تب مدیریتی نمیرسد).
+- route قدیمی `my-clinic` و `MyClinicPage` را حذف کن؛ اگر لینک قدیمی ممکن است جایی باز شود، یک ریدایرکت از `my-clinic` به `settings/clinic-doctors` بگذار.
+
+### ۵. بهروزرسانی هر دو منوی تنظیمات
+
+- در `SettingsLayout.tsx` آیتم `{ key:'clinic', label:'مدیریت مطب', ... }` را حذف و جایگزین کن با:
+
+```tsx
+{ key: 'clinic-doctors', label: 'پزشکان کلینیک', icon: BuildingOffice2Icon, to: '/admin/settings/clinic-doctors', roles: ['clinic'] },
+```
+
+- در `PurchaseSubscriptionSidebar.tsx`:
+ - آیتم `{ key:'clinic', label:'مدیریت مطب', to:'/admin/my-clinic' }` را حذف و با `{ key:'clinic-doctors', label:'پزشکان کلینیک', to:'/admin/settings/clinic-doctors' }` جایگزین کن.
+ - این sidebar را **نقشمحور** کن: `primaryRole` را از `useAuthStore` بگیر و `NAV_ITEMS` را با همان منطق `menuForRole` فیلتر کن (به `NavItem` فیلد اختیاری `roles?: string[]` اضافه کن و به آیتم `clinic-doctors` بده `roles: ['clinic']`، به آیتم `doctor`/`appointment` هم `roles:['doctor']` مطابق `SETTINGS_MENU`). این باعث میشود پزشک تب «پزشکان کلینیک» را در دسکتاپ هم نبیند (رفع باگ فعلی).
+
+### ۶. اصلاح لینک نویگیشن اصلی — `Sidebar.tsx`
+
+- خط ~۲۰۶ که `/admin/my-clinic` میسازد را به `/admin/settings/clinic-doctors` تغییر بده (فقط برای نقش `clinic`). منطق نقش همانجا را حفظ کن.
+
+### ۷. تستها
+
+- `SettingsLayout.test.tsx`: تست فعلی که انتظار دارد «مدیریت مطب» دیده شود را بهروز کن — حالا:
+ * برای `role='clinic'` باید «پزشکان کلینیک» دیده شود و «مدیریت مطب» **نباشد**.
+ * برای `role='doctor'` باید «پزشکان کلینیک» **دیده نشود** (چون دسکتاپ حالا نقشمحور است — کامنت قدیمیِ «desktop sidebar is not role-gated» را هم اصلاح کن).
+- `SettingsMenuPage.test.tsx`: `menuForRole('doctor')` نباید `clinic-doctors` بدهد؛ `menuForRole('clinic')` باید بدهد.
+- تست جدید برای `ClinicDoctorsManager` (رندر لیست پزشکان از mock، نمایش دکمههای مدیریت وقتی `readOnly` نیست).
+- `yarn test` و `npx tsc --noEmit` باید سبز شوند.
+
+## نکات مهم
+
+- **نقشها:** `admin` (مدیرکل) · `clinic` (مالک/مدیر کلینیک — برچسب «مالک کلینیک») · `doctor` · `secretary` · `representation` · `user`. «مدیر کلینیک» در این سیستم = `primaryRole === 'clinic'`. پزشکِ مهمانِ دعوتشده = `doctor` با `context.scope === 'clinic'` که با `blockClinicScope` در `RoleRoute` رد میشود.
+- **envelope:** لیست پزشکان و دعوتنامهها با `data?.data ?? raw` استخراج میشوند (double-nest احتمالی). دقیقاً از الگوی فعلی `ClinicDetailPage` کپی کن، تغییر نده.
+- **تاریخها:** timestampهای Unix (مثل `expires_at`)؛ انقضا با `Date.now()/1000 > inv.expires_at` سنجیده میشود — همین را نگهدار.
+- **SearchableSelect:** طبق قانون پروژه هر جا `select` لازم شد از `SearchableSelect` استفاده کن، نه `` بومی.
+- **رشتهها فارسی، RTL.** دکمهها/بجها/آیکنها از همان کلاسهای موجود (`btn`, `badge`, `mini-btn`, `seg`).
+- **گارد سطح UI کافی نیست:** چون پزشک نباید بتواند مدیریت کند، هم UI را گِیت کن (`RoleRoute` + منوی نقشمحور) و هم Backend را (وظیفهی ۱). بدون وظیفهی ۱، دکمهی «جداسازی پزشک» برای مالک کلینیک ۴۰۳ میدهد.
+- **SOLID:** منطق مدیریت پزشکان فقط در `ClinicDoctorsManager` باشد؛ نه در `ClinicDetailPage` کپی بماند نه در `ClinicDoctorsPage` دوباره نوشته شود.
+- **بعد از اتمام:** `graphify update .` برای بهروز نگهداشتن گراف (طبق قانون پروژه).
diff --git a/.claude/prompt/settings-menu-pixel-perfect.md b/.claude/prompt/settings-menu-pixel-perfect.md
new file mode 100644
index 00000000..2056c548
--- /dev/null
+++ b/.claude/prompt/settings-menu-pixel-perfect.md
@@ -0,0 +1,159 @@
+# پیکسلبهپیکسل کردن منوی تنظیمات + ریشهگرفتن رنگها از شخصیسازی
+
+## پروژه
+
+`clinicpro` (فقط پنل ادمین React — تغییر backend ندارد).
+
+اپ دسکتاپ `clinic-pro-tauri` فقط **مرجع بصری** است، نه هدف تغییر. صفحهی مرجع:
+`http://127.0.0.1:5170/setting/purchase-subscription`
+(کد آن: `clinic-pro-tauri/src/components/setting/listMenu/purchaseSubscription/PurchaseSubscriptionSidebar.jsx`)
+
+## زمینه
+
+پنل ادمین `clinicpro` یک ناحیهی «تنظیمات» دارد که کنار محتوا یک سایدمنوی مشترک (`PurchaseSubscriptionSidebar`) نمایش میدهد. این منو در همهی صفحات تنظیمات از طریق `SettingsLayout` رندر میشود.
+
+- صفحهی `/admin/subscription` (`SubscriptionPage.tsx`) درست و پیکسلبهپیکسل است — **مرجع درونپروژهای**.
+- صفحهی `/admin/appointment-settings` (`AppointmentSettingsPage.tsx`) **خراب** است: محتوا روی زمینهی خاکستری `#fafafa` نشسته، پنل سفید ندارد و فاصلهها با مرجع فرق دارد.
+
+## مشکل / هدف
+
+سه اصلاح مشخص:
+
+1. **رنگها باید از شخصیسازی ریشه بگیرند.** رنگ ردیف فعال منو در `PurchaseSubscriptionSidebar.tsx` بهصورت هاردکد `#f17732` است. باید از متغیر برند شخصیسازی (`var(--accent)`) استفاده کند تا با «رنگ اصلی» انتخابشده در پنل شخصیسازی (`Topbar.tsx` → `setBrandHue`) هماهنگ شود.
+
+2. **محتوای appointment-settings باید روی پنل سفید `#ffffff` بنشیند، نه `#fafafa`.** مرجع (tauri و نیز `SubscriptionPage.tsx`) محتوا را داخل یک پنل با `background: var(--surface)` (سفید) میگذارد. در appointment-settings محتوا مستقیم روی `--bg` صفحه (`#fafafa`) است. `#fafafa` رنگِ **ناحیهی منو** است، نه محتوا.
+
+3. **منو باید چسبیده به نوارِ کناری اصلی (سمت راست) باشد.** در مرجع tauri منوی تنظیمات فلاش به سایدبار اصلی میچسبد؛ در clinicpro بهخاطر padding محتوا فاصله افتاده.
+
+## فایلهای مرتبط
+
+| فایل | نقش |
+|------|-----|
+| `assets/admin/components/layout/PurchaseSubscriptionSidebar.tsx` | سایدمنوی مشترک تنظیمات — رنگ ردیف فعال اینجاست |
+| `assets/admin/pages/AppointmentSettingsPage.tsx` | صفحهی خراب که باید پیکسلبهپیکسل شود |
+| `assets/admin/pages/SubscriptionPage.tsx` | **مرجع درست** — الگوی wrap محتوا در پنل سفید |
+| `assets/admin/components/layout/SettingsLayout.tsx` | شل تنظیمات (grid: `[248px_minmax(0,1fr)] gap-5`) — محل اصلاح فلاششدن |
+| `assets/admin/styles.css` | تعریف متغیرها: `--surface:#ffffff`، `--bg:#fafafa`، `--accent:#f0682a`، `--gap:20px`، `.content{padding:var(--gap);max-width:1480px;margin:0 auto}` |
+| `clinic-pro-tauri/.../PurchaseSubscriptionSidebar.jsx` | مرجع بصری (تغییر نده) |
+
+## وضعیت فعلی
+
+### ۱) رنگ هاردکد در سایدمنو — `PurchaseSubscriptionSidebar.tsx`
+
+```tsx
+const rowStyle: React.CSSProperties = {
+ borderRadius: 12, height: 44, marginBottom: 8,
+ display: 'flex', alignItems: 'center', justifyContent: 'flex-start',
+ padding: '0 14px', fontSize: 16, fontWeight: isActive ? 700 : 500,
+ lineHeight: 1, textAlign: 'right',
+ background: isActive ? '#f17732' : 'transparent', // ← هاردکد
+ color: isActive ? '#FFFFFF' : 'var(--text-2)',
+ cursor: item.to ? 'pointer' : 'not-allowed',
+ transition: 'background .14s',
+};
+// ...
+onMouseEnter={(e) => { if (!isActive) (e.currentTarget as HTMLElement).style.background = 'var(--surface-2)'; }}
+onMouseLeave={(e) => { if (!isActive) (e.currentTarget as HTMLElement).style.background = 'transparent'; }}
+```
+
+### ۲) صفحهی خراب — `AppointmentSettingsPage.tsx` (بازگشت فعلی)
+
+```tsx
+return (
+
+ {/* ← fade-in تکراری؛ SettingsLayout خودش fade-in دارد */}
+
مدیریت نوبت دهی
+
+ {!uuid ? (
+
این بخش فقط برای پزشک در دسترس است.
+ ) : isLoading ? (
+
در حال بارگذاری...
+ ) : (
+
{/* ← مستقیم روی #fafafa */}
+ )}
+
+
+);
+```
+
+### مرجع درست — `SubscriptionPage.tsx` (محتوا داخل پنل سفید)
+
+```tsx
+return (
+
+ {/* ← پنل سفید #ffffff */}
+
+ {/* محتوا */}
+
+
+
+);
+```
+
+## وظایف
+
+### ۱. رنگ ردیف فعال سایدمنو را از شخصیسازی بگیر
+
+در `PurchaseSubscriptionSidebar.tsx`:
+
+- `background: isActive ? '#f17732'` → `background: isActive ? 'var(--accent)'`
+- بررسی کن که در حالت hover و active در تم تیره هم درست باشد (متغیرهای `--accent`، `--surface-2` هر دو تم را در `styles.css` پوشش میدهند — نیازی به `.dark &` جدا نیست).
+- رنگ متن ردیف فعال `#FFFFFF` بماند (روی برند خوانا است).
+
+### ۲. محتوای appointment-settings را داخل پنل سفید بگذار و ساختار را مثل مرجع کن
+
+در `AppointmentSettingsPage.tsx`:
+
+- `` تکراری را حذف کن (چون `SettingsLayout` خودش `fade-in` دارد) و بهجای آن دقیقاً الگوی `SubscriptionPage` را بهکار ببر:
+
+```tsx
+return (
+
+
+
مدیریت نوبت دهی
+
+ {!uuid ? (
+
+ این بخش فقط برای پزشک در دسترس است.
+
+ ) : isLoading ? (
+
در حال بارگذاری...
+ ) : (
+
+ )}
+
+
+);
+```
+
+- بعد از تغییر، چشمی چک کن که `WeeklyScheduleTab` و `FreeVisitPrice` روی سفید (`--surface`) بنشینند و padding/فاصلهها با `SubscriptionPage` یکی باشد. اگر `WeeklyScheduleTab` خودش زمینه/کارت داخلی دارد، مطمئن شو با پنل سفید بیرونی دوبارکارت (double-card) نمیشود.
+
+### ۳. منوی تنظیمات را فلاش به نوار کناری اصلی بچسبان
+
+مشکل: `.content` در `styles.css` دارای `padding: var(--gap)` (۲۰px) و `max-width:1480px; margin:0 auto` است؛ همین padding سمت راست، بین نوار کناری اصلی و منوی تنظیمات فاصله میاندازد.
+
+در `SettingsLayout.tsx` کاری کن ستون منو تا لبهی نوار کناری اصلی کشیده شود؛ گزینهی پیشنهادی: یک negative margin سمت راست روی ریشهی `SettingsLayout` برابر `--gap` تا padding محتوا خنثی شود، بدون شکستن `max-width` بخش محتوا:
+
+```tsx
+return (
+
+);
+```
+
+- **مهم:** RTL است؛ نوار کناری اصلی سمت راست است و منوی تنظیمات هم سمت راستِ گرید. جهت negative margin را با تست چشمی درست کن (`marginInlineStart`/`marginInlineEnd`) تا منو دقیقاً به نوار کناری بچسبد و لبهی چپِ محتوا از قاب بیرون نزند.
+- چون `SettingsLayout` مشترک است، بعد از این تغییر **همهی صفحات تنظیمات** (subscription، appointment، tags، insurance، …) را چک کن که همزمان درست بمانند.
+
+## نکات مهم
+
+- **هیچ تغییری در tauri نده** — فقط مرجع بصری است.
+- منبع رنگها = متغیرهای CSS در `assets/admin/styles.css`؛ رنگ برند شخصیسازی از `--accent` میآید (پنل `Topbar.tsx` → `setBrandHue`). هر رنگ هاردکد جدید ممنوع.
+- هر دو تم روشن/تیره باید سالم بمانند (`--surface`، `--accent`، `--surface-2` در هر دو تم تعریف شدهاند).
+- تست بصری پیکسلبهپیکسل: `SubscriptionPage` (درست) را کنار `AppointmentSettingsPage` بگذار؛ عرض منو، فاصله، زمینهی سفید و رنگ ردیف فعال باید یکسان باشند.
+- تستهای موجود را نگهدار: `PurchaseSubscriptionSidebar.test.tsx`، `SettingsLayout.test.tsx`، `AppointmentSettingsPage.test.tsx` — بعد از تغییر `npm test` (یا `ddev exec`) اجرا کن. اگر تستی رنگ هاردکد `#f17732` را چک میکند، آن را به `var(--accent)` بهروزرسانی کن.
+- backend/API دست نمیخورد → نیازی به بهروزرسانی `docs/api/*` نیست.
diff --git a/.claude/skills/figma-to-feature/SKILL.md b/.claude/skills/figma-to-feature/SKILL.md
new file mode 100644
index 00000000..6074df56
--- /dev/null
+++ b/.claude/skills/figma-to-feature/SKILL.md
@@ -0,0 +1,128 @@
+---
+name: figma-to-feature
+description: وقتی کاربر یک لینک figma.com/design با node-id میدهد، صفحه را تحلیل
+ کن، نیازهای فرانتاند و بکاند را استخراج کن و پس از تأیید پیادهسازی کن.
+---
+
+## بخش ۰ — زبان (قبل از هر کاری)
+- ورودی من فارسی است. منظور را استخراج کن، نه ترجمهی لغوی.
+- متن را به یک normalized English spec تبدیل کن با فیلدهای:
+ Goal / Scope (in-out) / Constraints / Acceptance criteria / Ambiguities
+- اصطلاحات فینگلیش (کامپوننت، اندپوینت، باتن) اصطلاح فنیاند، ترجمه نکن.
+- اسم متغیر، مسیر فایل، اسم کامپوننت و هر چیز داخل بکتیک را عیناً حفظ کن.
+- spec انگلیسی + خلاصهی برداشتت به فارسی را نشانم بده و منتظر تأیید بمان.
+ اگر Ambiguities خالی نبود، سؤالها را بپرس. بدون تأیید، کد ننویس.
+- خروجی: کد/کامنت/داکیومنت/کامیت انگلیسی. گفتوگو با من فارسی.
+ رشتههای UI فارسی و از فایل i18n پروژه — هاردکد ممنوع.
+
+## بخش ۱ — استخراج از فیگما
+- fileKey و node-id را از URL دربیاور.
+- get_design_context → ساختار و لِیاوت
+- get_variable_defs → رنگ/اسپیسینگ/تایپوگرافی
+- get_screenshot → مرجع تطبیق بصری
+- download_assets → آیکون و تصاویر
+- توکنهای فیگما را با mapping.md به متغیرهای واقعی پروژه نگاشت کن.
+
+## بخش ۲ — ممیزی کدبیس (اجباری، قبل از هر تحلیلی)
+
+هر بار که یک لینک صفحه میگیری، باید هم بکاند و هم فرانتاند را واقعاً بگردی.
+حدس زدن ممنوع؛ فقط چیزی که با Grep/Read در کد دیدی.
+
+### الف) ممیزی بکاند
+- routes/controllers را بگرد: کدام اندپوینتها مرتبط با این صفحه از قبل وجود دارند؟
+- مدلها و اسکیمای دیتابیس: کدام جدول/فیلد لازم است و از قبل هست؟
+- سرویسها و validationها و middleware مرتبط
+- برای هر مورد بنویس: مسیر فایل + شماره خط
+
+### ب) ممیزی فرانتاند
+- کامپوننتهای design system که میشود reuse کرد (با مسیر فایل)
+- روت مربوطه هست یا نه
+- hook/service/state موجود برای این داده
+- فایل i18n: کلیدهای متنی این صفحه از قبل هستند؟
+- برای هر مورد بنویس: مسیر فایل + شماره خط
+
+### ج) خروجی ممیزی — این جدول را بده
+| مورد | لایه | وضعیت | فایل | اقدام |
+|------|------|-------|------|-------|
+| نام دقیق | Frontend/Backend | ✅ موجود / ✏️ نیاز به ادیت / 🆕 جدید | مسیر:خط | یک جمله |
+
+### د) مبهمها
+هر چیزی که از دیزاین معلوم نیست: empty state، حالت خطا، لودینگ، pagination،
+دسترسی/نقش کاربر، اعتبارسنجی فیلدها. لیست کن و بپرس.
+
+منتظر تأیید من بمان.
+
+---
+
+## بخش ۳ — TODO List (اجباری)
+
+بعد از تأیید ممیزی، با ابزار TodoWrite یک TODO بساز. قواعد:
+
+- ترتیب حتماً: **Backend → Frontend → i18n → تست → تطبیق بصری**
+ (فرانت را قبل از آماده شدن اندپوینت نساز.)
+- هر آیتم اتمیک و قابل تست باشد. آیتم مبهم مثل «صفحه را بساز» ممنوع.
+- هر آیتم TODO باید تست خودش را هم شامل شود، نه یک آیتم «تست» در آخر.
+- ساختار پیشنهادی:
+ 1. [BE] مایگریشن/مدل X — + تست
+ 2. [BE] توسعهی اندپوینت Y (یا ساخت جدید، اگر توجیه شد) — + integration test
+ 3. [FE] service/hook برای فراخوانی Y — + unit test
+ 4. [FE] کامپوننت A (presentational) — + تست رندر
+ 5. [FE] کامپوننت B (تعاملی) — + تست تعامل
+ 6. [FE] مونتاژ صفحه و روت
+ 7. [i18n] کلیدهای متنی فارسی
+ 8. [QA] اجرای کل تستها
+ 9. [QA] مقایسه با اسکرینشات فیگما و اصلاح اختلافها
+
+TODO را قبل از شروع نشانم بده.
+
+---
+
+## بخش ۴ — اجرا، مرحله به مرحله
+
+- **همیشه فقط یک آیتم in_progress باشد.** موازیکاری ممنوع.
+- ترتیب TODO را رعایت کن؛ از روی آیتمها نپر.
+- بعد از هر آیتم: تستش را اجرا کن. **آیتم بدون تست سبز، completed علامت نمیخورد.**
+- بعد از هر آیتم یک خط فارسی گزارش بده: چه ساختی، کدام فایل، تست سبز شد یا نه.
+- اگر وسط کار به چیزی برخوردی که در ممیزی ندیده بودی (اندپوینت پنهان، کامپوننت
+ مشابه، تضاد با SOLID) → **توقف کن**، TODO را بهروز کن، و از من تأیید بگیر.
+ خودسرانه scope را عوض نکن.
+- در آخر: خروجی را با اسکرینشات فیگما مقایسه کن، اختلافها را لیست و اصلاح کن،
+ و کل تستها را یک بار دیگر اجرا کن.
+
+## بخش ۵ — بستن کار (به همین ترتیب)
+
+1. **اول کدها را commit کن.** وقتی همهٔ تستها سبز شد، تغییرات را با یک پیام
+ انگلیسی معنادار commit کن (طبق Conventional Commits). قبل از graphify commit
+ کن، نه بعدش.
+2. **بعد graphify را بهروز کن:** `graphify update .` — تا گراف با کد جدید همگام
+ شود. این مرحله فقط پس از commitِ موفق اجرا میشود.
+
+### ۱. SOLID
+- SRP: هر کامپوننت/کلاس یک مسئولیت. کامپوننتی که هم fetch میکند هم رندر میکند
+ باید به hook/service + کامپوننت presentational شکسته شود.
+- OCP: رفتار جدید با prop/strategy، نه if/else تو در تو در کد موجود.
+- LSP: هر پیادهسازی جایگزین قرارداد اینترفیس را کامل رعایت کند.
+- ISP: props و اینترفیس بزرگ ممنوع؛ به قراردادهای کوچک بشکن.
+- DIP: UI و لایهی بیزنس مستقیم به axios/fetch/ORM وابسته نشوند.
+اگر SOLID با ساختار فعلی تضاد داشت، توقف کن و بپرس؛ خودسرانه بازنویسی نکن.
+
+### ۲. API جدید — آخرین گزینه
+1. کل لایهی routes/controllers را بگرد.
+2. اگر اندپوینتی با یک پارامتر یا فیلد اضافه کافی است → همان را
+ backward-compatible توسعه بده.
+3. فقط اگر هیچ اندپوینتی نبود، جدید بساز.
+در جدول تحلیل برای هر نیاز بنویس: «موجود X» / «توسعهی X» / «جدید — چون اینها
+را بررسی کردم و کافی نبودند: [...]». بدون این توجیه، اندپوینت جدید نساز.
+
+### ۳. مستندسازی — دقیق و مختصر
+- هر تابع/کامپوننت عمومی: بلاک کوتاه (چه میکند، ورودی، خروجی، خطاها).
+- هر اندپوینت: متد، مسیر، payload، response، کدهای خطا — در همان فرمت
+ مستندات فعلی پروژه.
+- کامنت بدیهی ممنوع. «چرا» را بنویس، نه «چه».
+
+### ۴. تست — بدون تست کار تمام نیست
+- منطق بیزنس/سرویس/هوک: unit test با حالت موفق + خطا + مرزی.
+- اندپوینت جدید یا توسعهیافته: integration test.
+- کامپوننت تعاملی: تست رندر + تست تعامل.
+- از فریمورک تست موجود پروژه استفاده کن.
+- تستها را اجرا کن و خروجی سبز را نشان بده.
diff --git a/.claude/skills/figma-to-feature/mapping.md b/.claude/skills/figma-to-feature/mapping.md
new file mode 100644
index 00000000..35578a82
--- /dev/null
+++ b/.claude/skills/figma-to-feature/mapping.md
@@ -0,0 +1,140 @@
+# Figma → Project token mapping
+
+نگاشت توکنهای خروجی `get_variable_defs` فیگما به متغیرهای واقعی این پروژه.
+**منبع حقیقت:** `assets/admin/styles.css` (بلاک `:root`). هرگز hex هاردکد نکن — همیشه `var(--token)`.
+
+---
+
+## Colors — brand
+
+| نقش فیگما (نمونه نامها) | متغیر پروژه | مقدار |
+|---|---|---|
+| Primary / Brand / Indigo 500 | `--primary` | `#5559CE` |
+| Primary hover / 600 | `--primary-600` | `#494CB3` |
+| Primary pressed / 700 | `--primary-700` | `#3E41A0` |
+| Primary tint / subtle bg | `--primary-soft` | `#ecedfb` |
+| Primary tint 2 | `--primary-soft2` | `#d9dbf6` |
+| On-primary / text on brand | `--on-primary` | `#ffffff` |
+| Accent / Orange (CTA ثانویه، آواتار) | `--accent` | `#f0682a` |
+| Accent hover | `--accent-600` | `#db5a1f` |
+| Accent tint | `--accent-bg` | `#fdeee4` |
+
+## Colors — surface / text / border
+
+| نقش فیگما | متغیر پروژه | مقدار |
+|---|---|---|
+| Page background | `--bg` | `#fafafa` |
+| Alt background | `--bg-2` | `#f2f2f5` |
+| Card / surface | `--surface` | `#ffffff` |
+| Surface raised 2 | `--surface-2` | `#f6f8fc` |
+| Surface raised 3 | `--surface-3` | `#eef2f8` |
+| Border default | `--border` | `#e4e9f1` |
+| Border strong | `--border-2` | `#d6dde8` |
+| Text primary | `--text` | `#0f1b2e` |
+| Text secondary | `--text-2` | `#56657c` |
+| Text muted / placeholder | `--text-3` | `#8a98ad` |
+| Focus ring | `--ring` | `rgba(85,89,206,.32)` |
+
+## Colors — status
+
+| نقش | fg | bg |
+|---|---|---|
+| Success | `--success` `#15a35a` | `--success-bg` `#e6f6ed` |
+| Warning | `--warning` `#d98a09` | `--warning-bg` `#fcf2df` |
+| Danger / Error | `--danger` `#e0394a` | `--danger-bg` `#fdebed` |
+| Info | `--info` `#2b86d8` | `--info-bg` `#e7f1fb` |
+| Violet | `--violet` `#7c5cf0` | `--violet-bg` `#efeafe` |
+
+## Colors — dashboard stat cards
+
+| رنگ | bg | fg |
+|---|---|---|
+| Amber | `--stat-amber-bg` | `--stat-amber-fg` `#FFC051` |
+| Violet | `--stat-violet-bg` | `--stat-violet-fg` `#5559CE` |
+| Green | `--stat-green-bg` | `--stat-green-fg` `#009D79` |
+| Pink | `--stat-pink-bg` | `--stat-pink-fg` `#F17732` |
+
+---
+
+## Typography
+
+| فیگما | پروژه |
+|---|---|
+| Font family (fa + latin) | `--font-sans` = `"Vazirmatn", ui-sans-serif, system-ui, sans-serif` |
+| منبع فونت | `@fontsource/vazirmatn/{300,400,500,600,700,800}.css` (در `styles.css`) |
+
+اوزان موجود: 300 / 400 / 500 / 600 / 700 / 800. اندازه/line-height فیگما → کلاسهای Tailwind (`text-sm`, `text-lg`, …).
+
+## Radius
+
+| فیگما | پروژه | مقدار |
+|---|---|---|
+| xs (chip داخلی) | `--r-xs` | `7px` |
+| sm (badge, input کوچک) | `--r-sm` | `8px` |
+| md (input, button, card عادی) | `--r` | `14px` |
+| lg (card بزرگ) | `--r-lg` | `18px` |
+| xl (modal) | `--r-xl` | `24px` |
+| full (avatar, pill, toggle) | `--r-pill` | `999px` |
+
+## Shadow / elevation
+
+| فیگما | پروژه |
+|---|---|
+| Elevation 1 (card) | `--shadow-sm` |
+| Elevation 2 (dropdown/hover) | `--shadow` |
+| Elevation 3 (modal/popover) | `--shadow-lg` |
+
+## Spacing & layout dimensions
+
+| نقش | پروژه | مقدار |
+|---|---|---|
+| Grid gap | `--gap` | `20px` (compact: `14px`) |
+| Card padding | `--card-pad` | `22px` (compact: `16px`) |
+| Table row height | `--row-h` | `56px` (compact: `46px`) |
+| Sidebar width | `--sidebar-w` | `243px` |
+| Sidebar collapsed | `--collapsed-w` | `90px` |
+| Topbar height | `--topbar-h` | `64px` |
+| Motion easing | `--ease` | `cubic-bezier(.22,.61,.36,1)` |
+
+اسپیسینگ آزاد (margin/padding داخل اجزا) → مقیاس Tailwind (`p-4`, `gap-2`, …)؛ برای ابعاد ساختاری بالا از متغیرها استفاده کن.
+
+## Theming
+
+- Dark mode: بازتعریف متغیرها زیر `[data-theme="dark"]` در `styles.css`. رنگ خام دارک ننویس؛ همان `var(--token)` خودکار سوییچ میشود.
+- Density: `[data-density="compact"]` مقادیر `--gap` / `--card-pad` / `--row-h` را کم میکند.
+- RTL: کل پنل `dir="rtl"`؛ در نگاشت left/right فیگما را به start/end منطقی تبدیل کن.
+
+---
+
+## Component mapping (فیگما → کامپوننت موجود پروژه)
+
+قبل از ساخت، از `assets/admin/components/ui/` reuse کن:
+
+| المان فیگما | کامپوننت پروژه (`assets/admin/components/ui/`) |
+|---|---|
+| Table / list با ستون | `DataTable.tsx` (sort، search، skeleton، empty، bulk) |
+| Modal / dialog | `Modal.tsx` |
+| Delete/confirm dialog | `ConfirmDialog.tsx` |
+| Page title + breadcrumb + action | `PageHeader.tsx` |
+| Stat / KPI card | `StatCard.tsx` |
+| Status pill / badge | `StatusBadge.tsx` |
+| Pagination bar | `Pagination.tsx` |
+| Searchable / async select | `SearchableSelect.tsx` |
+| Appointment status control | `AppointmentStatusDropdown.tsx` |
+| Mobile number input | `MobileInput.tsx` |
+| Price / amount input | `PriceInput.tsx` |
+| Jalali date input/picker/calendar | `PersianDateInput.tsx` / `PersianDatePicker.tsx` / `PersianCalendar.tsx` |
+| Overlay/portal مبنا | `Portal.tsx` |
+| Feature-flag gate | `FeatureGate.tsx` |
+| Captcha | `Altcha.tsx` |
+
+کامپوننتهای ترکیبی فیچرمحور (نه generic) → `assets/admin/components/*.tsx`.
+آیکونها → `@heroicons/react/24/outline` (اول موجودها؛ فقط اگر نبود از `download_assets` فیگما).
+
+---
+
+## قواعد نگاشت
+1. هر توکن فیگما را به نزدیکترین متغیر بالا map کن. اگر معادل نبود → **توقف و بپرس**، توکن جدید خودسر به `styles.css` اضافه نکن.
+2. رنگ/فاصله/شعاع خام (hex/px) در کامپوننت ممنوع؛ فقط `var(--token)` یا کلاس Tailwind.
+3. اختلاف جزئی رنگ فیگما با پالت پروژه → پالت پروژه برنده است (تطبیق با design system، نه عین فیگما).
+4. منبع مقادیر همیشه `assets/admin/styles.css` است؛ این فایل خلاصهی نگاشت است، نه منبع مستقل — هنگام تغییر `styles.css` این را هم بهروز کن.
diff --git a/.claude/skills/qa-clinicpro/SKILL.md b/.claude/skills/qa-clinicpro/SKILL.md
new file mode 100644
index 00000000..ff49d90f
--- /dev/null
+++ b/.claude/skills/qa-clinicpro/SKILL.md
@@ -0,0 +1,530 @@
+---
+name: qa-clinicpro
+description: تست QA اپلیکیشن ClinicPro مثل یک کاربر واقعی — ابتدا ساخت همهٔ نقشها و پروفایلهای کامل (پزشک مستقل، پزشک عضو کلینیک، کلینیک، منشی، نماینده، بیمار، …) و تعیین ماتریس سطح دسترسی، سپس تست ماتریس دسترسی با تکتک آنها. هر مانعی سر راه تست را مثل یک دولوپر ارشد Symfony/React خودش رفع میکند و تست را ادامه میدهد. اجرای اپ، ورود با هر نقش، پیمایش صفحات پنل ادمین، اسکرینشات، کشف خطاهای کنسول و شبکه، تست UI/UX و RTL، تست دسترسی نقشها (authz)، تست قرارداد API و اندازهگیری کارایی، و تولید Bug Report. Use when asked to QA, test, smoke-test, find bugs in, screenshot, or verify ClinicPro's admin panel or API — «تست کن»، «باگ پیدا کن»، «QA کن»، «این صفحه را بررسی کن».
+---
+
+# QA ClinicPro
+
+ClinicPro = بکاند Symfony 7.4 + یک **SPA کلاینتساید React 19** که از `/admin/*` سرو میشود.
+یعنی `curl` و فلگ `--screenshot` کروم به درد نمیخورند — هر دو روی فرم لاگین مینشینند،
+چون JWT در `localStorage['clinicpro-auth']` است.
+
+درایور این skill آن کار را انجام میدهد: با API لاگین میکند، `localStorage` را seed
+میکند، بعد ناوبری میکند و **خطاهای کنسول، درخواستهای شکستخورده، مسیری که واقعاً روی آن
+فرود آمده، و اسکرینشات** را گزارش میدهد — با CDP روی `WebSocket` نیتیو Node 22،
+**بدون هیچ وابستگی npm** (نه playwright، نه puppeteer).
+
+مسیرها نسبت به `clinicpro/` هستند.
+
+## پیشنیازها
+
+هیچ نصبی لازم نیست. فقط این دو:
+
+```bash
+ddev describe | head -3 # باید بالا باشد: https://clinic-pro.ddev.site
+ls "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
+```
+
+کروم جای دیگری است؟ `CHROME_BIN` را ست کن. بکاند جای دیگری است؟ `CLINICPRO_BASE`.
+
+## کاربران تست
+
+⚠ **`TEST_USERS.md` منسوخ است** — هیچکدام از کاربرانش (`09100000001`, `09100100000`, …)
+در دیتابیس وجود ندارند و همه `ERR_AUTH_005` میگیرند. اسکریپتهای `create_test_users.php`
+و `seed_realistic_data.php` هم که آن فایل ارجاع میدهد در ریپو نیستند.
+
+پرسوناهای QA در `ROLES` داخل درایور تعریف شدهاند. **واحد کار «پرسونا» است، نه
+`ROLE_*`** — پزشک مستقل و پزشک عضو کلینیک هر دو `ROLE_DOCTOR` دارند ولی دادهٔ متفاوتی
+میبینند، پس هرکدام یک ردیف جداگانهاند.
+
+| پرسونا | موبایل | پسورد | نقشها | تمایز |
+|---|---|---|---|---|
+| `admin` | `09120671756` | `QaTest@1234` | `ROLE_ADMIN` | — |
+| `clinic` | `09127000000` | `QaTest@1234` | `ROLE_CLINIC` | مالک کلینیک |
+| `secretary` | `09123456778` | `QaTest@1234` | `ROLE_SECRETARY` | منشیِ یک پزشک |
+| `doctor` | `09390039833` | `09390039833` | `ROLE_DOCTOR` | حساب قدیمی، وضعیت عضویتش نامعلوم |
+| `representation` | `09124000001` | `09124000001` | `ROLE_REPRESENTATION` | نماینده شهر |
+| `doctor_solo` | `09129000001` | `QaTest@1234` | `ROLE_DOCTOR` | **پزشک مستقل** — مطب شخصی، بدون کلینیک |
+| `doctor_member` | `09129000002` | `QaTest@1234` | `ROLE_DOCTOR` | پزشک **عضو کلینیک** |
+| `clinic_doctor` | `09129000003` | `QaTest@1234` | `ROLE_CLINIC`+`ROLE_DOCTOR` | چندنقشی |
+| `secretary_clinic` | `09129000004` | `QaTest@1234` | `ROLE_SECRETARY` | منشیِ کلینیک (نه پزشک) |
+| `unclaimed_doctor` | `09129000005` | `QaTest@1234` | `ROLE_UNCLAIMED_DOCTOR` | پروفایل ایمپورتشدهٔ تصاحبنشده |
+| `patient` | `09129000006` | `QaTest@1234` | `ROLE_USER` | کاربر عادی سایت |
+| `importer` | `09129000007` | `QaTest@1234` | `ROLE_IMPORTER` | — |
+
+پنج ردیف اول موجودند. **هفت ردیف آخر تا وقتی Phase 0 اجرا نشده وجود ندارند** و
+`driver.mjs roles` برایشان `✗` میدهد — این دقیقاً چک آمادگی است.
+
+اگر DB ریست شد، پسورد پنجتای اول را دوباره ست کن:
+
+```bash
+ddev exec php bin/console security:hash-password 'QaTest@1234'
+# هش خروجی را در این کوئری بگذار:
+ddev mysql -e "UPDATE users SET password_hash='<هش>' \
+ WHERE mobile_number IN ('09120671756','09127000000','09123456778');"
+```
+
+اعتبارسنجی همه نقشها:
+
+```bash
+node .claude/skills/qa-clinicpro/driver.mjs roles
+```
+
+خروجی واقعی:
+
+```
+admin 09120671756 ROLE_USER,ROLE_ADMIN token 15min
+clinic 09127000000 ROLE_USER,ROLE_CLINIC token 15min
+secretary 09123456778 ROLE_USER,ROLE_SECRETARY token 15min
+doctor 09390039833 ROLE_USER,ROLE_DOCTOR token 15min
+representation 09124000001 ROLE_USER,ROLE_REPRESENTATION token 15min
+```
+
+میتوانی بهجای نام نقش، `--as "0912xxxxxxx:password"` هم بدهی.
+
+---
+
+## مسیر اجرا (agent path)
+
+### ۰. Phase 0 — ساخت نقشها، پروفایلها و ماتریس دسترسی (اجباری، قبل از هر تست)
+
+هیچ تستی را قبل از تمامشدن این فاز شروع نکن. خروجی این فاز سه چیز است:
+**همهٔ پرسوناها موجود** · **پروفایل هرکدام کامل** · **ماتریس دسترسی مکتوب**.
+
+**۰.۱ — کشف نقشها.** لیست بالا را دوباره از روی کد بساز، به آن استناد نکن؛ ممکن است
+نقشی اضافه شده باشد:
+
+```bash
+grep -rhoE "ROLE_[A-Z_]+" src/ assets/admin/ config/ | sort -u
+ddev mysql -e "SELECT roles, COUNT(*) c FROM users GROUP BY roles ORDER BY c DESC;"
+grep -n "role_hierarchy" -A 10 config/packages/security.yaml
+```
+
+هر نقشی که در کد هست و در جدول پرسوناها نیست را به `ROLES` در `driver.mjs` اضافه کن.
+
+**۰.۲ — چک آمادگی.** ببین کدام پرسونا هنوز نیست:
+
+```bash
+node .claude/skills/qa-clinicpro/driver.mjs roles
+```
+
+**۰.۳ — ساخت پرسوناهای ناموجود.** برای هرکدام، **اول مسیر واقعی ساخت را در خود اپ پیدا
+کن** و از همان استفاده کن — دستکاری مستقیم SQL پروفایل ناقص میسازد و تست را دروغین
+میکند. به این ترتیب بگرد:
+
+```bash
+ls src/*/Command/ # آیا کامند کنسولی برای ساخت کاربر هست؟
+grep -rn "IsGranted" src/Admin/Controller/ # اندپوینتهای ادمینِ ساخت کاربر
+sed -n '1,80p' docs/api/admin.md
+```
+
+فقط برای چیزی که هیچ مسیر اپلیکیشنی ندارد (مثلاً ستکردن `ROLE_IMPORTER` یا ساختن
+`ROLE_UNCLAIMED_DOCTOR`) به `ddev mysql` برگرد، و در گزارش بنویس که کدام پرسونا
+دستی ساخته شد.
+
+**ترتیب ساخت مهم است** — وابستگی دارند:
+
+```
+کلینیک → doctor_member (عضو همان کلینیک) → secretary_clinic (منشیِ همان کلینیک)
+پزشک → secretary (منشیِ همان پزشک)
+```
+
+**۰.۴ — کاملکردن پروفایل.** یک حسابِ بدون پروفایل، صفحات را خالی نشان میدهد و
+باگهای واقعی را پنهان میکند. برای هر پرسونا اینها باید پر باشند:
+
+| پرسونا | حداقل پروفایل لازم |
+|---|---|
+| `doctor_solo` / `doctor_member` / `clinic_doctor` | نام، تخصص، آدرس مطب، برنامهٔ کاری هفتگی، حداقل یک خدمت با تعرفه، حداقل یک بیمه |
+| `clinic` | نام کلینیک، شهر، آدرس، حداقل یک پزشک عضو، حداقل یک خدمت |
+| `secretary` / `secretary_clinic` | اتصال به پزشک/کلینیک + سطح دسترسیاش |
+| `representation` | شهر تخصیصیافته |
+| `patient` | نام، و حداقل یک نوبت رزروشده (برای اینکه صفحات خالی نباشند) |
+| `unclaimed_doctor` | پروفایل پزشک بدون کاربرِ تصاحبکننده |
+
+بعد از ساخت، پرشدن را تأیید کن — نه با حدس، با درخواست:
+
+```bash
+node .claude/skills/qa-clinicpro/driver.mjs api GET /api/v1/doctor/profile --as doctor_solo
+```
+
+**۰.۵ — تعیین سطح دسترسی.** ماتریس را از کد دربیاور، نه از ذهنت:
+
+```bash
+grep -n "RoleRoute\|allowedRoles\|element=" assets/admin/App.tsx # مسیرهای فرانت
+grep -rn "IsGranted" src/*/Controller/ | sed 's/.*IsGranted(//' # گاردهای بکاند
+```
+
+از این دو، جدول `مسیر → نقشهای مجاز` را بساز و در گزارش بیاور. بعد برای هر اندپوینت
+حساس با `authz` (بخش ۳) تأییدش کن. **اختلاف بین ماتریسِ کد و خروجی `authz` = باگ**،
+حتی اگر خروجی `authz` سختگیرانهتر باشد.
+
+**۰.۶ — دروازهٔ خروج.** تا وقتی `roles` برای همهٔ پرسوناها توکن برمیگرداند و ماتریس
+نوشته شده، به فاز بعد نرو. اگر پرسونایی ساخته نشد، طبق بخش «وقتی به مانع خوردی»
+خودت رفعش کن؛ رها کردنش یعنی آن نقش اصلاً تست نشده.
+
+### ۱. بازدید از صفحه — اسکرینشات + خطاها
+
+```bash
+node .claude/skills/qa-clinicpro/driver.mjs visit \
+ "https://clinic-pro.ddev.site/admin/dashboard" --as admin --out /tmp/qa-dash.png
+```
+
+```
+✓ screenshot /tmp/qa-dash.png (1440x900, as admin)
+
+LANDING
+ (none)
+
+CONSOLE ERRORS
+ (none)
+
+NETWORK FAILURES
+ (none)
+```
+
+**بعد حتماً تصویر را با ابزار Read باز کن و نگاه کن.** نیمی از باگهای UI فقط دیدنیاند،
+نه لاگشدنی — همان یک اسکرینشات داشبورد دو باگ i18n لو داد (پایین را ببین).
+
+فلگها: `--w 1440 --h 900` (ویوپورت)، `--wait 4000` (ms صبر برای رندر)، `--full` (کل صفحه).
+
+**موبایل را جدا تست کن** — پنل RTL و پرجدول است و بیشتر مشکلات آنجاست:
+
+```bash
+node .claude/skills/qa-clinicpro/driver.mjs visit \
+ "https://clinic-pro.ddev.site/admin/dashboard" --as admin --w 390 --h 844 --out /tmp/qa-m.png
+```
+
+بخش `LANDING` دو حالتی را میگیرد که اسکرینشات پنهان میکند:
+
+```
+⚠ WRONG PAGE: asked /admin/users, landed /admin/dashboard — role likely lacks access (RoleRoute in App.tsx)
+```
+
+### ۲. آدیت UI/UX و RTL
+
+```bash
+node .claude/skills/qa-clinicpro/driver.mjs ux \
+ "https://clinic-pro.ddev.site/admin/dashboard" --as admin --w 390 --h 844
+```
+
+```
+UX FINDINGS (390x844, as admin)
+ 5 tap target(s) under 36px on a mobile viewport
+```
+
+چکها: RTL نبودن ریشه، `lang` غلط، سرریز افقی، رقم لاتین داخل متن فارسی، تارگت لمسی
+زیر ۳۶px، `
` بدون alt، فیلد بدون label، `id` تکراری، جدول خالی بدون empty-state،
+و `
` نیتیو (استاندارد پروژه `SearchableSelect` است).
+
+### ۳. تست دسترسی نقشها (Security)
+
+همان درخواست با همه نقشها + ناشناس:
+
+```bash
+node .claude/skills/qa-clinicpro/driver.mjs authz GET /api/v1/admin/users
+```
+
+```
+AUTHZ GET /api/v1/admin/users
+
+ anonymous 401
+ admin 200
+ clinic 403
+ secretary 403
+ doctor 403
+ representation 403
+
+ 200 for: admin
+```
+
+هر ۲۰۰ غیرمنتظره در این جدول = یک باگ Critical. اگر `anonymous` هم ۲۰۰ گرفت، درایور
+هشدار میدهد.
+
+#### ۳.۱ جاروی کامل ماتریس — اجباری، نه نمونهای
+
+`authz` خودش همهٔ پرسوناها را میزند، پس **تست دسترسی نباید روی چند اندپوینت منتخب
+بماند**. لیست اندپوینتها را از روتر بگیر و همه را جارو کن:
+
+```bash
+ddev exec php bin/console debug:router --format=json \
+ | node -e 'const r=JSON.parse(require("fs").readFileSync(0));
+ for (const [n,v] of Object.entries(r))
+ if (v.path.startsWith("/api/v1") && !v.path.includes("{"))
+ console.log(v.method.split("|")[0].replace("ANY","GET"), v.path);' \
+ | while read m p; do
+ node .claude/skills/qa-clinicpro/driver.mjs authz "$m" "$p"
+ done | tee /tmp/qa-authz-matrix.txt
+```
+
+روی این DB حدود **۱۶۸ مسیر بدون پارامتر** برمیگردد و `authz` برای هر مسیر بهازای هر
+پرسونا دوباره لاگین میکند (≈۲۲۰۰ درخواست) — چند دقیقه طول میکشد، پس در پسزمینه
+اجرایش کن و بعد فایل را بخوان. دو تله در خواندن خروجی:
+
+- **۴۲۲ روی مسیرهای POST طبیعی است** (بدنه خالی فرستاده شده) و باگ نیست؛ چیزی که مهم
+ است تمایز ۴۰۱/۴۰۳ از بقیه است. اگر نقشی بهجای ۴۰۳ یک ۴۲۲ گرفت، یعنی **گارد بعد از
+ اعتبارسنجی اجرا شده** — همان هم یافته است.
+- **مسیرهای عمومی** (لاگین، ثبتنام، لیست شهرها) قاعدتاً برای `anonymous` هم ۲۰۰اند؛
+ اول با `config/packages/security.yaml` تطبیق بده، بعد ادعای نشت کن.
+
+اندپوینتهای پارامتردار (`{uuid}`) از این حلقه میافتند — آنها را دستی و با
+**شناسهٔ متعلق به پرسونای دیگر** بزن، چون همانجاست که IDOR پیدا میشود:
+
+```bash
+# uuid پزشکِ دیگری را به پرسونای doctor_solo بده — باید ۴۰۳/۴۰۴ بگیرد، نه ۲۰۰
+node .claude/skills/qa-clinicpro/driver.mjs authz GET /api/v1/doctor/
+```
+
+سه الگویی که باید در `/tmp/qa-authz-matrix.txt` دنبالشان بگردی:
+
+| یافته | معنی |
+|---|---|
+| `anonymous` = ۲۰۰ روی مسیر غیرعمومی | نشت داده — Critical |
+| نقشی ۲۰۰ میگیرد که در ماتریس ۰.۵ نبود | گارد جا افتاده — Critical |
+| ۲۰۰ روی uuidِ مستأجر دیگر | IDOR — Critical |
+| نقشی ۴۰۳ میگیرد که طبق ماتریس باید ۲۰۰ بگیرد | یا گارد سختگیر است یا ماتریس غلط — بررسی کن |
+| ۵۰۰ بهجای ۴۰۳ | گارد کار میکند ولی خطا مدیریت نشده — High |
+
+**بدون این جدولِ کامل، فاز دسترسی تمامشده نیست.** خروجیاش را در گزارش نهایی بیاور.
+
+### ۴. تست قرارداد API
+
+```bash
+node .claude/skills/qa-clinicpro/driver.mjs api GET /api/v1/categorys/state --as admin
+```
+
+```
+GET /api/v1/categorys/state → 301 12ms (as admin)
+
+ENVELOPE
+ (none)
+
+BODY
+{
+ "success": false,
+ "data": null,
+ "errors": [
+ { "code": "ERR_MOVED", "message": "این endpoint منتقل شده. لطفاً از /api/v1/provinces استفاده کنید." }
+ ]
+}
+```
+
+بخش `ENVELOPE` پاکت `BaseController` را چک میکند: نبودِ `success`، پاسخ خطای بدون
+`errors`، و دام معروف **double/triple nesting** (`data.data.data`).
+
+POST هم میشود: `--body '{"name":"x"}'`.
+
+### ۵. کارایی
+
+```bash
+node .claude/skills/qa-clinicpro/driver.mjs perf "https://clinic-pro.ddev.site/admin/doctors" --as admin
+```
+
+```
+PERF https://clinic-pro.ddev.site/admin/doctors (as admin)
+ ttfb 12ms
+ domContentLoaded 232ms
+ load 233ms
+ first-paint 180ms
+ first-contentful-paint 248ms
+ resources 24 · DOM nodes 1132
+
+SLOWEST API CALLS
+ 18ms 2kb v1/admin/doctors?page=1&limit=25
+ 17ms 1kb v1/admin/doctors/stats
+ 17ms 5kb v1/specialties
+ 13ms 1kb v1/provinces
+```
+
+---
+
+## نقش QA و روش کار
+
+وقتی این skill فعال شد، مثل یک **مهندس ارشد تست** رفتار کن، نه فقط اجراکننده دستور:
+
+0. **Phase 0 را تمام کن** (بالا). بدون پرسوناهای کامل، هر تستی نتیجهٔ بیمعنی میدهد.
+1. **اول سناریوی واقعی کاربر را بنویس**، بعد اجرا کن. مثال: ورود منشی → لیست نوبتها →
+ تغییر وضعیت یک نوبت → خروج → ورود مجدد → آیا تغییر ماند؟
+
+**پیمایش با هر پرسونا اجباری است.** بعد از Phase 0، برای *هر* پرسونا در جدول، وارد شو و
+مسیرهای مجازش را طبق ماتریس ۰.۵ بگرد — نه فقط با `admin`. برای هر پرسونا حداقل:
+
+```bash
+for p in admin clinic doctor_solo doctor_member clinic_doctor \
+ secretary secretary_clinic representation patient; do
+ node .claude/skills/qa-clinicpro/driver.mjs visit \
+ "https://clinic-pro.ddev.site/admin/dashboard" --as "$p" --out "/tmp/qa-$p.png"
+done
+```
+
+بعد **هر اسکرینشات را با Read باز کن و ببین** — و بخش `LANDING` را بخوان تا ریدایرکت
+بیصدای نقش را نگیری. سه چیزی که فقط با مقایسهٔ بین پرسوناها پیدا میشوند:
+
+- **نشت داده بین مستأجرها:** آیا `doctor_solo` دادهٔ بیمار پزشک دیگری را میبیند؟ آیا
+ `clinic` نوبتهای پزشک غیرعضو را میبیند؟ اینها همیشه Criticalاند.
+- **صفحهٔ سفید بهجای «دسترسی ندارید»:** نقشی که نباید ببیند، باید پیام روشن بگیرد.
+- **منوی سایدبار در برابر دسترسی واقعی:** آیتمی که نمایش داده میشود ولی به ۴۰۳
+ میخورد (یا برعکس: مسیر باز است ولی در منو نیست) باگ است.
+2. برای هر بخش این حالتها را پوشش بده:
+ Happy Path · ورودی نامعتبر · داده خالی · داده خیلی زیاد (لیست ۱۰٬۹۳۲ کاربری) ·
+ شرایط مرزی · خطای شبکه · **همهٔ پرسوناها** · دسکتاپ ۱۴۴۰ و موبایل ۳۹۰.
+3. **هیچ چیز را حدس نزن.** ادعای بدون خروجی دستور، ادعا نیست.
+4. **قبل از گزارش، باگ را دوباره تکرار کن.** همان دستور را دوباره بزن؛ اگر تکرار نشد،
+ flaky بودنش را بنویس نه خودِ باگ را.
+5. باگهای کوچک UI را هم گزارش کن، ولی باگهای Business Logic اولویت بالاترند.
+
+### وقتی به مانع خوردی — رفعش کن، بعد برو تست بعدی
+
+QA اینجا فقط گزارشنویس نیست. هر جا اجرای تست گیر کرد، **مثل یک دولوپر ارشد
+Symfony/React خودت مشکل را حل کن**، تأیید کن که حل شده، و تست را از همانجا ادامه بده.
+توقف روی اولین مانع یعنی بقیهٔ نقشها هیچوقت تست نمیشوند.
+
+روال ثابت هر مانع:
+
+```
+بازتولید → ریشهیابی (نه علامت) → اصلاح → اثبات اصلاح → ثبت → ادامهٔ همان تست
+```
+
+1. **ریشه را پیدا کن، نه علامت را.** `visit` صفحهٔ سفید داد؟ اول `CONSOLE ERRORS` و
+ `NETWORK FAILURES`، بعد فایل سورس صفحه (`redesign-page/driver.mjs inspect`)، بعد
+ کنترلر مربوطه. اصلاح باید در همان لایهای باشد که علت آنجاست.
+2. **طبق قواعد پروژه اصلاح کن**، نه با وصلهٔ سریع:
+ - بکاند: `extends BaseController`، خطا با `AppException(ErrorCodes::…)`، کد SOLID،
+ تغییر entity ⟵ `doctrine:migrations:diff` + `migrate`.
+ - فرانت: TanStack Query برای دادهٔ سرور، کامپوننتهای `components/ui/`، توکنهای
+ `styles.css` (هیچ hex هاردکد)، رشتههای فارسی.
+ - اندپوینت عوض شد ⟵ همان جلسه `docs/api/.md` را بهروز کن (قاعدهٔ ثابت پروژه).
+3. **اثبات کن.** همان دستوری که شکست خورده بود را دوباره بزن و خروجی سالمش را نشان بده.
+ بعد `ddev exec php bin/phpunit` و در صورت تغییر فرانت `npx tsc --noEmit` را اجرا کن
+ تا مطمئن شوی چیزی نشکستهای.
+4. **ثبت کن.** هر اصلاح یک ورودی در بخش «Fixes Applied» گزارش نهایی میگیرد:
+ مانع · ریشه · فایلهای تغییریافته · دستور اثبات.
+
+**مرزهایی که رد نمیکنی:**
+
+- **باگ محصول را بیصدا رفع نکن.** اگر مانع خودش یک باگ واقعی محصول است، هم Bug Report
+ را بنویس هم اصلاح را — نه فقط اصلاح. گزارش، خروجی کار است.
+- **هرگز برای سبزشدن تست، دسترسی را باز نکن.** اگر نقشی ۴۰۳ میگیرد و تو انتظار ۲۰۰
+ داری، پیشفرض این است که **انتظارت غلط است**. `IsGranted` یا `RoleRoute` را فقط وقتی
+ عوض کن که از روی کد ثابت کرده باشی آن نقش باید دسترسی داشته باشد، و دلیلش را بنویس.
+ همین قاعده برای حذف اعتبارسنجی ورودی هم هست.
+- **دادهٔ تست را با تغییر محصول نساز.** کمبود دادهٔ پرسونا را با seed درست کن، نه با
+ نرمکردن یک قاعدهٔ کسبوکار.
+- **مهاجرت مخرب نزن.** روی DB لوکالِ پر (۱۰٬۹۳۲ کاربر) `doctrine:schema:drop` یا
+ مهاجرتی که ستون پرداده را میاندازد، ممنوع.
+- **اگر اصلاح از تست بزرگتر شد** (بازطراحی معماری، تغییر شکستدهندهٔ قرارداد API که
+ `nobat724_front` و `clinic-pro-tauri` هم مصرفش میکنند)، دست نگه دار: باگ را با
+ اصلاح پیشنهادی گزارش کن، آن یک تست را `SKIPPED` علامت بزن، و **برو تست بعدی**.
+
+### فرمت Bug Report
+
+هر یافته را با این قالب بنویس (فارسی):
+
+```markdown
+## Title
+<عنوان کوتاه و مشخص>
+
+- **Severity:** Critical | High | Medium | Low
+- **Priority:** فوری | مهم | معمولی | کم
+- **Environment:** Chrome headless · macOS · ddev · نقش: · ویوپورت: x
+
+### Description
+### Steps To Reproduce
+1. `node .claude/skills/qa-clinicpro/driver.mjs …` ← دستور دقیق، نه توضیح
+2.
+### Expected Behavior
+### Actual Behavior
+### Evidence
+<خروجی درایور، مسیر اسکرینشات، پاسخ API>
+### Impact
+### Suggested Fix
+<فایل:خط اگر پیدا کردی>
+```
+
+برای پیدا کردن فایل سورس یک صفحه از روی URL، از skill خواهر استفاده کن:
+
+```bash
+node .claude/skills/redesign-page/driver.mjs inspect "https://clinic-pro.ddev.site/admin/doctors"
+```
+
+### گزارش نهایی
+
+۱. خلاصه وضعیت کلی · ۲. تعداد باگها · ۳. لیست بر اساس Severity ·
+۴. باگهایی که باید فوری رفع شوند · ۵. پیشنهاد بهبود کیفیت.
+
+بهعلاوه این سه بخش که از قواعد بالا میآیند:
+
+**۶. Fixes Applied** — هر مانعی که خودت رفع کردی:
+
+| مانع | ریشه | فایلهای تغییریافته | دستور اثبات |
+|---|---|---|---|
+
+**۷. ماتریس دسترسی** — جدول کامل `مسیر × پرسونا` از بخش ۳.۱، با اختلافهای
+ماتریسِ کد و رفتار واقعی مشخصشده.
+
+**۸. پوشش** — کدام پرسونا چه چیزی تست شد، و هر `SKIPPED` با دلیلش. اگر نقشی تست نشد
+باید اینجا صریح بیاید؛ گزارشِ ساکت بدتر از گزارش ناقص است.
+
+---
+
+## Gotchas
+
+- **SPA است، پس `curl` صفحه نمیدهد.** `curl /admin/doctors` همیشه همان HTML پوسته را
+ برمیگرداند. هر ادعایی درباره محتوای صفحه باید از `visit` بیاید.
+- **ریدایرکت بیصدای نقش.** `RoleRoute` در `App.tsx` کاربر بدون دسترسی را بیهیچ پیغامی
+ به `/dashboard` میفرستد — اسکرینشات کاملاً سالم بهنظر میرسد ولی صفحهٔ اشتباهی است.
+ همیشه بخش `LANDING` را بخوان.
+- **توکن فقط ۱۵ دقیقه اعتبار دارد.** درایور برای هر دستور دوباره لاگین میکند، پس مسئلهای
+ نیست؛ ولی اگر خودت توکن را جایی کش کردی، انتظار ۴۰۱ داشته باش.
+- **`TEST_USERS.md` دروغ میگوید** (بالا). به آن استناد نکن.
+- **`CLAUDE.md` هم روی `/api/v1/categorys/{bundle}` منسوخ است** — آن مسیر حالا ۳۰۱ با
+ `ERR_MOVED` میدهد و مسیر واقعی `/api/v1/provinces` است.
+- **کد OTP در محیط dev همیشه `12345` است** (`OtpService::sendCode` — در غیر dev کد تصادفی
+ ۵رقمی میسازد و SMS میکند). پس زنجیرهٔ کامل ورود بدون رمز اسکریپتپذیر است:
+ `POST /api/v1/user/send-code` → `POST /api/v1/user/verify-code` با `code=12345` →
+ `grant` → `POST /api/v1/user/otp-login`.
+- **`patient` و `unclaimed_doctor` با رمز وارد نمیشوند و این باگ نیست.**
+ `PasswordAuthenticator::onAuthenticationSuccess` هر کاربری که `User::isStaff()` نباشد را
+ با ۴۰۳ و `ERR_AUTH_006` رد میکند (staff = doctor/clinic/secretary/admin/representation/importer).
+ این دو پرسونا فقط OTP-only هستند؛ درایور خودش به زنجیرهٔ OTP بالا fallback میکند.
+ **گاردش را برای سبزشدن تست باز نکن.**
+- **`send-code` سقف ۵ درخواست در ساعت بهازای هر IP دارد** (`config/packages/rate_limiter.yaml`).
+ یک جاروی کامل authz این سقف را میسوزاند و بعدش پرسوناهای OTP-only شکست میخورند
+ (`ERR_RATE_LIMIT_001`). راهحل بدون دستزدن به محصول: توکن را مستقیم با کامند خود اپ بساز —
+ ```bash
+ ddev exec 'php bin/console lexik:jwt:generate-token 09129000006 --user-class="App\\Auth\\Entity\\User"'
+ ```
+ همان کلید و همان claimها؛ فقط محدودیت نرخ را دور میزند.
+- **برای جاروی ماتریس، درایور را در حلقه صدا نزن.** هر فراخوانی دوباره لاگین میکند
+ (۱۶۸ مسیر × ۱۳ پرسونا ≈ ۲۲۰۰ لاگین) — هم چند ده دقیقه طول میکشد هم rate limit را میسوزاند.
+ یکبار برای هر پرسونا توکن بگیر و همان را در همهٔ مسیرها استفاده کن.
+- **گواهی TLS ddev را Node قبول نمیکند.** درایور فقط برای هاستهای `*.ddev.site` /
+ `localhost` `NODE_TLS_REJECT_UNAUTHORIZED=0` میگذارد و وارنینگ نویزیاش را خفه میکند.
+- **خطاهای صفحهٔ لاگین به حساب صفحهٔ تحت تست نوشته نشوند.** درایور بافر خطا را بعد از
+ seed کردن `localStorage` و قبل از ناوبری به URL هدف پاک میکند.
+- **دیتای لوکال واقعی و بزرگ است** (۱۰٬۹۳۲ کاربر، ۲۰۲ کلینیک، ۱۷۹ پزشک) — برای تست
+ «داده زیاد» لازم نیست چیزی seed کنی.
+- **CDP روی پورت ۹۴۴۴** است تا با درایور `redesign-page` (پورت ۹۳۳۳) تداخل نکند؛
+ میتوانی هر دو را همزمان اجرا کنی. `CDP_PORT` قابل تغییر است.
+
+## Troubleshooting
+
+| نشانه | علت / رفع |
+|---|---|
+| `login as admin failed: … ERR_AUTH_005` | DB ریست شده؛ پسورد QA را دوباره ست کن (بخش «کاربران تست») |
+| `Chrome did not expose CDP on :9444` | `CHROME_BIN` غلط است، یا نمونهٔ قبلی کروم روی همان پورت مانده — `pkill -f clinicpro-qa` |
+| `⚠ page text only N chars` | رندر SPA کرش کرده یا کند است؛ اول `--wait 8000` را امتحان کن، بعد `CONSOLE ERRORS` را بخوان |
+| `⚠ redirected to /login` | توکن رد شده — با `driver.mjs login ` صحتش را چک کن |
+| `fetch failed` / `ECONNREFUSED` | ddev بالا نیست: `ddev start` |
+
+## باگهای شناختهشده (در همین اجرا پیدا شدند)
+
+نمونههایی از خروجی واقعی همین درایور، بهعنوان مرجعِ اینکه گزارش چطور باشد:
+
+1. **Medium** — در «وضعیت نوبتها»ی داشبورد، برچسبهای `confirmed` و `expired` انگلیسی
+ ماندهاند در حالی که بقیه فارسیاند («تکمیل شده»، «لغو پزشک»).
+ بازتولید: `visit https://clinic-pro.ddev.site/admin/dashboard --as admin`، اسکرینشات.
+2. **Low** — کارت «درآمد این ماه» کلمهٔ «تومان» را دو بار نشان میدهد (یکبار کنار عدد،
+ یکبار بهعنوان زیرنویس کارت). همان اسکرینشات.
+3. **Low** — در ویوپورت ۳۹۰px داشبورد، ۵ تارگت لمسی زیر ۳۶px هستند.
+ بازتولید: `ux … --w 390 --h 844`.
+4. **Medium (مستندات)** — `TEST_USERS.md` و بخش Category در `CLAUDE.md` هر دو منسوخاند.
diff --git a/.claude/skills/qa-clinicpro/driver.mjs b/.claude/skills/qa-clinicpro/driver.mjs
new file mode 100644
index 00000000..f9734f70
--- /dev/null
+++ b/.claude/skills/qa-clinicpro/driver.mjs
@@ -0,0 +1,481 @@
+#!/usr/bin/env node
+/**
+ * ClinicPro QA driver — drives the running app the way a real user would, and
+ * reports what broke. No npm dependencies: Node 22's global WebSocket speaks CDP
+ * to a headless Chrome directly, so there is no playwright/puppeteer to install.
+ *
+ * driver.mjs login
+ * driver.mjs visit [--as role] [--out f.png] [--w] [--h] [--wait] [--full]
+ * driver.mjs api [--as role] [--body '{...}']
+ * driver.mjs authz [--body '{...}']
+ * driver.mjs ux [--as role]
+ * driver.mjs perf [--as role]
+ * driver.mjs roles
+ *
+ * `visit` is the workhorse: it logs in over the API, seeds the SPA's auth store
+ * into localStorage, navigates, then reports console errors, failed network
+ * requests, the path it actually landed on, and a screenshot.
+ */
+import { spawn, execSync } from 'node:child_process';
+import { writeFileSync } from 'node:fs';
+
+const BASE = process.env.CLINICPRO_BASE ?? 'https://clinic-pro.ddev.site';
+const CHROME = process.env.CHROME_BIN
+ ?? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
+const PORT = Number(process.env.CDP_PORT ?? 9444);
+
+/**
+ * Local QA personas — one per distinct authorization identity in the product,
+ * not merely one per ROLE_* constant: an independent doctor and a clinic-member
+ * doctor carry the same role but see different data, so each gets its own row.
+ *
+ * The first five predate this list and are known to exist; the rest are
+ * provisioned by SKILL.md § Phase 0 and report `✗` from `driver.mjs roles`
+ * until they are. TEST_USERS.md is stale — its accounts do not exist.
+ */
+const ROLES = {
+ admin: ['09120671756', 'QaTest@1234'],
+ clinic: ['09127000000', 'QaTest@1234'],
+ secretary: ['09123456778', 'QaTest@1234'],
+ doctor: ['09390039833', 'QaTest@1234'],
+ representation: ['09124000001', 'QaTest@1234'],
+
+ // Provisioned by Phase 0. Reserved QA range 0912900000x, password QaTest@1234.
+ doctor_solo: ['09129000001', 'QaTest@1234'], // own office, no clinic
+ doctor_member: ['09129000002', 'QaTest@1234'], // member of a clinic
+ clinic_doctor: ['09129000003', 'QaTest@1234'], // ROLE_CLINIC + ROLE_DOCTOR
+ secretary_clinic: ['09129000004', 'QaTest@1234'], // secretary of a clinic
+ unclaimed_doctor: ['09129000005', 'QaTest@1234'], // imported, unclaimed profile
+ patient: ['09129000006', 'QaTest@1234'], // ROLE_USER only
+ importer: ['09129000007', 'QaTest@1234'],
+};
+
+// ddev serves a locally-signed cert Node's fetch refuses. Relax TLS only for it.
+if (/^https:\/\/([\w-]+\.ddev\.site|localhost|127\.0\.0\.1)/.test(BASE)) {
+ process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
+ // …which Node then warns about on every run, drowning the actual QA output.
+ process.removeAllListeners('warning');
+ process.on('warning', () => {});
+}
+
+// ── auth ───────────────────────────────────────────────────────────────────
+
+function creds(role) {
+ if (ROLES[role]) return ROLES[role];
+ if (role.includes(':')) return role.split(':'); // "0912...:password"
+ throw new Error(`unknown role "${role}". Known: ${Object.keys(ROLES).join(', ')}`);
+}
+
+/**
+ * Non-staff accounts (ROLE_USER, ROLE_UNCLAIMED_DOCTOR) are rejected by
+ * PasswordAuthenticator with ERR_AUTH_006 by design — they are OTP-only.
+ * In dev the OTP is the fixed '12345' (OtpService::sendCode), so the whole
+ * send-code → verify-code → otp-login chain is scriptable.
+ */
+async function otpLogin(mobile) {
+ const post = async (path, body) => {
+ const r = await fetch(`${BASE}${path}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ });
+ return r.json();
+ };
+
+ const sent = await post('/api/v1/user/send-code', { mobile });
+ if (!sent.uuid) throw new Error(`send-code failed: ${JSON.stringify(sent).slice(0, 200)}`);
+
+ const ver = await post('/api/v1/user/verify-code', { uuid: sent.uuid, code: '12345' });
+ const grant = ver?.data?.grant;
+ if (!grant) throw new Error(`verify-code failed: ${JSON.stringify(ver).slice(0, 200)}`);
+
+ return post('/api/v1/user/otp-login', { grant });
+}
+
+/** Same signing key and claims as a real login — only skips the rate limiter. */
+function mintToken(mobile) {
+ const out = execSync(
+ `ddev exec 'php bin/console lexik:jwt:generate-token ${mobile} --user-class="App\\\\Auth\\\\Entity\\\\User"'`,
+ { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], cwd: process.cwd() },
+ );
+ const tok = out.trim().split('\n').pop().trim();
+ if (!tok.startsWith('ey')) throw new Error(`could not mint token for ${mobile}`);
+ return tok;
+}
+
+async function login(role) {
+ const [mobile_number, password] = creds(role);
+ const r = await fetch(`${BASE}/api/v1/user/login`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ mobile_number, password }),
+ });
+ const j = await r.json();
+ if (j.access_token) return j;
+
+ // ERR_AUTH_006 here means "staff-only endpoint", not "wrong password".
+ if (j?.errors?.some((e) => e.code === 'ERR_AUTH_006')) {
+ const o = await otpLogin(mobile_number).catch((e) => ({ _err: e.message }));
+ if (o.access_token) return o;
+ // send-code is capped at 5/hour/IP; once burned, mint the JWT with the app's
+ // own command rather than loosening a real product limit for a test.
+ return { access_token: mintToken(mobile_number) };
+ }
+
+ throw new Error(`login as ${role} failed: ${JSON.stringify(j).slice(0, 300)}`);
+}
+
+/** JWT is unsigned-read here purely to report which roles a token carries. */
+function claims(token) {
+ const s = token.split('.')[1];
+ return JSON.parse(Buffer.from(s.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString());
+}
+
+// ── CDP plumbing ───────────────────────────────────────────────────────────
+
+async function waitForCdp(timeoutMs = 15000) {
+ const deadline = Date.now() + timeoutMs;
+ while (Date.now() < deadline) {
+ try {
+ const r = await fetch(`http://127.0.0.1:${PORT}/json/version`);
+ if (r.ok) return (await r.json()).webSocketDebuggerUrl;
+ } catch { /* not up yet */ }
+ await new Promise((r) => setTimeout(r, 200));
+ }
+ throw new Error(`Chrome did not expose CDP on :${PORT} within ${timeoutMs}ms`);
+}
+
+/** CDP client with both request/response and event subscription. */
+function cdp(ws) {
+ let id = 0;
+ const pending = new Map();
+ const listeners = [];
+ ws.addEventListener('message', (ev) => {
+ const msg = JSON.parse(ev.data);
+ if (msg.id && pending.has(msg.id)) {
+ const { resolve, reject } = pending.get(msg.id);
+ pending.delete(msg.id);
+ msg.error ? reject(new Error(JSON.stringify(msg.error))) : resolve(msg.result);
+ } else if (msg.method) {
+ listeners.forEach((fn) => fn(msg.method, msg.params));
+ }
+ });
+ const send = (method, params = {}, sessionId) =>
+ new Promise((res, rej) => {
+ const msgId = ++id;
+ pending.set(msgId, { resolve: res, reject: rej });
+ ws.send(JSON.stringify({ id: msgId, method, params, sessionId }));
+ });
+ send.on = (fn) => listeners.push(fn);
+ return send;
+}
+
+/**
+ * Boot Chrome, authenticate the SPA, navigate, and hand the page to `fn`.
+ * Collects console errors and failed requests for the whole session.
+ */
+async function withPage(url, opts, fn) {
+ const { access_token, refresh_token } = await login(opts.as);
+
+ const chrome = spawn(CHROME, [
+ '--headless=new', '--disable-gpu', '--no-sandbox', '--hide-scrollbars',
+ '--ignore-certificate-errors', // ddev's local CA
+ `--remote-debugging-port=${PORT}`,
+ `--user-data-dir=/tmp/clinicpro-qa-${process.pid}`,
+ `--window-size=${opts.w},${opts.h}`,
+ 'about:blank',
+ ], { stdio: 'ignore' });
+
+ const errors = [];
+ const netFails = [];
+ try {
+ const ws = new WebSocket(await waitForCdp());
+ await new Promise((res) => ws.addEventListener('open', res, { once: true }));
+ const send = cdp(ws);
+
+ const { targetId } = await send('Target.createTarget', { url: 'about:blank' });
+ const { sessionId } = await send('Target.attachToTarget', { targetId, flatten: true });
+ const S = (m, p) => send(m, p, sessionId);
+
+ await S('Page.enable');
+ await S('Runtime.enable');
+ await S('Log.enable');
+ await S('Network.enable');
+
+ send.on((method, p) => {
+ if (method === 'Runtime.exceptionThrown') {
+ errors.push(`uncaught: ${p.exceptionDetails?.exception?.description ?? p.exceptionDetails?.text}`);
+ } else if (method === 'Runtime.consoleAPICalled' && p.type === 'error') {
+ errors.push('console.error: ' + p.args.map((a) => a.value ?? a.description ?? a.type).join(' '));
+ } else if (method === 'Log.entryAdded' && p.entry.level === 'error') {
+ errors.push(`log(${p.entry.source}): ${p.entry.text}`);
+ } else if (method === 'Network.loadingFailed') {
+ netFails.push(`request failed: ${p.errorText}`);
+ } else if (method === 'Network.responseReceived' && p.response.status >= 400) {
+ netFails.push(`HTTP ${p.response.status} ${p.response.url.replace(BASE, '')}`);
+ }
+ });
+
+ // localStorage is origin-scoped: load the origin before seeding it.
+ await S('Page.navigate', { url: `${BASE}/admin/login` });
+ await new Promise((r) => setTimeout(r, 1500));
+ const auth = {
+ state: { token: access_token, refreshToken: refresh_token, isAuthenticated: true },
+ version: 0,
+ };
+ await S('Runtime.evaluate', {
+ expression: `localStorage.setItem('clinicpro-auth', ${JSON.stringify(JSON.stringify(auth))});
+ localStorage.setItem('pwa-dismissed','1');`,
+ });
+
+ // Errors before this point belong to the login page, not the page under test.
+ errors.length = 0; netFails.length = 0;
+
+ await S('Page.navigate', { url });
+ await new Promise((r) => setTimeout(r, opts.wait));
+
+ const evalJs = async (expression) => {
+ const { result, exceptionDetails } = await S('Runtime.evaluate', {
+ expression, returnByValue: true, awaitPromise: true,
+ });
+ if (exceptionDetails) throw new Error(exceptionDetails.text);
+ return result.value;
+ };
+
+ await fn({ S, evalJs, errors, netFails, url, opts });
+ ws.close();
+ } finally {
+ chrome.kill();
+ }
+}
+
+/** Report the two failure modes a screenshot alone hides: wrong page, blank page. */
+async function landingCheck(evalJs, url) {
+ const v = await evalJs('JSON.stringify({p:location.pathname,t:(document.body.innerText||"").trim().length})');
+ const { p: landed, t: len } = JSON.parse(v);
+ const wanted = new URL(url).pathname;
+ const out = [];
+ if (landed.includes('/login')) out.push('⚠ redirected to /login — token rejected, expired, or route requires auth');
+ else if (landed.replace(/\/$/, '') !== wanted.replace(/\/$/, '')) {
+ out.push(`⚠ WRONG PAGE: asked ${wanted}, landed ${landed} — role likely lacks access (RoleRoute in App.tsx)`);
+ }
+ if (len < 40) out.push(`⚠ page text only ${len} chars — likely blank / crashed render`);
+ return out;
+}
+
+function report(title, lines) {
+ console.log(`\n${title}`);
+ console.log(lines.length ? lines.map((l) => ' ' + l).join('\n') : ' (none)');
+}
+
+// ── commands ───────────────────────────────────────────────────────────────
+
+async function cmdVisit(url, opts) {
+ await withPage(url, opts, async ({ S, evalJs, errors, netFails }) => {
+ const { data } = await S('Page.captureScreenshot', { format: 'png', captureBeyondViewport: opts.full });
+ writeFileSync(opts.out, Buffer.from(data, 'base64'));
+ console.log(`✓ screenshot ${opts.out} (${opts.w}x${opts.h}, as ${opts.as})`);
+ report('LANDING', await landingCheck(evalJs, url));
+ report('CONSOLE ERRORS', [...new Set(errors)]);
+ report('NETWORK FAILURES', [...new Set(netFails)]);
+ });
+}
+
+/**
+ * DOM heuristics for the recurring UX defects of an RTL Persian admin: layout
+ * that overflows sideways, Latin digits leaking into Persian copy, tap targets
+ * too small for the mobile viewport, tables with no empty state.
+ */
+const UX_PROBE = `(() => {
+ const out = [];
+ const de = document.documentElement;
+ if (de.dir !== 'rtl' && getComputedStyle(de).direction !== 'rtl') out.push('root is not RTL');
+ if (de.lang !== 'fa') out.push('html lang is "' + de.lang + '", expected "fa"');
+ if (de.scrollWidth > de.clientWidth + 2)
+ out.push('horizontal overflow: content ' + de.scrollWidth + 'px > viewport ' + de.clientWidth + 'px');
+
+ // Latin digits inside Persian text read as untranslated to a Persian user.
+ const fa = /[\\u0600-\\u06FF]/, latin = /[0-9]/;
+ let mixed = 0;
+ document.querySelectorAll('h1,h2,h3,label,th,button,a').forEach(el => {
+ const t = (el.textContent||'').trim();
+ if (t && fa.test(t) && latin.test(t)) mixed++;
+ });
+ if (mixed) out.push(mixed + ' element(s) mix Persian text with Latin digits (use Persian numerals)');
+
+ // 44px is the usual minimum comfortable touch target.
+ if (innerWidth < 600) {
+ let small = 0;
+ document.querySelectorAll('button,a,[role=button]').forEach(el => {
+ const r = el.getBoundingClientRect();
+ if (r.width > 0 && (r.height < 36 || r.width < 36)) small++;
+ });
+ if (small) out.push(small + ' tap target(s) under 36px on a mobile viewport');
+ }
+
+ document.querySelectorAll('img:not([alt])').forEach(() => {});
+ const noAlt = document.querySelectorAll('img:not([alt])').length;
+ if (noAlt) out.push(noAlt + ' without alt');
+
+ const noLabel = [...document.querySelectorAll('input,select,textarea')]
+ .filter(el => !el.labels?.length && !el.getAttribute('aria-label') && !el.placeholder).length;
+ if (noLabel) out.push(noLabel + ' form field(s) with no label, aria-label, or placeholder');
+
+ const ids = {}; let dup = 0;
+ document.querySelectorAll('[id]').forEach(el => { dup += (ids[el.id] = (ids[el.id]||0) + 1) > 1 ? 1 : 0; });
+ if (dup) out.push(dup + ' duplicate DOM id(s)');
+
+ // A table rendered with zero rows and no empty-state message is a dead end.
+ document.querySelectorAll('table').forEach((t, i) => {
+ const rows = t.querySelectorAll('tbody tr').length;
+ if (rows === 0 && !/(هیچ|یافت نشد|خالی|موردی)/.test(t.parentElement?.textContent||''))
+ out.push('table #' + (i+1) + ' has 0 rows and no empty-state message');
+ });
+
+ if (document.querySelector('select')) out.push('native present — project standard is SearchableSelect');
+ return JSON.stringify(out);
+})()`;
+
+async function cmdUx(url, opts) {
+ await withPage(url, opts, async ({ evalJs, errors, netFails }) => {
+ report('LANDING', await landingCheck(evalJs, url));
+ report(`UX FINDINGS (${opts.w}x${opts.h}, as ${opts.as})`, JSON.parse(await evalJs(UX_PROBE)));
+ report('CONSOLE ERRORS', [...new Set(errors)]);
+ report('NETWORK FAILURES', [...new Set(netFails)]);
+ });
+}
+
+async function cmdPerf(url, opts) {
+ await withPage(url, opts, async ({ evalJs }) => {
+ const t = JSON.parse(await evalJs(`JSON.stringify({
+ nav: performance.getEntriesByType('navigation')[0],
+ paint: performance.getEntriesByType('paint'),
+ api: performance.getEntriesByType('resource')
+ .filter(r => r.name.includes('/api/'))
+ .map(r => ({ u: r.name.split('/api/')[1], ms: Math.round(r.duration), kb: Math.round(r.transferSize/1024) }))
+ .sort((a,b) => b.ms - a.ms).slice(0, 12),
+ res: performance.getEntriesByType('resource').length,
+ dom: document.querySelectorAll('*').length,
+ })`));
+ console.log(`\nPERF ${url} (as ${opts.as})`);
+ if (t.nav) {
+ console.log(` ${'ttfb'.padEnd(24)}${Math.round(t.nav.responseStart)}ms`);
+ console.log(` ${'domContentLoaded'.padEnd(24)}${Math.round(t.nav.domContentLoadedEventEnd)}ms`);
+ console.log(` ${'load'.padEnd(24)}${Math.round(t.nav.loadEventEnd)}ms`);
+ }
+ t.paint.forEach((p) => console.log(` ${p.name.padEnd(24)}${Math.round(p.startTime)}ms`));
+ console.log(` resources ${t.res} · DOM nodes ${t.dom}`);
+ report('SLOWEST API CALLS', t.api.map((a) => `${String(a.ms).padStart(5)}ms ${a.kb}kb ${a.u}`));
+ });
+}
+
+async function apiCall(method, path, role, body) {
+ const { access_token } = await login(role);
+ const t0 = Date.now();
+ const r = await fetch(`${BASE}${path.startsWith('/') ? path : '/' + path}`, {
+ method,
+ headers: {
+ Authorization: `Bearer ${access_token}`,
+ 'Content-Type': 'application/json',
+ },
+ body: body ?? undefined,
+ });
+ const text = await r.text();
+ let json = null;
+ try { json = JSON.parse(text); } catch { /* not json */ }
+ return { status: r.status, ms: Date.now() - t0, json, text };
+}
+
+async function cmdApi(method, path, opts) {
+ const { status, ms, json, text } = await apiCall(method, path, opts.as, opts.body);
+ console.log(`${method} ${path} → ${status} ${ms}ms (as ${opts.as})`);
+
+ // BaseController's envelope is the contract every client depends on.
+ const problems = [];
+ if (!json) problems.push('response is not JSON');
+ else {
+ if (!('success' in json)) problems.push('envelope missing "success"');
+ if (status >= 400 && !json.errors) problems.push('error response has no "errors" array');
+ if (json?.data?.data?.data) problems.push('triple-nested data — BaseController double-nesting pitfall');
+ else if (json?.data?.data && !Array.isArray(json.data)) problems.push('double-nested data (client must read data.data.data)');
+ }
+ report('ENVELOPE', problems);
+ console.log('\nBODY\n' + (json ? JSON.stringify(json, null, 2) : text).slice(0, 2000));
+}
+
+/** Same request as every role plus anonymous — the access-control matrix. */
+async function cmdAuthz(method, path, opts) {
+ console.log(`AUTHZ ${method} ${path}\n`);
+ const rows = [];
+
+ const anon = await fetch(`${BASE}${path}`, { method, headers: { 'Content-Type': 'application/json' }, body: opts.body ?? undefined });
+ rows.push(['anonymous', anon.status]);
+
+ for (const role of Object.keys(ROLES)) {
+ try {
+ const { status } = await apiCall(method, path, role, opts.body);
+ rows.push([role, status]);
+ } catch (e) {
+ rows.push([role, `login failed (${String(e.message).slice(0, 40)})`]);
+ }
+ }
+ rows.forEach(([r, s]) => console.log(` ${r.padEnd(16)} ${s}`));
+
+ const leaks = rows.filter(([r, s]) => r === 'anonymous' && s === 200);
+ if (leaks.length) console.log('\n ⚠ anonymous got 200 — endpoint is public. Intended?');
+ const allowed = rows.filter(([, s]) => s === 200).map(([r]) => r);
+ console.log(`\n 200 for: ${allowed.join(', ') || '(nobody)'}`);
+}
+
+async function cmdRoles() {
+ for (const role of Object.keys(ROLES)) {
+ try {
+ const j = await login(role);
+ const c = claims(j.access_token);
+ const mins = Math.round((c.exp - c.iat) / 60);
+ console.log(`${role.padEnd(16)} ${creds(role)[0]} ${c.roles.join(',')} token ${mins}min`);
+ } catch (e) {
+ console.log(`${role.padEnd(16)} ✗ ${e.message.slice(0, 90)}`);
+ }
+ }
+}
+
+// ── CLI ────────────────────────────────────────────────────────────────────
+
+const [cmd, ...argv] = process.argv.slice(2);
+const flag = (n, d) => { const i = argv.indexOf(`--${n}`); return i >= 0 ? argv[i + 1] : d; };
+const positional = argv.filter((a, i) => !a.startsWith('--') && !(i > 0 && argv[i - 1].startsWith('--') && argv[i - 1] !== '--full'));
+
+const opts = {
+ as: flag('as', 'admin'),
+ out: flag('out', '/tmp/clinicpro-qa.png'),
+ w: Number(flag('w', 1440)),
+ h: Number(flag('h', 900)),
+ wait: Number(flag('wait', 4000)),
+ full: argv.includes('--full'),
+ body: flag('body', null),
+};
+
+try {
+ if (cmd === 'visit' && positional[0]) await cmdVisit(positional[0], opts);
+ else if (cmd === 'ux' && positional[0]) await cmdUx(positional[0], opts);
+ else if (cmd === 'perf' && positional[0]) await cmdPerf(positional[0], opts);
+ else if (cmd === 'api' && positional[1]) await cmdApi(positional[0].toUpperCase(), positional[1], opts);
+ else if (cmd === 'authz' && positional[1]) await cmdAuthz(positional[0].toUpperCase(), positional[1], opts);
+ else if (cmd === 'login' && positional[0]) console.log(JSON.stringify(claims((await login(positional[0])).access_token), null, 2));
+ else if (cmd === 'roles') await cmdRoles();
+ else {
+ console.log(`usage (roles: ${Object.keys(ROLES).join(', ')}, or "mobile:password")
+ driver.mjs roles
+ driver.mjs login
+ driver.mjs visit [--as admin] [--out f.png] [--w 1440] [--h 900] [--wait 4000] [--full]
+ driver.mjs ux [--as admin] [--w] [--h]
+ driver.mjs perf [--as admin]
+ driver.mjs api [--as admin] [--body '{"k":1}']
+ driver.mjs authz [--body '{"k":1}']`);
+ process.exit(1);
+ }
+} catch (e) {
+ console.error('✗ ' + e.message);
+ process.exit(1);
+}
diff --git a/.claude/skills/redesign-page/SKILL.md b/.claude/skills/redesign-page/SKILL.md
new file mode 100644
index 00000000..133c1848
--- /dev/null
+++ b/.claude/skills/redesign-page/SKILL.md
@@ -0,0 +1,165 @@
+---
+name: redesign-page
+description: بازطراحی UI/UX یک صفحه از پنل ادمین ClinicPro از روی URL آن — اسکرینشات گرفتن از صفحه، نگاشت URL به فایل سورس، آدیت انحرافها از دیزاینسیستم، و بازنویسی صفحه با کامپوننتها و توکنهای موجود. استفاده کن وقتی کاربر یک URL از /admin میدهد و میگوید «این صفحه ui/ux خوبی ندارد»، «این صفحه را بازطراحی کن»، «redesign this page»، «این قسمت را درست کن»، یا «screenshot این صفحه».
+---
+
+# بازطراحی صفحه پنل ادمین ClinicPro
+
+پنل ادمین یک SPA کلاینتساید است (React 19 + Webpack Encore، سرو شده از `/admin/*`).
+یعنی `curl` و فلگ `--screenshot` کروم به درد نمیخورند: هر دو روی فرم لاگین مینشینند،
+چون توکن JWT در `localStorage['clinicpro-auth']` است.
+
+درایور این skill آن کار را انجام میدهد: با API لاگین میکند، `localStorage` را seed
+میکند، بعد ناوبری و اسکرینشات میگیرد — با CDP روی `WebSocket` نیتیو Node 22،
+**بدون هیچ وابستگی npm** (نه playwright، نه puppeteer).
+
+مسیرها نسبت به `clinicpro/` هستند.
+
+## پیشنیازها
+
+هیچ نصبی لازم نیست. فقط این دو:
+
+```bash
+ddev describe | head -3 # باید بالا باشد: https://clinic-pro.ddev.site
+ls "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
+```
+
+کروم در مسیر دیگری است؟ `CHROME_BIN` را ست کن.
+
+## گردش کار
+
+### ۱. اسکرینشات صفحه فعلی
+
+```bash
+node .claude/skills/redesign-page/driver.mjs shot \
+ "https://clinic-pro.ddev.site/admin/appointments" --out /tmp/before.png
+```
+
+**بعد حتماً تصویر را با ابزار Read باز کن و نگاه کن.** بدون دیدنِ صفحه، بازطراحی
+یعنی حدس زدن.
+
+فلگها: `--w 1440 --h 900` (سایز ویوپورت)، `--wait 4000` (میلیثانیه صبر برای رندر)،
+`--full` (کل صفحه، نه فقط ویوپورت).
+
+موبایل هم ببین — این پنل RTL و پرجدول است و بیشتر مشکلات ریسپانسیو آنجاست:
+
+```bash
+node .claude/skills/redesign-page/driver.mjs shot \
+ "https://clinic-pro.ddev.site/admin/appointments" --w 390 --h 844 --out /tmp/mobile.png
+```
+
+### ۲. نگاشت URL به سورس + آدیت
+
+```bash
+node .claude/skills/redesign-page/driver.mjs inspect \
+ "https://clinic-pro.ddev.site/admin/clinics/41e325c4-e825-4067-8438-5d828ecaee09"
+```
+
+خروجی واقعی:
+
+```
+route clinics/:uuid
+component ClinicDetailPage
+file assets/admin/pages/ClinicDetailPage.tsx
+components ConfirmDialog, Modal, PageHeader, SearchableSelect, NotificationMobileCard
+lines 1035
+
+AUDIT
+ assets/admin/pages/ClinicDetailPage.tsx:242 hand-rolled overlay — use the shared
+```
+
+روی هر فایل دلخواه هم مستقیم:
+
+```bash
+node .claude/skills/redesign-page/driver.mjs audit assets/admin/pages/AppointmentsPage.tsx
+```
+
+### ۳. قبل از نوشتن کد، دیزاینسیستم را بخوان
+
+**منبع حقیقتِ توکنها `assets/admin/styles.css` است** — نه `docs/admin-ui/ui-design-spec.md`
+(آن سند قدیمی و پالت بنفشش با کد شیپشده نمیخواند).
+
+```bash
+sed -n '/^:root/,/^}/p' assets/admin/styles.css | head -60 # توکنها
+ls assets/admin/components/ui/ # کامپوننتهای آماده
+```
+
+قانون: **اول کامپوننت موجود، بعد توسعهاش، در آخر ساخت کامپوننت جدید** — و دلیلش را بنویس.
+
+### ۴. بازنویسی، سپس مقایسه
+
+بعد از ادیت، دوباره اسکرینشات بگیر و با `before.png` مقایسه کن:
+
+```bash
+yarn dev # یا: yarn watch
+node .claude/skills/redesign-page/driver.mjs shot "<همان url>" --out /tmp/after.png
+```
+
+### ۵. تست + تایپچک (بدون این، تسک تمام نیست)
+
+```bash
+npx tsc --noEmit -p tsconfig.json
+npx vitest run assets/admin/pages/.test.tsx
+```
+
+توجه: سوییت کامل همین الان **۲۱ تست از پیش شکسته** دارد (`api.test.ts`، `LoginPage`،
+`PatientDetailPage`، …) که ربطی به کار تو ندارند. قبل از شروع یکبار `npx vitest run`
+بگیر و عدد پایه را یادداشت کن، وگرنه خطاهای موجود را به گردن تغییر خودت میاندازی.
+
+## چکلیست بازطراحی
+
+درایور موارد گرپشدنی را میگیرد؛ اینها را باید خودت با چشم ببینی:
+
+- **`.field` در مقابل `.field-block`** — `.field` یک باکس افقی بوردردار است که لیبل
+ *داخلش* مینشیند. اگر `` داخل `.field` بگذاری، لیبل کنار اینپوت میچسبد؛ و اگر
+ `SearchableSelect` داخلش بگذاری، دو باکس تودرتو میشود. برای «لیبل بالای فیلد» از
+ `.field-block` استفاده کن.
+- **`className="btn"` بدون واریانت** بیرنگ و بدون بوردر رندر میشود — عملاً نامرئی.
+ همیشه `btn primary` / `btn ghost` / `btn soft` / `btn danger`.
+- **دکمههای فقط-آیکون** → `mini-btn`، نه `btn ghost sm` با پدینگ دستی.
+- **توکن مرده** — مثلاً `var(--error)` وجود ندارد (`--danger` درست است). درایور این را میگیرد.
+- **سلسلهمراتب** — عنوان صفحه در `PageHeader` بیاید و در کارت زیرش تکرار نشود.
+- **RTL/جلالی** — رشتههای جدید فارسی، تاریخها جلالی، اعداد با `formatNumber`/`formatRial`.
+- **دارکمود** — چون توکن استفاده میکنی خودکار درست است؛ هگز هاردکد آن را میشکند.
+
+## Gotchas
+
+- **ریدایرکت خاموش نقشها.** `RoleRoute` کاربری که نقشش اجازه ندارد را بیصدا به
+ `/admin/dashboard` میبرد. یعنی یک اسکرینشات کاملاً سالم از **صفحهٔ اشتباه** میگیری.
+ درایور مسیر نهایی را با مسیر درخواستی مقایسه میکند و هشدار میدهد:
+
+ ```
+ ⚠ WRONG PAGE: asked for /admin/clinics/…, landed on /admin/dashboard
+ ```
+
+ کاربر پیشفرض (`09390039833`) نقش **doctor** دارد. صفحات ادمین/کلینیک با آن باز نمیشوند.
+ برای آنها `CLINICPRO_USER` / `CLINICPRO_PASS` را ست کن.
+
+- **کاربران تستی ممکن است seed نشده باشند.** `TEST_USERS.md` ادمین `09100000001` با رمز
+ `Test@1234` را مستند میکند، ولی روی این دیتابیس وجود نداشت و لاگین `ERR_AUTH_005` داد.
+ ساختنشان: `ddev exec php create_test_users.php` (دیتابیس را مینویسد — اول بپرس).
+
+- **مودال نصب PWA جلوی صفحه را میگیرد.** درایور `localStorage['pwa-dismissed']='1'` را
+ seed میکند. اگر با کروم خام اسکرینشات بگیری، این مودال وسط تصویر است.
+
+- **کپچا (altcha) لوکال اجباری نیست.** `POST /api/v1/user/login` بدون فیلد `altcha` هم
+ توکن میدهد؛ درایور به همین تکیه میکند. اگر روی محیطی که کپچا را اجبار میکند اجرا شود، میشکند.
+
+- **سرت ddev را Node رد میکند** (`UNABLE_TO_VERIFY_LEAF_SIGNATURE`). درایور فقط برای
+ هاستهای `*.ddev.site` / `localhost` تأیید TLS را خاموش میکند، نه برای هر مبدأ.
+
+- **صفحهٔ نوبتها خودش اسکرول میشود** به ساعت جاری، پس ویوپورت وسط تایملاین میافتد.
+ برای دیدن هدر از `--full` استفاده کن.
+
+- **بیلد CSS داخل ddev خطای نیتیو `lightningcss` میدهد** — از قبل وجود دارد و جلوی
+ کامپایل JS/TS را نمیگیرد. خطاهای TypeScript همچنان در خروجی `tsc` میآیند.
+
+## Troubleshooting
+
+| علامت | علت / راهحل |
+|---|---|
+| `Chrome did not expose CDP on :9333` | نمونهٔ کروم قبلی زنده مانده. `CDP_PORT=9444` بده یا پروسه را بکش. |
+| `login failed: … ERR_AUTH_005` | کاربر seed نشده یا رمز فرق دارد. `TEST_USERS.md` را ببین. |
+| `⚠ redirected to /login` | توکن رد شد؛ معمولاً یعنی JWT منقضی شده — دوباره اجرا کن. |
+| `⚠ page text is only N chars` | صفحه خالی رندر شده. `--wait 8000` بده یا کنسول را چک کن. |
+| اسکرینشات تغییرات را نشان نمیدهد | باندل قدیمی است. `yarn dev` بزن (یا `yarn watch` روشن باشد). |
diff --git a/.claude/skills/redesign-page/driver.mjs b/.claude/skills/redesign-page/driver.mjs
new file mode 100644
index 00000000..dad2cb90
--- /dev/null
+++ b/.claude/skills/redesign-page/driver.mjs
@@ -0,0 +1,245 @@
+#!/usr/bin/env node
+/**
+ * ClinicPro admin page driver — screenshots and audits a page of the React admin
+ * SPA from its URL, with no npm dependencies (Node 22's global WebSocket speaks
+ * CDP directly, so there is no playwright/puppeteer install to babysit).
+ *
+ * node .claude/skills/redesign-page/driver.mjs shot [--out f.png] [--w 1440] [--h 900] [--full]
+ * node .claude/skills/redesign-page/driver.mjs inspect
+ * node .claude/skills/redesign-page/driver.mjs audit
+ *
+ * `shot` logs in over the API, seeds localStorage['clinicpro-auth'], then
+ * navigates and captures. Needed because the admin is a client-side
+ * SPA: Chrome's plain `--screenshot` flag lands on the login form.
+ * `inspect` maps a URL to the route entry in App.tsx, the page source file, and
+ * the design-system components it already imports.
+ * `audit` greps one source file for the anti-patterns this project keeps
+ * regrowing (native , hardcoded hex, dead tokens, …).
+ */
+import { spawn } from 'node:child_process';
+import { readFileSync, writeFileSync, existsSync } from 'node:fs';
+import { resolve, dirname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const REPO = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
+const BASE = process.env.CLINICPRO_BASE ?? 'https://clinic-pro.ddev.site';
+const USER = process.env.CLINICPRO_USER ?? '09390039833';
+const PASS = process.env.CLINICPRO_PASS ?? '09390039833';
+const CHROME = process.env.CHROME_BIN
+ ?? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
+const PORT = Number(process.env.CDP_PORT ?? 9333);
+
+// ddev serves a locally-signed cert that Node's fetch refuses. Only relax TLS for
+// that local host — never for a real origin someone might point this at.
+if (/^https:\/\/([\w-]+\.ddev\.site|localhost|127\.0\.0\.1)/.test(BASE)) {
+ process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
+}
+
+// ── CDP plumbing ───────────────────────────────────────────────────────────
+
+/** Chrome needs a moment before /json/version answers; poll instead of sleeping. */
+async function waitForCdp(timeoutMs = 15000) {
+ const deadline = Date.now() + timeoutMs;
+ while (Date.now() < deadline) {
+ try {
+ const r = await fetch(`http://127.0.0.1:${PORT}/json/version`);
+ if (r.ok) return (await r.json()).webSocketDebuggerUrl;
+ } catch { /* not up yet */ }
+ await new Promise((r) => setTimeout(r, 200));
+ }
+ throw new Error(`Chrome did not expose CDP on :${PORT} within ${timeoutMs}ms`);
+}
+
+/** Minimal CDP client: send(method, params) → Promise. */
+function cdp(ws) {
+ let id = 0;
+ const pending = new Map();
+ ws.addEventListener('message', (ev) => {
+ const msg = JSON.parse(ev.data);
+ if (msg.id && pending.has(msg.id)) {
+ const { resolve: res, reject } = pending.get(msg.id);
+ pending.delete(msg.id);
+ msg.error ? reject(new Error(JSON.stringify(msg.error))) : res(msg.result);
+ }
+ });
+ return (method, params = {}, sessionId) =>
+ new Promise((res, reject) => {
+ const msgId = ++id;
+ pending.set(msgId, { resolve: res, reject });
+ ws.send(JSON.stringify({ id: msgId, method, params, sessionId }));
+ });
+}
+
+async function login() {
+ const r = await fetch(`${BASE}/api/v1/user/login`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ mobile_number: USER, password: PASS }),
+ });
+ const j = await r.json();
+ if (!j.access_token) throw new Error(`login failed: ${JSON.stringify(j).slice(0, 200)}`);
+ return j;
+}
+
+async function shot(url, opts) {
+ const { access_token, refresh_token } = await login();
+
+ const chrome = spawn(CHROME, [
+ '--headless=new', '--disable-gpu', '--no-sandbox', '--hide-scrollbars',
+ '--ignore-certificate-errors', // ddev serves a local CA cert
+ `--remote-debugging-port=${PORT}`,
+ `--user-data-dir=/tmp/clinicpro-shot-${process.pid}`,
+ `--window-size=${opts.w},${opts.h}`,
+ 'about:blank',
+ ], { stdio: 'ignore' });
+
+ try {
+ const ws = new WebSocket(await waitForCdp());
+ await new Promise((res) => ws.addEventListener('open', res, { once: true }));
+ const send = cdp(ws);
+
+ const { targetId } = await send('Target.createTarget', { url: 'about:blank' });
+ const { sessionId } = await send('Target.attachToTarget', { targetId, flatten: true });
+ const S = (m, p) => send(m, p, sessionId);
+
+ await S('Page.enable');
+ await S('Runtime.enable');
+
+ // localStorage is origin-scoped, so the origin must be loaded before seeding.
+ await S('Page.navigate', { url: `${BASE}/admin/login` });
+ await new Promise((r) => setTimeout(r, 1500));
+
+ const auth = {
+ state: {
+ token: access_token, refreshToken: refresh_token, isAuthenticated: true,
+ },
+ version: 0,
+ };
+ await S('Runtime.evaluate', {
+ expression: `
+ localStorage.setItem('clinicpro-auth', ${JSON.stringify(JSON.stringify(auth))});
+ localStorage.setItem('pwa-dismissed', '1');
+ `,
+ });
+
+ await S('Page.navigate', { url });
+ await new Promise((r) => setTimeout(r, opts.wait));
+
+ const { data } = await S('Page.captureScreenshot', {
+ format: 'png',
+ captureBeyondViewport: opts.full,
+ });
+ writeFileSync(opts.out, Buffer.from(data, 'base64'));
+ console.log(`✓ ${opts.out}`);
+
+ // The SPA redirects silently: RoleRoute bounces a user whose role lacks access
+ // straight to /dashboard, so you get a valid-looking screenshot of the WRONG
+ // page. Compare the landed path against the requested one and say so loudly.
+ const { result } = await S('Runtime.evaluate', {
+ expression: 'location.pathname + "|" + (document.body.innerText||"").trim().length',
+ returnByValue: true,
+ });
+ const [landed, len] = String(result.value).split('|');
+ const wanted = new URL(url).pathname;
+ if (landed.includes('/login')) {
+ console.log('⚠ redirected to /login — token rejected or expired');
+ } else if (landed.replace(/\/$/, '') !== wanted.replace(/\/$/, '')) {
+ console.log(`⚠ WRONG PAGE: asked for ${wanted}, landed on ${landed}`);
+ console.log(' → the test user\'s role probably lacks access (see RoleRoute in App.tsx).');
+ console.log(' → set CLINICPRO_USER/CLINICPRO_PASS to a user with the right role.');
+ }
+ if (Number(len) < 40) console.log(`⚠ page text is only ${len} chars — may be blank`);
+ ws.close();
+ } finally {
+ chrome.kill();
+ }
+}
+
+// ── Static inspection ──────────────────────────────────────────────────────
+
+/** URL path → the line in App.tsx → the page component file. */
+function inspect(url) {
+ const path = url.replace(/^https?:\/\/[^/]+/, '').replace(/^\/admin\/?/, '').split('?')[0];
+ const app = readFileSync(`${REPO}/assets/admin/App.tsx`, 'utf8');
+ const segs = path.split('/').filter(Boolean);
+
+ const routes = [...app.matchAll(/ /g)]
+ .map(([, p, el]) => ({ p, comp: (el.match(/<(\w+)\s*\/>/g) ?? []).pop() ?? el.trim() }));
+
+ const score = (rp) => {
+ const rs = rp.split('/').filter(Boolean);
+ if (rs.length !== segs.length) return -1;
+ return rs.every((s, i) => s.startsWith(':') || s === segs[i]) ? rs.length : -1;
+ };
+ const hit = routes.map((r) => ({ ...r, s: score(r.p) })).filter((r) => r.s >= 0)
+ .sort((a, b) => b.s - a.s)[0];
+
+ if (!hit) {
+ console.log(`no route matched "${path}". Routes:\n` + routes.map((r) => ' ' + r.p).join('\n'));
+ return;
+ }
+ const comp = hit.comp.replace(/[<>/\s]/g, '');
+ const imp = app.match(new RegExp(`import\\s+${comp}\\s+from\\s+'([^']+)'`));
+ const file = imp ? `assets/admin/${imp[1].replace(/^\.\//, '')}.tsx` : '(inline element)';
+
+ console.log(`route ${hit.p}`);
+ console.log(`component ${comp}`);
+ console.log(`file ${file}`);
+
+ const abs = `${REPO}/${file}`;
+ if (existsSync(abs)) {
+ const src = readFileSync(abs, 'utf8');
+ const ds = [...src.matchAll(/from\s+'\.\.\/components\/(ui\/)?([\w/]+)'/g)].map((m) => m[2]);
+ console.log(`components ${[...new Set(ds)].join(', ') || '(none)'}`);
+ console.log(`lines ${src.split('\n').length}`);
+ auditSource(file, src);
+ }
+}
+
+/** The anti-patterns this codebase keeps regrowing. Each one is a real past bug. */
+function auditSource(label, src) {
+ const tokens = readFileSync(`${REPO}/assets/admin/styles.css`, 'utf8');
+ const findings = [];
+ const push = (re, msg) => {
+ src.split('\n').forEach((line, i) => { if (re.test(line)) findings.push(`${label}:${i + 1} ${msg}`); });
+ };
+
+ push(/ — use SearchableSelect');
+ push(/className="btn"(?!\s*\+)/, '.btn with no variant — renders borderless/invisible');
+ push(/#[0-9a-fA-F]{6}\b/, 'hardcoded hex — use a var(--…) token');
+ push(/className="overlay"/, 'hand-rolled overlay — use the shared ');
+ push(/className="field"[\s\S]*? inside .field — .field is an inline box; use .field-block');
+
+ // var(--x) references that styles.css never defines (e.g. the dead --error).
+ for (const m of src.matchAll(/var\((--[\w-]+)/g)) {
+ if (!tokens.includes(`${m[1]}:`)) findings.push(`${label} undefined token ${m[1]}`);
+ }
+
+ console.log(findings.length ? '\nAUDIT\n' + [...new Set(findings)].map((f) => ' ' + f).join('\n')
+ : '\nAUDIT clean');
+}
+
+// ── CLI ────────────────────────────────────────────────────────────────────
+
+const [cmd, arg, ...rest] = process.argv.slice(2);
+const flag = (n, d) => { const i = rest.indexOf(`--${n}`); return i >= 0 ? rest[i + 1] : d; };
+
+if (cmd === 'shot' && arg) {
+ await shot(arg, {
+ out: flag('out', 'page.png'),
+ w: Number(flag('w', 1440)),
+ h: Number(flag('h', 900)),
+ wait: Number(flag('wait', 4000)),
+ full: rest.includes('--full'),
+ });
+} else if (cmd === 'inspect' && arg) {
+ inspect(arg);
+} else if (cmd === 'audit' && arg) {
+ auditSource(arg, readFileSync(resolve(REPO, arg), 'utf8'));
+} else {
+ console.log(`usage:
+ driver.mjs shot [--out f.png] [--w 1440] [--h 900] [--wait 4000] [--full]
+ driver.mjs inspect
+ driver.mjs audit `);
+ process.exit(1);
+}
diff --git a/CLAUDE.md b/CLAUDE.md
index 16da7e72..a4f73ccb 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1,178 +1,238 @@
# CLAUDE.md
-This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+Guidance for Claude Code when working in **ClinicPro** — a clinic management & appointment platform. A Symfony 7.4 REST API backend plus a React 19 admin SPA bundled inside Symfony via Webpack Encore.
-## Project Overview
+- **Local:** `https://clinic-pro.ddev.site` — **Admin:** `/admin` — **Swagger:** `/api/doc`
+- Runs inside **ddev**: prefix commands with `ddev exec`.
+- Entire product is Persian/Farsi, **RTL**, Jalali (Shamsi) dates. Keep new strings Persian, dates Jalali.
-**ClinicPro** — a clinic management and appointment booking platform migrated from Drupal to Symfony 7. It consists of a Symfony REST API backend and a React 19 admin SPA bundled inside Symfony via Webpack Encore.
-
-- **Local URL:** `https://clinic-pro.ddev.site`
-- **Admin panel:** `https://clinic-pro.ddev.site/admin`
-- **Swagger UI:** `https://clinic-pro.ddev.site/api/doc`
+> **Source of truth for design tokens is `assets/admin/styles.css`**, not `docs/admin-ui/ui-design-spec.md`. That spec doc is an older aspirational draft (purple palette, tiptap, date-io) that does **not** match the shipped code (indigo palette, CKEditor, jalaali-js). Trust the code.
---
-## Commands
+## Stack & versions
-All commands run inside ddev: prefix with `ddev exec` unless noted.
+### Backend (`composer.json`)
+- **PHP ≥ 8.2**, **Symfony 7.4**, **Doctrine ORM 3.6** + migrations, **MariaDB 11.8** (via ddev)
+- Auth: **JWT** (`lexik/jwt-authentication-bundle`)
+- Async/scheduled: `symfony/messenger` + `symfony/scheduler` + `symfony/redis-messenger`
+- API docs: `nelmio/api-doc-bundle` + `zircote/swagger-php`; CORS: `nelmio/cors-bundle`
+- Also: `symfony/uid`, `symfony/rate-limiter`, `altcha-org/altcha`, Twig, `symfony/ux-react`
+- PSR-4: `App\` → `src/`, tests `App\Tests\` → `tests/`
-### First-time setup
-```bash
-ddev exec composer install
-ddev exec php bin/console lexik:jwt:generate-keypair # generate JWT keys
-ddev exec php bin/console doctrine:migrations:migrate --no-interaction
-ddev exec yarn install && ddev exec yarn dev
-ddev exec php bin/console app:create-admin # create first admin user
-```
+### Frontend admin SPA (`package.json`)
+- **React 19** + **TypeScript 5**, bundled by **Webpack Encore 6** (not Vite)
+- **Tailwind CSS v4** (`@tailwindcss/postcss`, CSS-first config)
+- Server state: **TanStack Query v5** · Tables: **TanStack Table v8**
+- Client state: **Zustand 5** · Forms: **React Hook Form 7 + Zod 3** (`@hookform/resolvers`)
+- Routing: **React Router v7** · Icons: **Heroicons v2** · Charts: **Recharts 3**
+- Select: `react-select` · Rich text: **CKEditor 5** · Dates: `jalaali-js` · Maps: `leaflet`/`react-leaflet`
+- Toasts: `sonner` (+ `react-hot-toast`) · Font: **Vazirmatn** via `@fontsource/vazirmatn`
+- Tests: **Vitest** + Testing Library (jsdom)
-### Backend (PHP/Symfony)
-```bash
-ddev exec php bin/console cache:clear
-ddev exec php bin/console doctrine:migrations:migrate --no-interaction
-ddev exec php bin/console doctrine:migrations:diff --no-interaction # generate migration after entity change
-ddev exec php bin/console debug:router | grep api
-ddev exec php bin/console messenger:consume async # start queue worker (SMS, async jobs)
-ddev exec php bin/console messenger:consume scheduler_default # run scheduled tasks (expires unpaid bookings every 1 min)
-
-# Tests
-ddev exec php bin/phpunit
-ddev exec php bin/phpunit tests/SomeTest.php # single test file
-
-# Static analysis (level 5, with Symfony + Doctrine extensions)
-ddev exec php vendor/bin/phpstan analyse
-```
-
-### Frontend (React/TypeScript)
-```bash
-ddev exec yarn dev # one-off dev build (use this to check for errors)
-ddev exec yarn watch # watch mode
-ddev exec yarn build # production build
-
-# Type check only (faster)
-ddev exec npx tsc --noEmit --project tsconfig.json
-```
-
-> **Note:** The CSS build has a known `lightningcss.linux-arm64-gnu.node` native module error inside ddev — this is pre-existing and does not block JS/TS compilation. TypeScript errors only appear in TSC output.
+### Build entry points (`webpack.config.js`)
+Three Encore entries → `public/build/`:
+| Entry | Source | Purpose |
+|---|---|---|
+| `admin` | `assets/admin/index.tsx` | React admin SPA, mounted at `/admin/*` |
+| `app` | `assets/app.js` | Stimulus/UX React controllers |
+| `home` | `assets/home/index.js` | Public-facing pages |
---
-## Architecture
+## Commands (prefix with `ddev exec`)
-### Backend — `src/`
-
-Domain-driven structure; each domain is its own namespace under `App\\`:
+```bash
+# Backend
+php bin/console cache:clear
+php bin/console doctrine:migrations:diff --no-interaction # after any entity change
+php bin/console doctrine:migrations:migrate --no-interaction
+php bin/console debug:router | grep api
+php bin/console messenger:consume async # SMS / async jobs
+php bin/console messenger:consume scheduler_default # scheduled tasks
+php bin/phpunit # tests
+php vendor/bin/phpstan analyse # static analysis (level 5)
+# Frontend
+yarn dev # one-off build (use to check for errors)
+yarn watch # watch mode
+yarn build # production
+npx tsc --noEmit --project tsconfig.json # type check only (faster)
+yarn test # vitest
```
-src/
- Admin/Controller/AdminApiController.php # all admin-only list/stats endpoints
- Appointment/ Doctor/ Clinic/
- Auth/ Payment/ Rating/
- Blog/ Representation/ Secretary/
- Category/ Settlement/ Sms/
- Shared/Controller/BaseController.php # all controllers extend this
- Shared/Constant/ErrorCodes.php
+> Known: CSS build has a pre-existing `lightningcss.linux-arm64-gnu.node` native-module error inside ddev; it does not block JS/TS compilation. TypeScript errors still surface in TSC output.
+
+---
+
+## Folder structure & where things go
+
+### Backend — `src//`
+Domain-driven; each domain is its own namespace `App\\` holding its own layers:
```
+src//
+ Controller/ # HTTP endpoints — extend BaseController
+ Entity/ # Doctrine entities
+ Repository/ # Doctrine repositories (DQL / query builders)
+ Service/ # domain logic
+ Command/ # console commands (optional)
+```
+Domains include: `Doctor`, `Patient`, `Appointment`, `Payment`, `Clinic`, `ClinicInvitation`,
+`ClinicService`, `DoctorService`, `Secretary`, `Staff`, `Settlement`, `Billing`, `Subscription`,
+`Rating`, `Blog`, `Sms`, `Category`, `Location`, `Specialty`, `Insurance`, `Tag`, `Representation`,
+`Auth`, `Admin`, `Dashboard`, `UserProfile`, `Config`.
-**Every controller extends `BaseController`** which provides four response helpers:
-
-| Method | Shape | When to use |
-|--------|-------|-------------|
-| `$this->success($data)` | `{ success, data: $data }` | Single resource / action |
-| `$this->paginated($items, $total, $page, $limit)` | `{ success, data: $items[], meta: { totalRecords, totalPages, currentPage } }` | Admin list endpoints |
-| `$this->error($code, $message, $status)` | `{ success:false, errors:[{code,message}] }` | All error responses |
-| `$this->validationError($violations)` | `{ success:false, errors:[{code,field,message}] }` HTTP 422 | Input validation failures |
-
-**Domain exceptions:** throw `AppException(ErrorCodes::ERR_XXX, null, $httpStatus)` anywhere in the domain — `ExceptionSubscriber` catches it and calls `$this->error()` automatically. All error codes and their Persian messages live in `src/Shared/Constant/ErrorCodes.php`.
-
-**Critical pitfall — double-nested responses:**
-`$this->success(['data' => $rep->toArray()])` produces `{ data: { data: {...} } }`, so the frontend must extract with `data?.data?.data`. The `paginated()` helper does NOT nest — it returns `data` as a flat array.
+Shared infra:
+```
+src/Shared/Controller/BaseController.php # every controller extends this
+src/Shared/Constant/ErrorCodes.php # all error codes + Persian messages
+```
+Cross-cutting: `migrations/`, `config/`, `templates/`, `docs/api/` (endpoint docs).
### Frontend — `assets/admin/`
-
-Single-page app mounted at `/admin/*`:
-
```
-assets/admin/
- App.tsx # React Router routes
- pages/ # one file per page
- components/
- ui/ # DataTable, Modal, ConfirmDialog, PageHeader, StatusBadge, Pagination,
- # SearchableSelect, PersianDateInput, PersianCalendar, AppointmentStatusDropdown
- layout/ # AdminLayout, Sidebar, Topbar
- hooks/ # custom React hooks
- lib/api.ts # fetch wrapper (reads JWT from localStorage key: clinicpro-auth)
- lib/utils.ts # formatRial, formatNumber, formatDate, formatDateTime
- types/index.ts # all TypeScript interfaces
- stores/
- authStore.ts # Zustand auth store (persisted to localStorage)
- uiStore.ts # sidebar open/close state
+index.tsx # entry: mounts , QueryClientProvider, (sonner)
+App.tsx # React Router v7 routes
+pages/ # one file per page — XxxPage.tsx (~50 pages)
+components/
+ ui/ # shared design-system components (see below)
+ *.tsx # feature-specific composites (ServiceTariffModal, ImageCropModal, …)
+hooks/ # useSubscription, usePaymentConfig, usePwaInstall, …
+lib/api.ts # fetch wrapper; reads JWT from localStorage['clinicpro-auth']
+lib/utils.ts # formatRial, formatNumber, formatDate, formatDateTime
+types/index.ts # all shared TypeScript interfaces
+stores/ # authStore.ts (persisted), uiStore.ts (sidebar/ui)
+styles.css # Tailwind entry + all design tokens
```
-**Data fetching pattern:** TanStack Query v5 (`useQuery` / `useMutation`). Query keys use `['resource-name', page, filters]`.
-
-**API response types in `lib/api.ts`:**
-- `ApiResponse` — for single-resource responses: extract with `data?.data`
-- `PaginatedResponse` — for admin lists: items at `data?.data`, total at `data?.meta?.totalRecords`
-
-**Forms:** React Hook Form + Zod resolver. Schema defined with `z.object()`, type inferred with `z.infer`.
-
-### Auth
-
-- JWT stored in Zustand store → `localStorage['clinicpro-auth']` → `state.token`
-- `api.ts` reads it automatically for every request
-- Admin routes require `ROLE_ADMIN`. `#[IsGranted('ROLE_ADMIN')]` on controller class or method.
-- Public endpoints listed in `config/packages/security.yaml` under `public_endpoints` firewall pattern
-
-### Category / Bundle system
-
-Categories are polymorphic via a `bundle` string field. Used for: `state`, `city`, `specially_doctor`, `doctor_services`, `insurance_type`, `supplementary_insurance`, `tag`.
-
-City IDs (integer FK to `categories.id` where `bundle='city'`) are stored on entities like `Representation.cityId`. To get the city name, LEFT JOIN the categories table in DQL.
-
-### Database
-
-- MariaDB 11.8 via ddev
-- Doctrine ORM with integer Unix timestamps (`createdAt`, `updatedAt`) — **not** DateTime objects
-- All admin list queries in `AdminApiController` use DQL array hydration (`.getArrayResult()`) to avoid triggering non-existent getter errors on entities
-- Migrations in `migrations/` — always run `doctrine:migrations:diff` after entity changes
-
---
-## Key Patterns
+## Styling & design tokens
-**Adding a new admin list endpoint (backend):**
-1. Add method to `src/Admin/Controller/AdminApiController.php`
-2. Use `$this->em->createQueryBuilder()` with `->getArrayResult()` (never use entity getters in admin list queries)
-3. Return `$this->paginated($items, $total, $page, $limit)`
+Tailwind **v4**, imported in `assets/admin/styles.css`:
+```css
+@import "tailwindcss";
+@import "@fontsource/vazirmatn/{300..800}.css";
+@custom-variant dark (&:where(.dark, .dark *));
+@theme { --font-sans: "Vazirmatn", ui-sans-serif, system-ui, sans-serif; }
+```
+**All design tokens are CSS custom properties in the `:root` block of `styles.css`** — reference them via Tailwind arbitrary values (`bg-[var(--surface)]`) or plain CSS, do not hardcode hex:
-**Adding a new admin page (frontend):**
-1. Create `assets/admin/pages/XxxPage.tsx`
-2. Use `PaginatedResponse` with `useQuery`
-3. Extract: `data?.data` for items, `data?.meta?.totalRecords` for total
-4. Add route in `App.tsx`
-5. Add UI components: ``, ``, ``, ``
-
-**Category API endpoint pattern:** `GET /api/v1/categorys/{bundle}` (note: typo `categorys` is intentional — existing route). Response is double-nested: extract array with `data?.data?.data ?? []`.
-
----
-
-## Standing Rule — API Documentation
-
-**Whenever any API endpoint is created or modified** (controller file, route, request/response structure, error code, permission), the corresponding file in `docs/api/` **must be updated in the same session**.
-
-| Changed file | Doc to update |
+| Group | Tokens |
|---|---|
-| `src/Auth/*` | `docs/api/auth.md` |
-| `src/Doctor/*` | `docs/api/doctor.md` |
-| `src/Clinic/*` | `docs/api/clinic.md` + `docs/api/clinic-invitation.md` |
-| `src/Appointment/Controller/AppointmentController.php` | `docs/api/appointment.md` |
-| `src/Appointment/Controller/AppointmentSettings*` | `docs/api/appointment-settings.md` |
-| `src/Payment/*` | `docs/api/payment.md` |
-| `src/Settlement/*` | `docs/api/settlement.md` |
-| `src/Rating/*` | `docs/api/rating.md` |
-| `src/Secretary/*` | `docs/api/secretary.md` |
-| `src/Representation/*` | `docs/api/representation.md` |
-| `src/Sms/*` | `docs/api/sms.md` |
-| `src/Blog/*` | `docs/api/blog.md` |
-| `src/Admin/*` | `docs/api/admin.md` |
-| Category/Province/City controllers | `docs/api/location.md`, `docs/api/specialty.md`, `docs/api/insurance.md`, `docs/api/doctor-service.md`, `docs/api/tag.md` |
+| Brand | `--primary:#5559CE` (indigo), `--primary-600/700`, `--primary-soft/soft2`, `--on-primary` |
+| Accent | `--accent:#f0682a` (orange, matches clinic-pro-tauri), `--accent-600`, `--accent-bg` |
+| Surfaces | `--bg`, `--bg-2`, `--surface`, `--surface-2/3`, `--border`, `--border-2` |
+| Text | `--text`, `--text-2`, `--text-3` |
+| Status | `--success/-bg`, `--warning/-bg`, `--danger/-bg`, `--info/-bg`, `--violet/-bg` |
+| Stat cards | `--stat-{amber,violet,green,pink}-{bg,fg}` |
+| Radius | `--r-xs:7 · --r-sm:8 · --r:14 · --r-lg:18 · --r-xl:24 · --r-pill:999` (px) |
+| Shadow | `--shadow-sm`, `--shadow`, `--shadow-lg` |
+| Layout | `--sidebar-w:243`, `--collapsed-w:90`, `--topbar-h:64`, `--gap:20`, `--card-pad:22`, `--row-h:56` |
+| Motion | `--ease: cubic-bezier(.22,.61,.36,1)` |
+
+Theming: **dark mode** overrides via `[data-theme="dark"]`; **compact density** via `[data-density="compact"]`. oklch color versions applied under `@supports` with sRGB fallbacks for old WebKit.
+
+---
+
+## Design system components — `assets/admin/components/ui/`
+
+Reuse these before building new ones:
+
+`DataTable` (sortable, search, skeleton loading, empty state, bulk) · `Modal` · `ConfirmDialog` ·
+`PageHeader` (title + breadcrumb + action) · `StatCard` · `StatusBadge` · `Pagination` ·
+`SearchableSelect` · `AppointmentStatusDropdown` · `PersianDateInput` / `PersianDatePicker` /
+`PersianCalendar` · `MobileInput` · `PriceInput` · `Portal` · `FeatureGate` · `Altcha` ·
+`InviteDoctorModal` · `PwaInstallBanner` / `PwaLoginCard` / `NotificationMobileCard`.
+
+Feature composites (not generic) live one level up in `components/*.tsx`.
+
+---
+
+## Conventions
+
+**Naming:** pages `XxxPage.tsx`, components PascalCase, hooks `useXxx.ts`. Backend PSR-4 `App\\`; entities singular (`Doctor`), tables snake_case plural (`patient_records`).
+
+**Data fetching:** TanStack Query only — `useQuery` / `useMutation`. Query keys `['resource-name', page, filters]`. All HTTP goes through `lib/api.ts`, which injects the JWT automatically.
+- `ApiResponse` (single resource): extract with `data?.data`
+- `PaginatedResponse` (admin lists): items at `data?.data`, total at `data?.meta?.totalRecords`
+
+**State management:**
+- **Server state → TanStack Query** (cache, `staleTime: 30s`, `retry: 1`).
+- **Client state → Zustand.** `authStore` (JWT + user, persisted to `localStorage['clinicpro-auth']`), `uiStore` (sidebar/ui flags).
+
+**Forms:** React Hook Form + Zod resolver — `z.object({...})`, type via `z.infer`.
+
+**Routing:** React Router v7, `` in `index.tsx`, route table in `App.tsx`, SPA served at `/admin/*`.
+
+**Auth:** JWT issued by Symfony. Admin routes guard with `#[IsGranted('ROLE_ADMIN')]` on the controller class/method. Public endpoints are whitelisted in `config/packages/security.yaml`.
+
+---
+
+## Backend endpoint & model patterns
+
+### Response envelope — `BaseController` helpers
+Every controller `extends BaseController`:
+| Method | Shape |
+|---|---|
+| `$this->success($data)` | `{ success, data }` |
+| `$this->paginated($items, $total, $page, $limit)` | `{ success, data:[], meta:{ totalRecords, totalPages, currentPage } }` (flat — no nesting) |
+| `$this->error($code, $message, $status)` | `{ success:false, errors:[{code,message}] }` |
+| `$this->validationError($violations)` | `{ success:false, errors:[{code,field,message}] }` HTTP 422 |
+
+- **Errors:** throw `AppException(ErrorCodes::ERR_XXX, null, $httpStatus)` anywhere in a domain; `ExceptionSubscriber` catches it and formats via `$this->error()`. Codes + Persian messages in `src/Shared/Constant/ErrorCodes.php`.
+- **Pitfall — double nesting:** `$this->success(['data' => $x])` yields `{ data: { data: x } }`; the frontend must then read `data?.data?.data`. Prefer passing the payload directly.
+- **Admin list queries** use `->getArrayResult()` (array hydration), never entity getters, to avoid missing-getter errors.
+
+### Adding an endpoint (one domain)
+1. `src//Entity/Foo.php` — Doctrine entity (attributes; see model pattern).
+2. `src//Repository/FooRepository.php` — queries.
+3. `src//Service/FooService.php` — logic (optional but preferred).
+4. `src//Controller/FooController.php` — `extends BaseController`, route `/api/v1/...`, guard with `#[IsGranted]`, return a response helper.
+5. `ddev exec php bin/console doctrine:migrations:diff` then `migrate`.
+6. Update the matching `docs/api/*.md` (see Standing Rule).
+
+### Model (entity) pattern
+```php
+#[ORM\Entity(repositoryClass: FooRepository::class)]
+#[ORM\Table(name: 'foos')]
+class Foo
+{
+ #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column(type: 'integer')]
+ private ?int $id = null;
+
+ #[ORM\Column(type: 'string', length: 36, unique: true)]
+ private string $uuid; // Uuid::v4()->toRfc4122() in constructor
+
+ #[ORM\Column(name: 'created_at', type: 'integer')]
+ private int $createdAt; // Unix timestamp (time()), NOT DateTime
+
+ #[ORM\ManyToOne(targetEntity: User::class)]
+ #[ORM\JoinColumn(nullable: false, onDelete: 'RESTRICT')]
+ private User $user;
+}
+```
+Conventions: integer surrogate `id`; string `uuid` (v4) for external references; **timestamps are `int` Unix**, not DateTime; column names snake_case via `name:`; relations `ManyToOne` / `OneToMany`.
+
+### Category / bundle system
+Categories are polymorphic via a `bundle` string: `state`, `city`, `specially_doctor`, `doctor_services`, `insurance_type`, `supplementary_insurance`, `tag`. City is an integer FK to `categories.id` where `bundle='city'` (e.g. `Representation.cityId`) — LEFT JOIN `categories` in DQL to get the name. Endpoint: `GET /api/v1/categorys/{bundle}` (typo `categorys` is the real route); its response is double-nested → extract with `data?.data?.data ?? []`.
+
+---
+
+## Standing Rule — API docs
+
+**Whenever any endpoint is created or modified** (route, request/response shape, error code, permission), update the matching file in `docs/api/` **in the same session**. Mapping mirrors `src/` → `docs/api/.md` (e.g. `src/Doctor/*` → `docs/api/doctor.md`, `src/Admin/*` → `docs/api/admin.md`, Category/Location controllers → `docs/api/location.md` + `specialty.md` + `insurance.md` + `tag.md`).
+
+## Project skills
+`.claude/skills/`: `add-admin-endpoint`, `add-admin-page`, `sync-db`, `prompt-writer`, `run-prompt`. Prefer them over hand-rolling. Test users: `TEST_USERS.md` (rebuild: `ddev exec php create_test_users.php`).
+
+---
+
+## قواعد غیرقابلمذاکره
+1. SOLID در هر کد جدید. کامپوننت/کلاس چندمسئولیتی ننویس.
+2. API جدید فقط وقتی هیچ اندپوینت موجودی — حتی با توسعه — کافی نباشد.
+ همیشه اول بگرد، بعد توسعه بده، در آخر بساز. و دلیلش را بنویس.
+3. مستندات کوتاه و دقیق روی هر چیز عمومی. کامنت بدیهی ننویس.
+4. هیچ تسکی بدون تست (موفق + خطا + مرزی) و بدون اجرای موفق تستها تمامشده نیست.
+5. ورودی من فارسی است. اول منظورم را به spec انگلیسی تبدیل کن و برداشتت را
+ به فارسی تأیید بگیر. کد و مستندات و کامیت انگلیسی؛ گفتوگو با من فارسی.
+ رشتههای UI فارسی و از فایل i18n.
diff --git a/TEST_USERS.md b/TEST_USERS.md
index 3270b881..d412ee5f 100644
--- a/TEST_USERS.md
+++ b/TEST_USERS.md
@@ -2,108 +2,109 @@
**پنل ادمین:** https://clinic-pro.ddev.site/admin
-> رمز عبور همه کاربران seedشده: `Test@1234`
+> رمز عبور همهٔ پرسوناها: `QaTest@1234`
+
+این فایل وضعیت واقعی دیتابیس لوکال پس از بازسازی کامل (drop → migrate → seed) را
+توصیف میکند. صحتش با `node .claude/skills/qa-clinicpro/driver.mjs roles` قابل
+تأیید است — اگر ردیفی `✗` گرفت، این فایل کهنه شده است.
---
-## ادمین
+## پرسوناها
-| فیلد | مقدار |
-| ------ | --------------- |
-| موبایل | `09100000001` |
-| پسورد | `Test@1234` |
-| نقش | `ROLE_ADMIN` |
-| نام | مدیر سیستم |
+واحد کار «پرسونا» است نه `ROLE_*`؛ پزشک مستقل و پزشک عضو کلینیک هر دو `ROLE_DOCTOR`
+دارند ولی دادهٔ متفاوتی میبینند.
+
+| پرسونا | موبایل | نقشها | تمایز |
+|---|---|---|---|
+| `admin` | `09120671756` | `ROLE_ADMIN` | — |
+| `clinic` | `09127000000` | `ROLE_CLINIC` | مالک «کلینیک تست QA» |
+| `secretary` | `09123456778` | `ROLE_SECRETARY` | منشیِ `doctor_solo` |
+| `doctor` | `09390039833` | `ROLE_DOCTOR` | پزشک ساده، بدون کلینیک |
+| `representation` | `09124000001` | `ROLE_REPRESENTATION` | نمایندهٔ شهری |
+| `doctor_solo` | `09129000001` | `ROLE_DOCTOR` | مطب شخصی، بدون کلینیک |
+| `doctor_member` | `09129000002` | `ROLE_DOCTOR` | عضو «کلینیک تست QA» → موقع ورود «انتخاب محیط کاری» میبیند |
+| `clinic_doctor` | `09129000003` | `ROLE_CLINIC` + `ROLE_DOCTOR` | چندنقشی، مالک «کلینیک تست چندنقشی» |
+| `secretary_clinic` | `09129000004` | `ROLE_SECRETARY` | منشیِ `doctor_member` در کلینیک |
+| `unclaimed_doctor` | `09129000005` | `ROLE_UNCLAIMED_DOCTOR` | — |
+| `patient` | `09129000006` | `ROLE_USER` | کاربر عادی سایت |
+| `importer` | `09129000007` | `ROLE_IMPORTER` | — |
+
+`patient`، `unclaimed_doctor` و `importer` به پنل مدیریت دسترسی ندارند و در صفحهٔ
+ورود پیام «حساب شما دسترسی به پنل مدیریت را ندارد» میگیرند. این باگ نیست:
+`PasswordAuthenticator` هر کاربری را که `User::isStaff()` نباشد رد میکند.
+
+## شناسهها
+
+| موجودیت | نام | UUID |
+|---|---|---|
+| پزشک `doctor_solo` | سارا مستقل | `01e2a9b4-72f4-4a48-924c-0f95bb77a994` |
+| پزشک `doctor_member` | رضا عضوکلینیک | `439c9935-77bc-4f72-b73d-2432712bb6f5` |
+| پزشک `clinic_doctor` | نیما چندنقشی | `e3e4c2bf-170a-479c-a385-4af7d57fcbbe` |
+| پزشک `doctor` | کاوه قدیمی | `c3311b98-86b7-4d8e-8538-1390c36c2a90` |
+| پروفایل تصاحبنشده | تصاحب نشده تست | `ded7a65d-d0fa-47e0-bc16-e801c5c75147` |
+| کلینیک تست QA | — | `bcb00726-2343-4d63-90c6-d0175cc74591` |
+| کلینیک تست چندنقشی | — | `e62f69a2-381b-4a6c-9235-7f9c173f3c46` |
+
+هر سه پزشکِ `doctor_solo` / `doctor_member` / `clinic_doctor` آدرس مطب، تخصص و
+برنامهٔ هفتگی (شنبه تا چهارشنبه، ۰۹:۰۰–۱۳:۰۰ و ۱۶:۰۰–۱۹:۰۰، اسلات ۲۰ دقیقهای)
+دارند، پس صفحات نوبتدهیشان خالی نیستند.
+
+## دادهٔ انبوه
+
+`app:seed-demo-data` حدود ۸٬۴۰۰ کاربر، ۱۸۰ پزشک، ۲۰۰ کلینیک، ۲۵ نماینده و ۱۵٬۰۰۰
+نوبت میسازد — برای تست «دادهٔ زیاد» نیازی به seed اضافه نیست.
---
-## کلینیک نمونه — تبریز
+## بازسازی از صفر
-| فیلد | مقدار |
-| ----------- | -------------------------------------- |
-| موبایل | `09100100000` |
-| پسورد | `Test@1234` |
-| نقش | `ROLE_CLINIC` |
-| نام | کلینیک تخصصی امید تبریز |
-| UUID کلینیک | `9ac73318-d313-4772-bf1e-418d47f8f4bc` |
-| شهر | تبریز (id: 101) |
-
----
-
-## دکتر نمونه کامل — تبریز
-
-| فیلد | مقدار |
-| ----------- | -------------------------------------- |
-| موبایل | `09100100001` |
-| پسورد | `Test@1234` |
-| نقش | `ROLE_DOCTOR` |
-| نام | دکتر آرمان رضایی |
-| تخصص | قلب و عروق + داخلی |
-| UUID دکتر | `2a3a7ab9-8d34-4118-862f-b458bcd6d77f` |
-| کلینیک | کلینیک تخصصی امید تبریز (عضو) |
-| آدرس مطب | تبریز، خیابان آزادی |
-| برنامه کاری | شنبه–چهارشنبه ۹–۱۳ و ۱۴–۱۸، پنجشنبه ۹–۱۳ |
-| بیمه | تأمین اجتماعی، خدمات درمانی، نیروهای مسلح، ایران |
-
----
-
-## منشی دکتر نمونه
-
-| فیلد | مقدار |
-| ------ | ------------------- |
-| موبایل | `09100100002` |
-| پسورد | `Test@1234` |
-| نقش | `ROLE_SECRETARY` |
-| نام | خانم نرگس صادقی |
-| مرتبط | دکتر آرمان رضایی |
-
----
-
-## دکتران bulk (۱۵۰۰ دکتر در ۱۵ شهر)
-
-هر شهر ۱۰۰ دکتر با توزیع واقعی تخصص:
-
-| شهر | شروع موبایل | تعداد |
-| ---------- | ------------- | ----- |
-| تبریز | `09100100003` | ۱۰۰ |
-| ارومیه | `09100100103` | ۱۰۰ |
-| اردبیل | `09100100203` | ۱۰۰ |
-| اصفهان | `09100100303` | ۱۰۰ |
-| کرج | `09100100403` | ۱۰۰ |
-| تهران | `09100100503` | ۱۰۰ |
-| مشهد | `09100100603` | ۱۰۰ |
-| اهواز | `09100100703` | ۱۰۰ |
-| شیراز | `09100100803` | ۱۰۰ |
-| کرمان | `09100100903` | ۱۰۰ |
-| کرمانشاه | `09100101003` | ۱۰۰ |
-| رشت | `09100101103` | ۱۰۰ |
-| ساری | `09100101203` | ۱۰۰ |
-| همدان | `09100101303` | ۱۰۰ |
-| یزد | `09100101403` | ۱۰۰ |
-
-توزیع تخصص در هر شهر:
-- ۱۵ پزشک عمومی
-- ۱۰ داخلی + قلب
-- ۸ جراحی عمومی
-- ۸ زنان و زایمان
-- ۸ اطفال
-- ۷ ارتوپدی
-- ۶ گوارش، نورولوژی، پوست، چشمپزشکی، دندانپزشکی (هر کدام ۶)
-- ۵ ENT، روانپزشکی (هر کدام ۵)
-- ۴ اورولوژی
-
----
-
-## ساخت مجدد
-
-اگر دیتابیس ریست شد، ادمین را مستقیم در دیتابیس بساز:
+ترتیب اجباری است — وابستگیها چرخهایاند:
```bash
-ddev mysql -e "INSERT INTO users (uuid, mobile_number, password_hash, real_name, roles, status, created_at, updated_at) VALUES (UUID(), '09100000001', '\$2y\$13\$8.5nFvKRxQGAXfJMXPB6sO.eR.c1RNxj0nJalcQfhzFTiEXnfaJbG', 'مدیر سیستم', '[\"ROLE_USER\",\"ROLE_ADMIN\"]', 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP());"
+ddev exec php bin/console doctrine:schema:drop --full-database --force
+ddev exec php bin/console doctrine:migrations:migrate --no-interaction
+ddev exec php bin/console app:create-admin 09120671756 'QaTest@1234'
+# نمایندهها باید قبل از شهرها باشند: data/seed/cities.json به representation_id
+# های ۱ تا ۳ ارجاع میدهد و app:seed-categories اعتبارسنجیشان میکند.
+# POST /api/v1/representation ×۳ (با توکن ادمین)
+ddev exec php bin/console app:seed-categories --no-interaction
+ddev exec php bin/console app:seed-sms-message-templates --no-interaction
+ddev exec php bin/console app:seed-demo-data --purge --no-interaction
```
-برای seed دیتای واقعی دکتران و کلینیک:
+`app:seed-demo-data` خودش بازهٔ `09124000%` را مالک است و نمایندههای مرحلهٔ قبل را
+purge و بازسازی میکند؛ بعد از آن `cities.representation_id` به شناسههای قدیمی اشاره
+میکند و باید به شناسههای جدید نگاشت شود.
-```bash
-ddev exec php seed_realistic_data.php
-```
+سپس پرسوناها از راه اندپوینتهای خود اپ ساخته میشوند:
+`POST /api/v1/admin/doctors` · `POST /api/v1/admin/clinic` ·
+`POST /api/v1/admin/clinic/{uuid}/invite-doctor` + `POST /api/v1/doctor/invitation/{uuid}/respond` ·
+`POST /api/v1/secretary` · `POST /api/v1/admin/doctors/import` ·
+`send-code → verify-code → register` برای `patient`.
+
+`ROLE_IMPORTER` و `ROLE_UNCLAIMED_DOCTOR` **هیچ مسیر اپلیکیشنی ندارند** — نگاشت نقش
+در `AdminApiController::updateUserRole` فقط `admin/doctor/secretary/clinic/patient`
+را میشناسد، پس این دو با SQL مستقیم ست میشوند.
+
+## نکتهها
+
+- **کپچا:** نصب تازه `ALTCHA_ENABLED=true` دارد و override پنل خالی است، پس لاگین و
+ ثبتنام اسکریپتی رد میشود. از پنل ادمین یا
+ `PATCH /api/v1/admin/settings {"altcha_enabled":"0"}` غیرفعالش کن.
+- **کد OTP در dev همیشه `12345` است** (`OtpService::sendCode`).
+- **`send-code` سقف ۵ درخواست در ساعت بهازای هر IP دارد.** برای تستهای انبوه توکن را
+ مستقیم بساز:
+ `ddev exec 'php bin/console lexik:jwt:generate-token --user-class="App\\Auth\\Entity\\User"'`
+- **رمز پس از ریست:** `ddev exec php bin/console security:hash-password 'QaTest@1234'`
+ و هش را در `users.password_hash` بگذار. کاربرانی که از راه `POST /api/v1/admin/doctors`
+ یا `/api/v1/admin/clinic` ساخته میشوند رمز نمیگیرند.
+
+## نام پزشک بدون عنوان
+
+نام ذخیرهشدهٔ پزشک **هرگز** پیشوند «دکتر» ندارد؛ همهٔ مسیرهای ثبت و ویرایش آن را با
+`PersianText::stripDoctorTitle()` حذف میکنند. افزودن عنوان کار لایهٔ نمایش است —
+`nobat724_front` برای عنوان صفحه و JSON-LD از `doctorTitle()` استفاده میکند (idempotent)،
+و پنل ادمین اصلاً عنوان اضافه نمیکند.
+
+پاکسازی دادهٔ قدیمی: `php bin/console app:doctors:fix-irimc-names --all --dry-run`
diff --git a/assets/admin/App.tsx b/assets/admin/App.tsx
index 04093dd5..7f702ff2 100644
--- a/assets/admin/App.tsx
+++ b/assets/admin/App.tsx
@@ -1,6 +1,7 @@
import React, { useEffect } from 'react';
import { Routes, Route, Navigate, useLocation } from 'react-router-dom';
import { useAuthStore } from './stores/authStore';
+import { usePermissions } from './hooks/usePermissions';
import AdminLayout from './components/layout/AdminLayout';
import LoginPage from './pages/LoginPage';
import DashboardPage from './pages/DashboardPage';
@@ -13,7 +14,10 @@ import DoctorFormPage from './pages/DoctorFormPage';
import ClinicsPage from './pages/ClinicsPage';
import ClinicDetailPage from './pages/ClinicDetailPage';
import AppointmentsPage from './pages/AppointmentsPage';
+import AppointmentCreatePage from './pages/AppointmentCreatePage';
import AppointmentDetailPage from './pages/AppointmentDetailPage';
+import AppointmentEditPage from './pages/AppointmentEditPage';
+import ReserveAppointmentsPage from './pages/ReserveAppointmentsPage';
import PaymentsPage from './pages/PaymentsPage';
import PaymentDetailPage from './pages/PaymentDetailPage';
import SettlementsPage from './pages/SettlementsPage';
@@ -28,7 +32,7 @@ import CategoriesPage from './pages/CategoriesPage';
import BlogsPage from './pages/BlogsPage';
import BlogFormPage from './pages/BlogFormPage';
import SecretariesPage from './pages/SecretariesPage';
-import MyClinicPage from './pages/MyClinicPage';
+import ClinicDoctorsPage from './pages/ClinicDoctorsPage';
import SettingsPage from './pages/SettingsPage';
import FinancialReportPage from './pages/FinancialReportPage';
import RepresentationSettlementPage from './pages/RepresentationSettlementPage';
@@ -36,19 +40,36 @@ import RepresentationFinancePage from './pages/RepresentationFinancePage';
import RepresentationProfilePage from './pages/RepresentationProfilePage';
import DoctorProfilePage from './pages/DoctorProfilePage';
import MyPatientsPage from './pages/MyPatientsPage';
+import MyPaymentsPage from './pages/MyPaymentsPage';
+import MyPaymentDetailPage from './pages/MyPaymentDetailPage';
import NewSessionPage from './pages/NewSessionPage';
+import EditSessionPage from './pages/EditSessionPage';
+import SessionPaymentPage from './pages/SessionPaymentPage';
import InsurancePricingPage from './pages/InsurancePricingPage';
import ClaimsPage from './pages/ClaimsPage';
+import ClaimPatientDetailPage from './pages/ClaimPatientDetailPage';
import DoctorClaimsPage from './pages/DoctorClaimsPage';
import MyFinancialPage from './pages/MyFinancialPage';
import ClinicFormPage from './pages/ClinicFormPage';
import PreRegistrationsPage from './pages/PreRegistrationsPage';
import StaffPage from './pages/StaffPage';
import SubscriptionPage from './pages/SubscriptionPage';
+import DiscountsPage from './pages/DiscountsPage';
import ClinicServicesPage from './pages/ClinicServicesPage';
+import ServiceDetailPage from './pages/ServiceDetailPage';
import SmsWalletPage from './pages/SmsWalletPage';
import MySecretariesPage from './pages/MySecretariesPage';
import AdminSubscriptionPage from './pages/AdminSubscriptionPage';
+import SettingsMenuPage from './pages/SettingsMenuPage';
+import AccountSettingsPage from './pages/AccountSettingsPage';
+import TagsSettingsPage from './pages/TagsSettingsPage';
+import AppointmentSettingsPage from './pages/AppointmentSettingsPage';
+import ClinicAppointmentSettingsPage from './pages/ClinicAppointmentSettingsPage';
+import PatientsListPage from './pages/PatientsListPage';
+import InventoryPage from './pages/InventoryPage';
+import PatientRecordFormPage from './pages/PatientRecordFormPage';
+import PatientDetailPage from './pages/PatientDetailPage';
+import PaymentSuccessPage from './pages/PaymentSuccessPage';
import PwaInstallBanner from './components/ui/PwaInstallBanner';
// ── Guards ──────────────────────────────────────────────────────────────────
@@ -97,14 +118,23 @@ function PublicRoute({ children }: { children: React.ReactNode }) {
return isAuthenticated ? : <>{children}>;
}
-function RoleRoute({ roles, blockClinicScope, children }: { roles: string[]; blockClinicScope?: boolean; children: React.ReactNode }) {
+function RoleRoute({ roles, blockClinicScope, permission, children }: {
+ roles: string[];
+ blockClinicScope?: boolean;
+ /** [resource, action] — پزشکِ مهمان با داشتن این مجوز از blockClinicScope مستثنا میشود. */
+ permission?: [string, string];
+ children: React.ReactNode;
+}) {
const primaryRole = useAuthStore((s) => s.primaryRole);
const context = useAuthStore((s) => s.context);
+ const { can } = usePermissions();
if (!primaryRole) return در حال بارگذاری...
;
if (!roles.includes(primaryRole)) return ;
- // پزشکِ مهمان در محیط کلینیک به ابزارهای مدیریتی دسترسی ندارد.
+ // پزشکِ مهمان در محیط کلینیک فقط تا جایی که کلینیک مجوز داده دسترسی دارد.
if (blockClinicScope && primaryRole === 'doctor' && context?.scope === 'clinic') {
- return ;
+ if (!permission || !can(permission[0], permission[1])) {
+ return ;
+ }
}
return <>{children}>;
}
@@ -143,7 +173,10 @@ export default function App() {
{/* نوبتها — همه نقشها بهجز نماینده */}
} />
+ } />
+ } />
} />
+ } />
{/* فقط ادمین */}
} />
@@ -167,8 +200,11 @@ export default function App() {
} />
} />
- {/* کلینیک من — fallback اگر dbUuid هنوز لود نشده */}
- } />
+ {/* پزشکان کلینیک — تب تنظیماتِ مالک کلینیک */}
+ } />
+ } />
+ {/* مسیر قدیمی «مدیریت مطب» → ریدایرکت به تب جدید */}
+ } />
{/* ادمین + کلینیک */}
} />
@@ -185,15 +221,36 @@ export default function App() {
{/* دکتر / منشی / کلینیک */}
} />
+ } />
+ } />
+
+ } />
+ } />
+ } />
+ } />
+ } />
} />
+ } />
+ } />
+ } />
+ } />
} />
} />
+ } />
} />
{/* فاز ۲ — دکتر / کلینیک */}
} />
+ } />
+ } />
+ } />
+ } />
} />
+ } />
+ } />
} />
+ } />
+ } />
} />
} />
} />
diff --git a/assets/admin/components/AppointmentActions.test.tsx b/assets/admin/components/AppointmentActions.test.tsx
new file mode 100644
index 00000000..a9643fc5
--- /dev/null
+++ b/assets/admin/components/AppointmentActions.test.tsx
@@ -0,0 +1,124 @@
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { screen, fireEvent, waitFor } from '@testing-library/react';
+import { renderWithProviders } from '../test/utils';
+
+vi.mock('../lib/api', () => ({
+ api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() },
+ ApiError: class extends Error {},
+}));
+
+import { api } from '../lib/api';
+import AppointmentActionsMenu from './AppointmentActions';
+import type { Appointment } from '../types';
+
+const get = api.get as ReturnType;
+const patch = api.patch as ReturnType;
+
+const appt: Appointment = {
+ uuid: 'ap1', patient_name: 'مریم خلیلی', patient_mobile: '09136549874',
+ doctor_uuid: 'd1', doctor_name: 'دکتر احمدی',
+ slot_start: 1735639200, slot_end: 1735641900, // 45 min
+ appointment_date: '2024-12-31', appointment_time: '09:00', end_time: '09:45',
+ status: 'confirmed', version: 3, created_at: '',
+ service_section: { uuid: 's1', name: 'زیبایی' },
+ service_item: { uuid: 'i1', name: 'لیزر توتال' },
+ staff: { uuid: 'st1', full_name: 'دکتر حمیدی' },
+};
+
+beforeEach(() => {
+ get.mockReset(); patch.mockReset();
+ get.mockImplementation((url: string) => {
+ if (url.startsWith('/api/v1/patient?search=')) return Promise.resolve({ success: true, data: [{ uuid: 'rec1' }] });
+ if (url === '/api/v1/patient/rec1/wallet') return Promise.resolve({ success: true, data: { balance_rials: 500000, recent_transactions: [] } });
+ return Promise.resolve({ success: true, data: [] });
+ });
+ patch.mockResolvedValue({ success: true, data: {} });
+});
+
+function openMenu() {
+ renderWithProviders( );
+ fireEvent.click(screen.getByRole('button', { name: 'عملیات' }));
+}
+
+describe('AppointmentActionsMenu (عملیات نوبت)', () => {
+ it('lists all six actions from the Figma menu', () => {
+ openMenu();
+ for (const label of ['ویرایش', 'ثبت سرویس', 'مشاهده', 'جا به جایی نوبت', 'انتقال به لیست رزرو', 'جایگزینی نوبت']) {
+ expect(screen.getByText(label)).toBeInTheDocument();
+ }
+ });
+
+ it('info modal shows appointment details and patient wallet balance', async () => {
+ openMenu();
+ fireEvent.click(screen.getByText('مشاهده'));
+ expect(await screen.findByText('ساعت شروع:')).toBeInTheDocument();
+ expect(screen.getByText('۴۵ دقیقه')).toBeInTheDocument();
+ expect(screen.getByText('لیزر توتال')).toBeInTheDocument();
+ expect(screen.getByText('دکتر حمیدی')).toBeInTheDocument();
+ // wallet resolved through record search → balance shown (rial → toman)
+ expect(await screen.findByText(/تومان/)).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'مشاهده پرونده' })).toBeInTheDocument();
+ });
+
+ it('move modal patches new slot times', async () => {
+ openMenu();
+ fireEvent.click(screen.getByText('جا به جایی نوبت'));
+ expect(await screen.findByText('اعمال تغییرات')).toBeInTheDocument();
+ fireEvent.change(screen.getByLabelText('ساعت شروع'), { target: { value: '15:00' } });
+ fireEvent.change(screen.getByLabelText('ساعت پایان'), { target: { value: '16:00' } });
+ fireEvent.click(screen.getByText('اعمال تغییرات'));
+ await waitFor(() => expect(patch).toHaveBeenCalledWith('/api/v1/appointment/ap1', expect.objectContaining({
+ slot_start: Math.floor(new Date('2024-12-31T15:00').getTime() / 1000),
+ slot_end: Math.floor(new Date('2024-12-31T16:00').getTime() / 1000),
+ version: 3,
+ })));
+ });
+
+ it('transfer modal flips is_reserve with a day-level slot', async () => {
+ openMenu();
+ fireEvent.click(screen.getByText('انتقال به لیست رزرو'));
+ expect(await screen.findByText(/به لیست نوبت های رزرو شده منتقل می شود/)).toBeInTheDocument();
+ fireEvent.click(screen.getByText('انتقال و حذف از لیست'));
+ const day = Math.floor(new Date('2024-12-31T00:00').getTime() / 1000);
+ await waitFor(() => expect(patch).toHaveBeenCalledWith('/api/v1/appointment/ap1', expect.objectContaining({
+ is_reserve: true, slot_start: day, slot_end: day, version: 3,
+ })));
+ });
+
+ it('replace modal swaps the patient and keeps the slot locked', async () => {
+ openMenu();
+ fireEvent.click(screen.getByText('جایگزینی نوبت'));
+ expect(await screen.findByPlaceholderText('نام و نام خانوادگی')).toBeInTheDocument();
+ // the original slot is shown read-only
+ expect(screen.getByDisplayValue('2024-12-31')).toBeDisabled();
+ expect(screen.getByDisplayValue('09:00')).toBeDisabled();
+ // prefilled from the appointment's current specs (react-select single value)
+ expect(screen.getByText('قطعی شده')).toBeInTheDocument();
+
+ fireEvent.change(screen.getByPlaceholderText('نام و نام خانوادگی'), { target: { value: 'ساغر صابری' } });
+ fireEvent.change(screen.getByPlaceholderText('شماره تماس'), { target: { value: '09356619438' } });
+ fireEvent.click(screen.getByText('ثبت نوبت'));
+ await waitFor(() => expect(patch).toHaveBeenCalledWith('/api/v1/appointment/ap1', expect.objectContaining({
+ patient_name: 'ساغر صابری', patient_mobile: '09356619438',
+ service_section_uuid: 's1', service_item_uuid: 'i1', staff_uuid: 'st1',
+ version: 3,
+ })));
+ });
+
+ it('replace modal picks an existing patient from the record search', async () => {
+ get.mockImplementation((url: string) => {
+ if (url.startsWith('/api/v1/patient?search=')) return Promise.resolve({ success: true, data: [
+ { uuid: 'rec9', user_name: 'پریسا همتی', user_mobile: '09120009999' },
+ ] });
+ return Promise.resolve({ success: true, data: [] });
+ });
+ openMenu();
+ fireEvent.click(screen.getByText('جایگزینی نوبت'));
+ fireEvent.change(await screen.findByPlaceholderText('جستجوی نام، شماره تماس، شماره پرونده...'), { target: { value: 'پریسا' } });
+ fireEvent.click(await screen.findByText('پریسا همتی'));
+ fireEvent.click(screen.getByText('ثبت نوبت'));
+ await waitFor(() => expect(patch).toHaveBeenCalledWith('/api/v1/appointment/ap1', expect.objectContaining({
+ patient_name: 'پریسا همتی', patient_mobile: '09120009999',
+ })));
+ });
+});
diff --git a/assets/admin/components/AppointmentActions.tsx b/assets/admin/components/AppointmentActions.tsx
new file mode 100644
index 00000000..67030854
--- /dev/null
+++ b/assets/admin/components/AppointmentActions.tsx
@@ -0,0 +1,1003 @@
+import {
+ ArrowDownOnSquareIcon,
+ ArrowPathIcon,
+ ArrowsRightLeftIcon,
+ ClockIcon,
+ EllipsisHorizontalIcon,
+ EyeIcon,
+ PencilIcon,
+ PhoneIcon,
+ PlusIcon,
+ Squares2X2Icon,
+ TagIcon,
+ UserIcon,
+ WalletIcon,
+} from "@heroicons/react/24/outline";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import React, { useEffect, useRef, useState } from "react";
+import ReactDOM from "react-dom";
+import { useNavigate } from "react-router-dom";
+import { toast } from "sonner";
+import type { ApiResponse } from "../lib/api";
+import { api } from "../lib/api";
+import { formatRial, tehranWallClockToUnix, rialToToman, tomanToRial } from "../lib/utils";
+import type { Appointment } from "../types";
+import AppointmentStatusDropdown from "./ui/AppointmentStatusDropdown";
+import Modal from "./ui/Modal";
+import PersianDateInput from "./ui/PersianDateInput";
+import PriceInput from "./ui/PriceInput";
+import SearchableSelect from "./ui/SearchableSelect";
+
+/** Row actions for the appointments table (Figma عملیات menu). */
+type ModalKind = null | "info" | "move" | "transfer" | "replace";
+
+const toEpoch = (isoDate: string, time: string) => tehranWallClockToUnix(isoDate, time);
+
+/**
+ * Resolve the patient-record uuid behind an appointment via the patient list
+ * search (mobile is unique per user). Returns null when no record exists yet.
+ */
+export async function findRecordUuid(mobile: string): Promise {
+ const res: any = await api.get(
+ `/api/v1/patient?search=${encodeURIComponent(mobile)}&limit=1`,
+ );
+ return res?.data?.[0]?.uuid ?? null;
+}
+
+/**
+ * «شارژ کیف پول» accent link (appointment create/edit forms) — deep-links the
+ * patient's wallet tab, where the manual top-up modal lives.
+ */
+export function WalletChargeLink({ mobile }: { mobile?: string }) {
+ const navigate = useNavigate();
+ const go = async () => {
+ if (!mobile || mobile.trim().length < 10) {
+ toast.error("ابتدا شماره تماس مراجعه کننده را وارد کنید");
+ return;
+ }
+ try {
+ const recordUuid = await findRecordUuid(mobile.trim());
+ if (!recordUuid) {
+ toast.error("پروندهای برای این بیمار یافت نشد");
+ return;
+ }
+ navigate(`/admin/patients/${recordUuid}?tab=wallet`);
+ } catch {
+ toast.error("خطا در یافتن پرونده بیمار");
+ }
+ };
+ return (
+
+ شارژ کیف پول ‹
+
+ );
+}
+
+export default function AppointmentActionsMenu({
+ appointment,
+ queryKey,
+}: {
+ appointment: Appointment;
+ queryKey: unknown[];
+}) {
+ const [open, setOpen] = useState(false);
+ const [modal, setModal] = useState(null);
+ const [menuPos, setMenuPos] = useState<{
+ top: number;
+ right: number;
+ } | null>(null);
+ const btnRef = useRef(null);
+ const menuRef = useRef(null);
+ const navigate = useNavigate();
+
+ useEffect(() => {
+ if (!open) return;
+ const handler = (e: MouseEvent) => {
+ const t = e.target as Node;
+ if (!btnRef.current?.contains(t) && !menuRef.current?.contains(t))
+ setOpen(false);
+ };
+ document.addEventListener("mousedown", handler);
+ return () => document.removeEventListener("mousedown", handler);
+ }, [open]);
+
+ const openMenu = () => {
+ if (!open && btnRef.current) {
+ const r = btnRef.current.getBoundingClientRect();
+ setMenuPos({
+ top: r.bottom + 4,
+ right: window.innerWidth - r.right,
+ });
+ }
+ setOpen((o) => !o);
+ };
+
+ const goToServiceRegistration = async () => {
+ setOpen(false);
+ try {
+ const recordUuid = await findRecordUuid(appointment.patient_mobile);
+ if (!recordUuid) {
+ toast.error("پروندهای برای این بیمار یافت نشد");
+ return;
+ }
+ navigate(`/admin/patients/${recordUuid}/session/new`);
+ } catch {
+ toast.error("خطا در یافتن پرونده بیمار");
+ }
+ };
+
+ const items: {
+ label: string;
+ icon: React.ElementType;
+ onClick: () => void;
+ }[] = [
+ {
+ label: "ویرایش",
+ icon: PencilIcon,
+ onClick: () => {
+ setOpen(false);
+ navigate(`/admin/appointments/${appointment.uuid}/edit`);
+ },
+ },
+ {
+ label: "ثبت سرویس",
+ icon: PlusIcon,
+ onClick: goToServiceRegistration,
+ },
+ {
+ label: "مشاهده",
+ icon: EyeIcon,
+ onClick: () => {
+ setOpen(false);
+ setModal("info");
+ },
+ },
+ {
+ label: "جا به جایی نوبت",
+ icon: ArrowsRightLeftIcon,
+ onClick: () => {
+ setOpen(false);
+ setModal("move");
+ },
+ },
+ {
+ label: appointment.is_reserve
+ ? "انتقال به لیست نوبتها"
+ : "انتقال به لیست رزرو",
+ icon: ArrowDownOnSquareIcon,
+ onClick: () => {
+ setOpen(false);
+ setModal("transfer");
+ },
+ },
+ {
+ label: "جایگزینی نوبت",
+ icon: ArrowPathIcon,
+ onClick: () => {
+ setOpen(false);
+ setModal("replace");
+ },
+ },
+ ];
+
+ return (
+ <>
+
+ عملیات
+
+
+ {open &&
+ menuPos &&
+ ReactDOM.createPortal(
+
+ {items.map(({ label, icon: Icon, onClick }) => (
+
+ (e.currentTarget.style.background =
+ "var(--surface-2)")
+ }
+ onMouseLeave={(e) =>
+ (e.currentTarget.style.background =
+ "transparent")
+ }
+ >
+ {" "}
+ {label}
+
+ ))}
+
,
+ document.body,
+ )}
+
+ {modal === "info" && (
+ setModal(null)}
+ />
+ )}
+ {modal === "move" && (
+ setModal(null)}
+ />
+ )}
+ {modal === "transfer" && (
+ setModal(null)}
+ />
+ )}
+ {modal === "replace" && (
+ setModal(null)}
+ />
+ )}
+ >
+ );
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// مشاهده — appointment info + patient wallet balance (Figma appointments-info)
+// ─────────────────────────────────────────────────────────────────────────────
+
+function InfoRow({
+ icon: Icon,
+ label,
+ value,
+ ltr,
+}: {
+ icon: React.ElementType;
+ label: string;
+ value?: string | null;
+ ltr?: boolean;
+}) {
+ return (
+
+
+ {label}
+
+
+ {value || "—"}
+
+
+ );
+}
+
+export function AppointmentInfoModal({
+ appointment: a,
+ queryKey,
+ onClose,
+}: {
+ appointment: Appointment;
+ queryKey: unknown[];
+ onClose: () => void;
+}) {
+ const navigate = useNavigate();
+
+ // record uuid → wallet balance + «مشاهده پرونده» target (both need the record)
+ const recordQ = useQuery({
+ queryKey: ["appt-record", a.patient_mobile],
+ queryFn: () => findRecordUuid(a.patient_mobile),
+ });
+ const walletQ = useQuery>({
+ queryKey: ["appt-wallet", recordQ.data],
+ queryFn: () => api.get(`/api/v1/patient/${recordQ.data}/wallet`),
+ enabled: !!recordQ.data,
+ });
+
+ const durationMin = Math.max(
+ 0,
+ Math.round((a.slot_end - a.slot_start) / 60),
+ );
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ recordQ.data &&
+ navigate(`/admin/patients/${recordQ.data}`)
+ }
+ >
+ مشاهده پرونده
+
+
+
+ );
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// جا به جایی نوبت — pick a new date + start/end time
+// ─────────────────────────────────────────────────────────────────────────────
+
+export function MoveAppointmentModal({
+ appointment: a,
+ queryKey,
+ onClose,
+}: {
+ appointment: Appointment;
+ queryKey: unknown[];
+ onClose: () => void;
+}) {
+ const qc = useQueryClient();
+ const [date, setDate] = useState(a.appointment_date);
+ const [start, setStart] = useState(a.appointment_time);
+ const [end, setEnd] = useState(a.end_time);
+
+ const move = useMutation({
+ mutationFn: () =>
+ api.patch(`/api/v1/appointment/${a.uuid}`, {
+ slot_start: toEpoch(date, start),
+ slot_end: toEpoch(date, end),
+ version: a.version,
+ }),
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey });
+ toast.success("نوبت جا به جا شد");
+ onClose();
+ },
+ onError: (e: any) => toast.error(e.message || "خطا در جا به جایی نوبت"),
+ });
+
+ return (
+
+
+
+ انتخاب تاریخ
+
+
+
+
+
+ ساعت شروع
+
+
+ setStart(e.target.value)}
+ dir="ltr"
+ />
+
+
+
+
+ ساعت پایان
+
+
+ setEnd(e.target.value)}
+ dir="ltr"
+ />
+
+
+
+
move.mutate()}
+ >
+ اعمال تغییرات
+
+
+
+ );
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// انتقال به لیست رزرو (و بازگشت) — flips is_reserve for a chosen day
+// ─────────────────────────────────────────────────────────────────────────────
+
+export function TransferReserveModal({
+ appointment: a,
+ queryKey,
+ onClose,
+}: {
+ appointment: Appointment;
+ queryKey: unknown[];
+ onClose: () => void;
+}) {
+ const qc = useQueryClient();
+ const [date, setDate] = useState(a.appointment_date);
+ const toReserve = !a.is_reserve;
+
+ const transfer = useMutation({
+ mutationFn: () => {
+ const day = toEpoch(date, "00:00");
+ return api.patch(
+ `/api/v1/appointment/${a.uuid}`,
+ toReserve
+ ? // reserve entries are day-level: midnight-to-midnight, no slot occupation
+ {
+ is_reserve: true,
+ slot_start: day,
+ slot_end: day,
+ version: a.version,
+ }
+ : {
+ is_reserve: false,
+ slot_start: toEpoch(date, a.appointment_time),
+ slot_end: toEpoch(date, a.end_time),
+ version: a.version,
+ },
+ );
+ },
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey });
+ toast.success(
+ toReserve
+ ? "به لیست رزرو منتقل شد"
+ : "به لیست نوبتها منتقل شد",
+ );
+ onClose();
+ },
+ onError: (e: any) => toast.error(e.message || "خطا در انتقال"),
+ });
+
+ return (
+
+
+
+
+ !
+
+ {toReserve
+ ? "نوبت از لیست نوبت ها حذف شده و به لیست نوبت های رزرو شده منتقل می شود."
+ : "نوبت از لیست رزرو حذف شده و به لیست نوبت ها منتقل می شود."}
+
+
+ انتخاب تاریخ
+
+
+
+ transfer.mutate()}
+ >
+ انتقال و حذف از لیست
+
+
+ انصراف
+
+
+
+
+ );
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// جایگزینی نوبت — put a different patient into the same slot
+// (appointments-replace.pdf: patient search-or-new, بخش/سرویس, deposit,
+// locked date/time, پرسنل, وضعیت, توضیحات)
+// ─────────────────────────────────────────────────────────────────────────────
+
+interface PickerOption {
+ uuid: string;
+ name?: string;
+ full_name?: string;
+}
+interface PickedPatient {
+ uuid: string;
+ user_name?: string;
+ user_mobile?: string;
+}
+
+export function ReplaceAppointmentModal({
+ appointment: a,
+ queryKey,
+ onClose,
+}: {
+ appointment: Appointment;
+ queryKey: unknown[];
+ onClose: () => void;
+}) {
+ const qc = useQueryClient();
+
+ // patient: search an existing record or enter a new person
+ const [patientSearch, setPatientSearch] = useState("");
+ const [picked, setPicked] = useState(null);
+ const [name, setName] = useState("");
+ const [mobile, setMobile] = useState("");
+ const patientsQ = useQuery>({
+ queryKey: ["replace-patients", patientSearch],
+ queryFn: () =>
+ api.get(
+ `/api/v1/patient?search=${encodeURIComponent(patientSearch)}&limit=10`,
+ ),
+ enabled: patientSearch.trim().length >= 2,
+ });
+
+ // service specs + staff + status
+ const [sectionUuid, setSectionUuid] = useState(
+ a.service_section?.uuid ?? "",
+ );
+ const [itemUuid, setItemUuid] = useState(a.service_item?.uuid ?? "");
+ const [staffUuid, setStaffUuid] = useState(a.staff?.uuid ?? "");
+ const [status, setStatus] = useState(a.status);
+ const sectionsQ = useQuery>({
+ queryKey: ["service-sections"],
+ queryFn: () => api.get("/api/v1/service-sections"),
+ });
+ const itemsQ = useQuery>({
+ queryKey: ["service-items", sectionUuid],
+ queryFn: () => api.get(`/api/v1/service-items/${sectionUuid}`),
+ enabled: !!sectionUuid,
+ });
+ const staffQ = useQuery>({
+ queryKey: ["staff-list"],
+ queryFn: () => api.get("/api/v1/staff"),
+ });
+
+ // deposit
+ const [depositRequired, setDepositRequired] = useState(
+ !!a.deposit_required,
+ );
+ const [depositToman, setDepositToman] = useState(
+ rialToToman(a.deposit_amount_rials ?? 0),
+ );
+ const [note, setNote] = useState("");
+
+ const effectiveName = picked?.user_name || name.trim();
+ const effectiveMobile = picked?.user_mobile || mobile.trim();
+
+ const replace = useMutation({
+ mutationFn: () =>
+ api.patch(`/api/v1/appointment/${a.uuid}`, {
+ patient_name: effectiveName,
+ patient_mobile: effectiveMobile,
+ service_section_uuid: sectionUuid,
+ service_item_uuid: itemUuid,
+ staff_uuid: staffUuid,
+ deposit_required: depositRequired,
+ deposit_amount_rials: depositRequired ? tomanToRial(depositToman) : null,
+ ...(note.trim() ? { note: note.trim() } : {}),
+ ...(status !== a.status ? { status } : {}),
+ version: a.version,
+ }),
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey });
+ toast.success("نوبت جایگزین شد");
+ onClose();
+ },
+ onError: (e: any) => toast.error(e.message || "خطا در جایگزینی نوبت"),
+ });
+
+ const label = { fontSize: 12.5, color: "var(--text-3)" } as const;
+ const lockedField = { margin: "6px 0 12px", opacity: 0.6 } as const;
+ const patients = patientsQ.data?.data ?? [];
+
+ const statusOptions: [string, string][] = [
+ ["pending", "ثبت شده"],
+ ["confirmed", "قطعی شده"],
+ ["following_up", "در حال پیگیری"],
+ ["salon", "سالن"],
+ ["completed", "ویزیت شده"],
+ ["cancelled_by_doctor", "لغو شده"],
+ ];
+
+ return (
+
+
+
انتخاب مراجعه کننده
+
+ {
+ setPicked(null);
+ setPatientSearch(e.target.value);
+ }}
+ placeholder="جستجوی نام، شماره تماس، شماره پرونده..."
+ />
+
+ {!picked && patients.length > 0 && (
+
+ {patients.map((p) => (
+ setPicked(p)}
+ style={{
+ display: "block",
+ width: "100%",
+ padding: "8px 10px",
+ fontSize: 13,
+ textAlign: "right",
+ background: "transparent",
+ border: "none",
+ cursor: "pointer",
+ fontFamily: "inherit",
+ }}
+ >
+ {p.user_name}{" "}
+
+ {p.user_mobile}
+
+
+ ))}
+
+ )}
+ {picked === null && (
+
+ )}
+
+
+
+
بخش
+
+ ({ value: o.uuid, label: o.name ?? "" }))}
+ value={sectionUuid || null}
+ onChange={(v) => { setSectionUuid(v ? String(v) : ""); setItemUuid(""); }}
+ placeholder="انتخاب بخش"
+ isLoading={sectionsQ.isLoading}
+ isClearable
+ height={38}
+ />
+
+
+
+
سرویس
+
+ ({ value: o.uuid, label: o.name ?? "" }))}
+ value={itemUuid || null}
+ onChange={(v) => setItemUuid(v ? String(v) : "")}
+ placeholder="انتخاب زیر بخش"
+ isDisabled={!sectionUuid}
+ isLoading={itemsQ.isLoading}
+ isClearable
+ height={38}
+ />
+
+
+
+
+
+
+
+ setDepositRequired(e.target.checked)
+ }
+ />
+ بیعانه مورد نیاز است.
+
+ {depositRequired && (
+
+ )}
+
+ {depositRequired && (
+
+
مبلغ بیعانه (تومان)
+
+
+ )}
+
+ {/* the replacement keeps the original slot — date/time locked */}
+
+
+
انتخاب پرسنل
+
+ ({ value: o.uuid, label: o.full_name ?? "" }))}
+ value={staffUuid || null}
+ onChange={(v) => setStaffUuid(v ? String(v) : "")}
+ placeholder="انتخاب..."
+ isLoading={staffQ.isLoading}
+ isClearable
+ height={38}
+ />
+
+
+
انتخاب وضعیت
+
+ ({ value: v, label: l }))}
+ value={status || null}
+ onChange={(v) => setStatus((v ? String(v) : "") as Appointment["status"])}
+ placeholder="انتخاب وضعیت"
+ height={38}
+ />
+
+
+
توضیحات
+
+
+
+
replace.mutate()}
+ >
+ ثبت نوبت
+
+
+
+ );
+}
diff --git a/assets/admin/components/AppointmentFiltersModal.test.tsx b/assets/admin/components/AppointmentFiltersModal.test.tsx
new file mode 100644
index 00000000..ef36ddb0
--- /dev/null
+++ b/assets/admin/components/AppointmentFiltersModal.test.tsx
@@ -0,0 +1,78 @@
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { screen, fireEvent } from '@testing-library/react';
+import { renderWithProviders } from '../test/utils';
+
+vi.mock('../lib/api', () => ({
+ api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() },
+ ApiError: class extends Error {},
+}));
+
+import { api } from '../lib/api';
+import AppointmentFiltersModal, { applyAppointmentFilters, EMPTY_FILTERS } from './AppointmentFiltersModal';
+import type { Appointment } from '../types';
+
+const get = api.get as ReturnType;
+
+beforeEach(() => {
+ get.mockReset();
+ get.mockResolvedValue({ success: true, data: [] });
+});
+
+const mk = (over: Partial): Appointment => ({
+ uuid: Math.random().toString(36), patient_name: 'x', patient_mobile: '0912',
+ doctor_uuid: 'd', doctor_name: 'دکتر', slot_start: 0, slot_end: 0,
+ appointment_date: '', appointment_time: '', end_time: '',
+ status: 'pending', version: 1, created_at: '', ...over,
+});
+
+describe('applyAppointmentFilters', () => {
+ const items = [
+ mk({ patient_name: 'مریم اسکندری', patient_national_code: '001', status: 'completed', patient_gender: 'female', service_section: { uuid: 's1', name: 'زیبایی' } }),
+ mk({ patient_name: 'مازیار عزیزی', patient_national_code: '002', status: 'cancelled_by_user', patient_gender: 'male' }),
+ mk({ patient_name: 'پریسا همتی', status: 'salon', patient_gender: 'female' }),
+ ];
+
+ it('filters by name, national code, section, status group and gender', () => {
+ expect(applyAppointmentFilters(items, { ...EMPTY_FILTERS, name: 'مریم' })).toHaveLength(1);
+ expect(applyAppointmentFilters(items, { ...EMPTY_FILTERS, nationalCode: '002' })).toHaveLength(1);
+ expect(applyAppointmentFilters(items, { ...EMPTY_FILTERS, sectionUuid: 's1' })).toHaveLength(1);
+ // «لغو شده» covers both cancelled_by_* statuses
+ expect(applyAppointmentFilters(items, { ...EMPTY_FILTERS, statuses: ['cancelled'] })).toHaveLength(1);
+ expect(applyAppointmentFilters(items, { ...EMPTY_FILTERS, statuses: ['salon', 'completed'] })).toHaveLength(2);
+ expect(applyAppointmentFilters(items, { ...EMPTY_FILTERS, gender: 'female' })).toHaveLength(2);
+ expect(applyAppointmentFilters(
+ items.map((a, i) => ({ ...a, staff: i === 0 ? { uuid: 'st1', full_name: 'x' } : null })),
+ { ...EMPTY_FILTERS, staffUuid: 'st1' },
+ )).toHaveLength(1);
+ expect(applyAppointmentFilters(items, EMPTY_FILTERS)).toHaveLength(3);
+ });
+});
+
+describe('AppointmentFiltersModal (فیلترها)', () => {
+ it('renders the design controls and applies the chosen filters', () => {
+ const onApply = vi.fn();
+ renderWithProviders( {}} />);
+
+ expect(screen.getByText('حذف همه')).toBeInTheDocument();
+ expect(screen.getByText('وضعیت نوبت')).toBeInTheDocument();
+ expect(screen.getByText('جنسیت')).toBeInTheDocument();
+
+ fireEvent.change(screen.getByPlaceholderText('نام مراجعه کننده را وارد کنید...'), { target: { value: 'مریم' } });
+ fireEvent.click(screen.getByLabelText('ویزیت شده'));
+ fireEvent.click(screen.getByLabelText('خانم'));
+ fireEvent.click(screen.getByText('اعمال تغییرات'));
+
+ expect(onApply).toHaveBeenCalledWith(expect.objectContaining({
+ name: 'مریم', statuses: ['completed'], gender: 'female',
+ }));
+ });
+
+ it('«حذف همه» resets to the empty filter set', () => {
+ const onApply = vi.fn();
+ renderWithProviders( {}} />);
+ fireEvent.click(screen.getByText('حذف همه'));
+ fireEvent.click(screen.getByText('اعمال تغییرات'));
+ expect(onApply).toHaveBeenCalledWith(EMPTY_FILTERS);
+ });
+});
diff --git a/assets/admin/components/AppointmentFiltersModal.tsx b/assets/admin/components/AppointmentFiltersModal.tsx
new file mode 100644
index 00000000..5354484f
--- /dev/null
+++ b/assets/admin/components/AppointmentFiltersModal.tsx
@@ -0,0 +1,144 @@
+import { useState } from 'react';
+import { useQuery } from '@tanstack/react-query';
+import { XMarkIcon } from '@heroicons/react/24/outline';
+import { api } from '../lib/api';
+import type { ApiResponse } from '../lib/api';
+import type { Appointment } from '../types';
+import Modal from './ui/Modal';
+import SearchableSelect from './ui/SearchableSelect';
+import { digitsOnly } from '../lib/utils';
+
+interface Option { uuid: string; name?: string }
+
+export interface AppointmentFilters {
+ name: string;
+ nationalCode: string;
+ sectionUuid: string;
+ itemUuid: string;
+ /** toolbar «پرسنل را انتخاب کنید...» select — not part of the modal */
+ staffUuid: string;
+ statuses: string[];
+ gender: 'female' | 'male' | 'both';
+}
+
+export const EMPTY_FILTERS: AppointmentFilters = {
+ name: '', nationalCode: '', sectionUuid: '', itemUuid: '', staffUuid: '', statuses: [], gender: 'both',
+};
+
+// design's 6 checkboxes; لغو شده covers both cancel reasons
+const STATUS_OPTIONS: [string, string][] = [
+ ['pending', 'ثبت شده'],
+ ['confirmed', 'قطعی شده'],
+ ['following_up', 'در حال پیگیری'],
+ ['salon', 'سالن'],
+ ['completed', 'ویزیت شده'],
+ ['cancelled', 'لغو شده'],
+];
+
+/** Pure client-side filter of the loaded day's appointments (Figma فیلترها). */
+export function applyAppointmentFilters(items: Appointment[], f: AppointmentFilters): Appointment[] {
+ return items.filter(a => {
+ if (f.name && !(a.patient_name ?? '').includes(f.name)) return false;
+ if (f.nationalCode && !(a.patient_national_code ?? '').includes(f.nationalCode)) return false;
+ if (f.sectionUuid && a.service_section?.uuid !== f.sectionUuid) return false;
+ if (f.itemUuid && a.service_item?.uuid !== f.itemUuid) return false;
+ if (f.staffUuid && a.staff?.uuid !== f.staffUuid) return false;
+ if (f.statuses.length) {
+ const matches = f.statuses.some(s =>
+ s === 'cancelled' ? a.status.startsWith('cancelled') : a.status === s);
+ if (!matches) return false;
+ }
+ if (f.gender !== 'both' && (a.patient_gender ?? '') !== f.gender) return false;
+ return true;
+ });
+}
+
+/** فیلترها (filter-desktop.pdf) — name/national-code search, بخش/سرویس, status checkboxes, gender. */
+export default function AppointmentFiltersModal({ value, onApply, onClose }: {
+ value: AppointmentFilters; onApply: (f: AppointmentFilters) => void; onClose: () => void;
+}) {
+ const [f, setF] = useState(value);
+
+ const sectionsQ = useQuery>({ queryKey: ['service-sections'], queryFn: () => api.get('/api/v1/service-sections') });
+ const itemsQ = useQuery>({
+ queryKey: ['service-items', f.sectionUuid],
+ queryFn: () => api.get(`/api/v1/service-items/${f.sectionUuid}`),
+ enabled: !!f.sectionUuid,
+ });
+
+ const toggleStatus = (s: string) => setF(v => ({
+ ...v,
+ statuses: v.statuses.includes(s) ? v.statuses.filter(x => x !== s) : [...v.statuses, s],
+ }));
+
+ const label = { fontSize: 12.5, color: 'var(--text-3)' } as const;
+
+ return (
+
+
+
+ setF(EMPTY_FILTERS)}>
+ حذف همه
+
+
+
+
جستجو براساس نام
+
+ setF(v => ({ ...v, name: e.target.value }))} placeholder="نام مراجعه کننده را وارد کنید..." />
+
+
جستجو براساس کد ملی
+
+ setF(v => ({ ...v, nationalCode: digitsOnly(e.target.value, 10) }))} placeholder="کد ملی مراجعه کننده را وارد کنید..." inputMode="numeric" dir="ltr" />
+
+
+
بخش
+
+ ({ value: o.uuid, label: o.name ?? '' }))}
+ value={f.sectionUuid || null}
+ onChange={v => setF(prev => ({ ...prev, sectionUuid: v ? String(v) : '', itemUuid: '' }))}
+ placeholder="انتخاب بخش"
+ isLoading={sectionsQ.isLoading}
+ isClearable
+ height={38}
+ />
+
+
سرویس
+
+ ({ value: o.uuid, label: o.name ?? '' }))}
+ value={f.itemUuid || null}
+ onChange={v => setF(prev => ({ ...prev, itemUuid: v ? String(v) : '' }))}
+ placeholder="انتخاب سرویس"
+ isDisabled={!f.sectionUuid}
+ isLoading={itemsQ.isLoading}
+ isClearable
+ height={38}
+ />
+
+
+
وضعیت نوبت
+
+ {STATUS_OPTIONS.map(([v, l]) => (
+
+ toggleStatus(v)} /> {l}
+
+ ))}
+
+
+
+
جنسیت
+ {([['female', 'خانم'], ['male', 'آقا'], ['both', 'هر دو']] as const).map(([v, l]) => (
+
+ setF(x => ({ ...x, gender: v }))} /> {l}
+
+ ))}
+
+
+
{ onApply(f); onClose(); }}>
+ اعمال تغییرات
+
+
+
+ );
+}
diff --git a/assets/admin/components/AppointmentTurnCard.test.tsx b/assets/admin/components/AppointmentTurnCard.test.tsx
new file mode 100644
index 00000000..d98cd1cb
--- /dev/null
+++ b/assets/admin/components/AppointmentTurnCard.test.tsx
@@ -0,0 +1,38 @@
+import { describe, it, expect, vi } from 'vitest';
+import { screen } from '@testing-library/react';
+import { renderWithProviders } from '../test/utils';
+
+vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
+vi.mock('../lib/api', () => ({
+ api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() },
+ ApiError: class extends Error {},
+}));
+
+import AppointmentTurnCard, { type AppointmentCardData } from './AppointmentTurnCard';
+
+const base: AppointmentCardData = {
+ uuid: 'a1', starts_at: 1754000000, status: 'confirmed', version: 1,
+ doctor_name: 'دکتر ژیلا فتحی', service_name: null,
+};
+
+describe('AppointmentTurnCard', () => {
+ it('renders title, date/time labels, doctor and the live status label', () => {
+ renderWithProviders( );
+ expect(screen.getByText('نوبت')).toBeInTheDocument(); // no service → generic title
+ expect(screen.getByText('تاریخ:')).toBeInTheDocument();
+ expect(screen.getByText('ساعت:')).toBeInTheDocument();
+ expect(screen.getByText('پرسنل:')).toBeInTheDocument();
+ expect(screen.getByText('دکتر ژیلا فتحی')).toBeInTheDocument();
+ expect(screen.getByText('قطعی شده')).toBeInTheDocument(); // confirmed via STATUS_META
+ });
+
+ it('falls back to «—» when the doctor is missing', () => {
+ renderWithProviders( );
+ expect(screen.getByText('—')).toBeInTheDocument();
+ });
+
+ it('prefers the service name as the title when present', () => {
+ renderWithProviders( );
+ expect(screen.getByText('لیزر')).toBeInTheDocument();
+ });
+});
diff --git a/assets/admin/components/AppointmentTurnCard.tsx b/assets/admin/components/AppointmentTurnCard.tsx
new file mode 100644
index 00000000..d8bfa328
--- /dev/null
+++ b/assets/admin/components/AppointmentTurnCard.tsx
@@ -0,0 +1,93 @@
+import type { CSSProperties, ReactNode } from 'react';
+import { formatDate, formatTime } from '../lib/utils';
+import {
+ FilesServiceSuccess, FilesServiceMore, CalendarD, ClockP, UserD, StatusGlobe,
+} from './icons/FilesServiceIcons';
+import AppointmentStatusDropdown from './ui/AppointmentStatusDropdown';
+
+export interface AppointmentCardData {
+ uuid: string;
+ starts_at: number;
+ status: string;
+ version: number;
+ doctor_name?: string | null;
+ service_name?: string | null;
+}
+
+/**
+ * label/value row inside the turn card — mirrors tauri ServiceInfoRow
+ * (separatedValues variant): icon+label on one side, value (optionally chip) on
+ * the other.
+ */
+function InfoRow({ icon, label, value, chip = false, valueStyle }: {
+ icon: ReactNode; label: string; value: ReactNode; chip?: boolean; valueStyle?: CSSProperties;
+}) {
+ return (
+
+
+ {icon}
+ {label}:
+
+
+ {value}
+
+
+ );
+}
+
+/**
+ * A patient «نوبت» card — ported pixel-for-pixel from tauri
+ * files/services/TurnsCard. The status uses the admin's live
+ * AppointmentStatusDropdown (backed by PATCH /appointment/{uuid}/status)
+ * instead of the tauri mock.
+ */
+export default function AppointmentTurnCard({ appointment, queryKey }: {
+ appointment: AppointmentCardData;
+ queryKey: unknown[];
+}) {
+ const title = appointment.service_name || 'نوبت';
+ return (
+
+ {/* Header */}
+
+
+
+
+ {/* Middle: date & time chips */}
+
+ } label="تاریخ" chip value={formatDate(appointment.starts_at)} />
+ } label="ساعت" chip value={formatTime(appointment.starts_at)} />
+
+
+
+
+ {/* Bottom: personnel & status */}
+
+
} label="پرسنل" value={appointment.doctor_name || '—'} valueStyle={{ fontWeight: 500 }} />
+
}
+ label="وضعیت"
+ value={
}
+ />
+
+
+ );
+}
diff --git a/assets/admin/components/ClinicDoctorsManager.test.tsx b/assets/admin/components/ClinicDoctorsManager.test.tsx
new file mode 100644
index 00000000..4be1df24
--- /dev/null
+++ b/assets/admin/components/ClinicDoctorsManager.test.tsx
@@ -0,0 +1,48 @@
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { screen } from '@testing-library/react';
+import { renderWithProviders } from '../test/utils';
+
+vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
+vi.mock('../lib/api', () => ({
+ api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() },
+ ApiError: class extends Error {},
+}));
+
+import { api } from '../lib/api';
+import ClinicDoctorsManager from './ClinicDoctorsManager';
+
+const get = api.get as ReturnType;
+
+beforeEach(() => {
+ get.mockReset();
+ get.mockImplementation((url: string) => {
+ if (url.includes('/clinic/doctor-list/')) return Promise.resolve({ success: true, data: [
+ { id: '1', uuid: 'doc-uuid-1', name: 'دکتر رضایی', gender: null, degree: null,
+ img: [], specialties: [{ id: '2', name: 'قلب' }], active: true },
+ ] });
+ if (url.includes('/invitations')) return Promise.resolve({ success: true, data: [
+ { uuid: 'inv-1', mobile: '09120000000', invited_name: 'دکتر مهمان', invited_specialty: null,
+ status: 'pending', token_used: false, invited_at: 1, expires_at: 9_999_999_999,
+ responded_at: null, doctor: null },
+ ], meta: { totalRecords: 1, totalPages: 1, currentPage: 1 } });
+ return Promise.resolve({ success: true, data: [] });
+ });
+});
+
+describe('ClinicDoctorsManager', () => {
+ it('lists clinic doctors and shows management controls by default', async () => {
+ renderWithProviders( , { route: '/admin/settings/clinic-doctors' });
+ expect(await screen.findByText('دکتر رضایی')).toBeInTheDocument();
+ expect(screen.getByText('قلب')).toBeInTheDocument();
+ // manager controls
+ expect(screen.getByText('دعوت پزشک')).toBeInTheDocument();
+ expect(screen.getByTitle('جداسازی از کلینیک')).toBeInTheDocument();
+ });
+
+ it('hides every mutating control when readOnly', async () => {
+ renderWithProviders( , { route: '/admin/settings/clinic-doctors' });
+ expect(await screen.findByText('دکتر رضایی')).toBeInTheDocument();
+ expect(screen.queryByText('دعوت پزشک')).not.toBeInTheDocument();
+ expect(screen.queryByTitle('جداسازی از کلینیک')).not.toBeInTheDocument();
+ });
+});
diff --git a/assets/admin/components/ClinicDoctorsManager.tsx b/assets/admin/components/ClinicDoctorsManager.tsx
new file mode 100644
index 00000000..869ffde8
--- /dev/null
+++ b/assets/admin/components/ClinicDoctorsManager.tsx
@@ -0,0 +1,308 @@
+import { useState, useMemo } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import {
+ TrashIcon, EnvelopeIcon, ArrowPathIcon, NoSymbolIcon, EyeIcon, ShieldCheckIcon,
+} from '@heroicons/react/24/outline';
+import { toast } from 'sonner';
+import { api } from '../lib/api';
+import type { ApiResponse, PaginatedResponse } from '../lib/api';
+import { formatNumber } from '../lib/utils';
+import ConfirmDialog from './ui/ConfirmDialog';
+import InviteDoctorModal from './ui/InviteDoctorModal';
+import DoctorPermissionsModal from './ui/DoctorPermissionsModal';
+
+const HUES_LIST = [256, 205, 162, 295, 272];
+
+export interface ClinicDoctorItem {
+ id: string; uuid: string; name: string;
+ gender: string | null; degree: string | null;
+ img: { url: string }[];
+ specialties: { id: string; name: string }[];
+ active: boolean;
+}
+
+export interface ClinicInvitation {
+ uuid: string;
+ mobile: string;
+ invited_name: string | null;
+ invited_specialty: string | null;
+ status: 'pending' | 'accepted' | 'rejected' | 'suspended' | 'removed';
+ token_used: boolean;
+ invited_at: number;
+ expires_at: number;
+ responded_at: number | null;
+ doctor: { uuid: string; name: string } | null;
+}
+
+const INV_STATUS_MAP: Record = {
+ pending: { label: 'در انتظار', cls: 'amber' },
+ accepted: { label: 'پذیرفتهشده', cls: 'green' },
+ rejected: { label: 'رد شده', cls: 'gray' },
+ suspended: { label: 'تعلیق', cls: 'violet' },
+ removed: { label: 'حذفشده', cls: 'gray' },
+};
+
+/**
+ * ClinicDoctorsManager — self-contained management of a clinic's doctors and
+ * pending invitations (list, invite, resend, suspend, delete invitation, detach
+ * doctor). Reused by both the admin ClinicDetailPage and the clinic-owner
+ * settings tab (ClinicDoctorsPage). `readOnly` hides every mutating control.
+ */
+export default function ClinicDoctorsManager({ clinicUuid, readOnly = false }: {
+ clinicUuid: string;
+ readOnly?: boolean;
+}) {
+ const navigate = useNavigate();
+ const qc = useQueryClient();
+
+ const [doctorsTab, setDoctorsTab] = useState<'doctors' | 'invitations'>('doctors');
+ const [inviteOpen, setInviteOpen] = useState(false);
+ const [detachDoctorConfirm, setDetachDoctorConfirm] = useState(null);
+ const [permissionsFor, setPermissionsFor] = useState(null);
+
+ const doctorsQ = useQuery({
+ queryKey: ['clinic-doctors', clinicUuid],
+ queryFn: () => api.get>(`/api/v1/clinic/doctor-list/${clinicUuid}`),
+ enabled: !!clinicUuid,
+ });
+
+ const invitationsQ = useQuery({
+ queryKey: ['clinic-invitations', clinicUuid],
+ queryFn: () => api.get>(`/api/v1/admin/clinic/${clinicUuid}/invitations?limit=50`),
+ enabled: !!clinicUuid,
+ });
+
+ const doctorList: ClinicDoctorItem[] = useMemo(() => {
+ const raw = doctorsQ.data?.data;
+ return (raw as any)?.data ?? raw ?? [];
+ }, [doctorsQ.data]);
+
+ const invitationList: ClinicInvitation[] = invitationsQ.data?.data ?? [];
+
+ const resendInvMut = useMutation({
+ mutationFn: (invUuid: string) => api.post>(`/api/v1/admin/clinic/invitation/${invUuid}/resend`, {}),
+ onSuccess: () => { toast.success('پیامک مجدداً ارسال شد'); qc.invalidateQueries({ queryKey: ['clinic-invitations', clinicUuid] }); },
+ onError: (e: Error) => toast.error(e.message),
+ });
+
+ const changeInvStatusMut = useMutation({
+ mutationFn: ({ invUuid, status }: { invUuid: string; status: string }) =>
+ api.patch>(`/api/v1/admin/clinic/invitation/${invUuid}/status`, { status }),
+ onSuccess: (_d, v) => {
+ toast.success(v.status === 'pending' ? 'دعوتنامه فعال و پیامک مجدداً ارسال شد' : 'دعوتنامه تعلیق شد');
+ qc.invalidateQueries({ queryKey: ['clinic-invitations', clinicUuid] });
+ },
+ onError: (e: Error) => toast.error(e.message),
+ });
+
+ const deleteInvMut = useMutation({
+ mutationFn: (invUuid: string) => api.delete>(`/api/v1/admin/clinic/invitation/${invUuid}`),
+ onSuccess: () => { toast.success('دعوتنامه حذف شد'); qc.invalidateQueries({ queryKey: ['clinic-invitations', clinicUuid] }); },
+ onError: (e: Error) => toast.error(e.message),
+ });
+
+ const detachDoctorMut = useMutation({
+ mutationFn: (doctorUuid: string) =>
+ api.delete>(`/api/v1/admin/clinic/${clinicUuid}/doctor/${doctorUuid}`),
+ onSuccess: () => {
+ toast.success('پزشک از کلینیک جدا شد');
+ qc.invalidateQueries({ queryKey: ['clinic-doctors', clinicUuid] });
+ qc.invalidateQueries({ queryKey: ['clinic-detail', clinicUuid] });
+ },
+ onError: (e: Error) => toast.error(e.message),
+ });
+
+ return (
+ <>
+
+ {/* Card header */}
+
+
+ setDoctorsTab('doctors')}>
+ پزشکان ({formatNumber(doctorList.length)})
+
+ setDoctorsTab('invitations')}>
+ دعوتنامهها ({formatNumber(invitationList.length)})
+
+
+ {!readOnly && (
+
setInviteOpen(true)}>
+ دعوت پزشک
+
+ )}
+
+
+ {/* Doctors tab */}
+ {doctorsTab === 'doctors' && (
+ doctorList.length === 0 ? (
+
+
هیچ پزشکی به این کلینیک متصل نیست
+
+ ) : (
+
+ {doctorList.map(doc => {
+ const dHue = HUES_LIST[(doc.uuid?.charCodeAt(0) ?? 0) % HUES_LIST.length];
+ const img = doc.img?.[0]?.url;
+ return (
+
+ {img
+ ?
+ :
{doc.name?.[0] ?? '?'}
+ }
+
+
{doc.name}
+ {doc.specialties?.length > 0 && (
+
{doc.specialties.map(s => s.name).join('، ')}
+ )}
+
+
+
+ {doc.active ? 'فعال' : 'غیرفعال'}
+
+ navigate(`/admin/doctors/${doc.uuid}`)}
+ >
+
+
+ {!readOnly && (
+ <>
+ setPermissionsFor(doc)}
+ >
+
+
+ setDetachDoctorConfirm(doc)}
+ >
+
+
+ >
+ )}
+
+
+ );
+ })}
+
+ )
+ )}
+
+ {/* Invitations tab */}
+ {doctorsTab === 'invitations' && (
+ invitationList.length === 0 ? (
+
+
+
هیچ دعوتنامهای ارسال نشده
+
+ ) : (
+
+ {invitationList.map(inv => {
+ const statusInfo = INV_STATUS_MAP[inv.status] ?? { label: inv.status, cls: 'gray' };
+ const isExpired = !inv.token_used && inv.status === 'pending' && Date.now() / 1000 > inv.expires_at;
+ return (
+
+
+
{inv.invited_name ?? inv.mobile}
+
+ {inv.mobile}
+ {inv.invited_specialty && (
+ {inv.invited_specialty}
+ )}
+ {inv.doctor && (
+ navigate(`/admin/doctors/${inv.doctor!.uuid}`)}
+ >
+ {inv.doctor.name}
+
+ )}
+
+
+
+
+ {isExpired ? 'منقضی' : statusInfo.label}
+
+ {!readOnly && (
+
+ {inv.status === 'pending' && (
+
resendInvMut.mutate(inv.uuid)}
+ >
+
+
+ )}
+ {inv.status !== 'removed' && inv.status !== 'accepted' && (
+
changeInvStatusMut.mutate({ invUuid: inv.uuid, status: inv.status === 'suspended' ? 'pending' : 'suspended' })}
+ >
+
+
+ )}
+
deleteInvMut.mutate(inv.uuid)}
+ >
+
+
+
+ )}
+
+
+ );
+ })}
+
+ )
+ )}
+
+
+ {/* Detach Doctor From Clinic Confirm */}
+ {
+ if (detachDoctorConfirm) detachDoctorMut.mutate(detachDoctorConfirm.uuid);
+ setDetachDoctorConfirm(null);
+ }}
+ onCancel={() => setDetachDoctorConfirm(null)}
+ />
+
+ {/* Per-doctor clinic permissions */}
+ {permissionsFor && (
+ setPermissionsFor(null)}
+ />
+ )}
+
+ {/* Invite doctor modal */}
+ {inviteOpen && clinicUuid && (
+ setInviteOpen(false)}
+ onInvited={() => { qc.invalidateQueries({ queryKey: ['clinic-invitations', clinicUuid] }); setDoctorsTab('invitations'); }}
+ />
+ )}
+ >
+ );
+}
diff --git a/assets/admin/components/DiscountTab.tsx b/assets/admin/components/DiscountTab.tsx
new file mode 100644
index 00000000..797edfa4
--- /dev/null
+++ b/assets/admin/components/DiscountTab.tsx
@@ -0,0 +1,341 @@
+import React, { useEffect, useMemo, useState } from 'react';
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { PlusIcon, PencilIcon, TrashIcon } from '@heroicons/react/24/outline';
+import { toast } from 'sonner';
+import { api } from '../lib/api';
+import type { ApiResponse } from '../lib/api';
+import type { DiscountRule, DiscountRuleType } from '../types';
+import { formatRial, formatDate, tomanToRial, rialToToman, tehranWallClockToUnix } from '../lib/utils';
+import Modal from './ui/Modal';
+import ConfirmDialog from './ui/ConfirmDialog';
+import SearchableSelect from './ui/SearchableSelect';
+import PriceInput from './ui/PriceInput';
+import PersianDateInput from './ui/PersianDateInput';
+import { digitsOnly } from '../lib/utils';
+
+const TYPE_LABELS: Record = {
+ patient_tag: 'تگ بیمار',
+ invoice_amount: 'مبلغ فاکتور',
+ specific_patient: 'بیمار خاص',
+ occasion: 'مناسبتی',
+ service: 'سرویس',
+ visit_count: 'تعداد مراجعات',
+};
+
+interface Option { uuid: string; name?: string }
+
+const unixToIso = (u: number | null): string => {
+ if (!u) return '';
+ const d = new Date(u * 1000);
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
+};
+const isoToUnix = (iso: string): number | null => (iso ? tehranWallClockToUnix(iso, '00:00') : null);
+
+interface FormState {
+ name: string;
+ type: DiscountRuleType;
+ discount_type: 'percent' | 'fixed';
+ value: number; // percent (0..100) or toman (fixed)
+ priority: number;
+ combinable: boolean;
+ active: boolean;
+ valid_from: string; // iso
+ valid_to: string; // iso
+ target_tag_uuid: string;
+ target_record_uuid: string;
+ target_service_item_uuid: string;
+ min_amount_toman: number;
+ min_visit_count: number;
+ occasion_kind: '' | 'birthday';
+}
+
+const emptyForm = (): FormState => ({
+ name: '', type: 'invoice_amount', discount_type: 'percent', value: 0, priority: 0,
+ combinable: false, active: true, valid_from: '', valid_to: '',
+ target_tag_uuid: '', target_record_uuid: '', target_service_item_uuid: '',
+ min_amount_toman: 0, min_visit_count: 0, occasion_kind: '',
+});
+
+const fromRule = (r: DiscountRule): FormState => ({
+ name: r.name, type: r.type, discount_type: r.discount_type,
+ value: r.discount_type === 'fixed' ? rialToToman(r.value) : r.value,
+ priority: r.priority, combinable: r.combinable, active: r.active,
+ valid_from: unixToIso(r.valid_from), valid_to: unixToIso(r.valid_to),
+ target_tag_uuid: r.target_tag_uuid ?? '', target_record_uuid: r.target_record_uuid ?? '',
+ target_service_item_uuid: r.target_service_item_uuid ?? '',
+ min_amount_toman: r.min_amount_rials ? rialToToman(r.min_amount_rials) : 0,
+ min_visit_count: r.min_visit_count ?? 0,
+ occasion_kind: r.occasion_kind === 'birthday' ? 'birthday' : '',
+});
+
+function labelStyle(): React.CSSProperties { return { fontSize: 12.5, color: 'var(--text-3)', display: 'block', marginBottom: 6 }; }
+
+export default function DiscountTab() {
+ const qc = useQueryClient();
+ const [modal, setModal] = useState<'create' | DiscountRule | null>(null);
+ const [toDelete, setToDelete] = useState(null);
+
+ const { data, isLoading } = useQuery({
+ queryKey: ['admin-discount-rules'],
+ queryFn: () => api.get>('/api/v1/admin/discount-rules'),
+ });
+ const rules: DiscountRule[] = (data?.data as any)?.data ?? (data?.data as any) ?? [];
+
+ const removeMut = useMutation({
+ mutationFn: (uuid: string) => api.delete(`/api/v1/admin/discount-rules/${uuid}`),
+ onSuccess: () => { toast.success('قانون حذف شد'); setToDelete(null); qc.invalidateQueries({ queryKey: ['admin-discount-rules'] }); },
+ onError: (e: Error) => toast.error(e.message),
+ });
+
+ const discountDisplay = (r: DiscountRule) =>
+ r.discount_type === 'percent' ? `${r.value}٪` : formatRial(r.value);
+
+ return (
+
+
+
قوانین تخفیف عمومی — بر اساس تگ، مبلغ، بیمار، مناسبت، سرویس یا تعداد مراجعه
+
setModal('create')}>
+ قانون جدید
+
+
+
+ {isLoading ? (
+
در حال بارگذاری...
+ ) : rules.length === 0 ? (
+
هنوز قانونی تعریف نشده است.
+ ) : (
+
+
+
+
+ نام
+ نوع
+ تخفیف
+ اولویت
+ ترکیبپذیر
+ وضعیت
+
+
+
+
+ {rules.map((r) => (
+
+ {r.name}
+ {TYPE_LABELS[r.type]}
+ {discountDisplay(r)}
+ {r.priority}
+ {r.combinable ? 'بله' : 'خیر'}
+
+ {r.active ? 'فعال' : 'غیرفعال'}
+
+
+ setModal(r)} aria-label="ویرایش">
+ setToDelete(r)} aria-label="حذف">
+
+
+ ))}
+
+
+
+ )}
+
+ {modal && (
+
setModal(null)}
+ onSaved={() => { setModal(null); qc.invalidateQueries({ queryKey: ['admin-discount-rules'] }); }}
+ />
+ )}
+
+ toDelete && removeMut.mutate(toDelete.uuid)}
+ onCancel={() => setToDelete(null)}
+ />
+
+ );
+}
+
+function RuleModal({ initial, onClose, onSaved }: { initial: DiscountRule | null; onClose: () => void; onSaved: () => void }) {
+ const [f, setF] = useState(initial ? fromRule(initial) : emptyForm());
+ const set = (k: K, v: FormState[K]) => setF((s) => ({ ...s, [k]: v }));
+
+ const tagsQ = useQuery({
+ queryKey: ['tenant-tags'], queryFn: () => api.get>('/api/v1/tenant-tags'),
+ enabled: f.type === 'patient_tag',
+ });
+ const sectionsQ = useQuery({
+ queryKey: ['service-sections'], queryFn: () => api.get>('/api/v1/service-sections'),
+ enabled: f.type === 'service',
+ });
+ const [sectionUuid, setSectionUuid] = useState('');
+ const itemsQ = useQuery({
+ queryKey: ['service-items', sectionUuid], queryFn: () => api.get>(`/api/v1/service-items/${sectionUuid}`),
+ enabled: f.type === 'service' && !!sectionUuid,
+ });
+ const tags = (tagsQ.data?.data as any)?.data ?? (tagsQ.data?.data as any) ?? [];
+ const sections = (sectionsQ.data?.data as any)?.data ?? (sectionsQ.data?.data as any) ?? [];
+ const items = (itemsQ.data?.data as any)?.data ?? (itemsQ.data?.data as any) ?? [];
+
+ const save = useMutation({
+ mutationFn: () => {
+ const body: Record = {
+ name: f.name.trim(),
+ type: f.type,
+ discount_type: f.discount_type,
+ value: f.discount_type === 'fixed' ? tomanToRial(f.value) : f.value,
+ priority: f.priority,
+ combinable: f.combinable,
+ active: f.active,
+ valid_from: isoToUnix(f.valid_from),
+ valid_to: isoToUnix(f.valid_to),
+ target_tag_uuid: f.type === 'patient_tag' ? (f.target_tag_uuid || null) : null,
+ target_record_uuid: f.type === 'specific_patient' ? (f.target_record_uuid.trim() || null) : null,
+ target_service_item_uuid: f.type === 'service' ? (f.target_service_item_uuid || null) : null,
+ min_amount_rials: f.type === 'invoice_amount' ? tomanToRial(f.min_amount_toman) : null,
+ min_visit_count: f.type === 'visit_count' ? f.min_visit_count : null,
+ occasion_kind: f.type === 'occasion' ? (f.occasion_kind || null) : null,
+ };
+ return initial
+ ? api.patch(`/api/v1/admin/discount-rules/${initial.uuid}`, body)
+ : api.post('/api/v1/admin/discount-rules', body);
+ },
+ onSuccess: () => { toast.success('قانون ذخیره شد'); onSaved(); },
+ onError: (e: Error) => toast.error(e.message),
+ });
+
+ const canSave = f.name.trim().length > 0 && f.value >= 0;
+
+ return (
+
+
+
+ نام قانون
+ set('name', e.target.value)} placeholder="مثال: بیماران VIP" />
+
+
+
+
+ نوع قانون
+ ({ value: t, label: TYPE_LABELS[t] }))}
+ value={f.type} onChange={(v) => set('type', (v as DiscountRuleType) || 'invoice_amount')} height={38}
+ />
+
+
+ نوع تخفیف
+ set('discount_type', (v as 'percent' | 'fixed') || 'percent')} height={38}
+ />
+
+
+
+
+
+
{f.discount_type === 'percent' ? 'درصد تخفیف (۰ تا ۱۰۰)' : 'مبلغ تخفیف (تومان)'}
+ {f.discount_type === 'percent'
+ ?
set('value', Number(digitsOnly(e.target.value, 3)) || 0)} />
+ :
set('value', v)} />}
+
+
+ اولویت (بزرگتر = مهمتر)
+ set('priority', Number(digitsOnly(e.target.value)) || 0)} />
+
+
+
+ {/* target فیلد پویا بر اساس نوع */}
+ {f.type === 'patient_tag' && (
+
+ تگ بیمار
+ ({ value: t.uuid, label: t.name ?? '' }))}
+ value={f.target_tag_uuid || null} onChange={(v) => set('target_tag_uuid', v ? String(v) : '')}
+ placeholder="انتخاب تگ" isLoading={tagsQ.isLoading} isClearable height={38}
+ />
+
+ )}
+ {f.type === 'invoice_amount' && (
+
+
حداقل مبلغ فاکتور (تومان)
+
set('min_amount_toman', v)} />
+
+ )}
+ {f.type === 'specific_patient' && (
+
+ شناسهی پروندهی بیمار (uuid)
+ set('target_record_uuid', e.target.value)} placeholder="record uuid" />
+
+ )}
+ {f.type === 'service' && (
+
+
+ بخش
+ ({ value: s.uuid, label: s.name ?? '' }))}
+ value={sectionUuid || null} onChange={(v) => { setSectionUuid(v ? String(v) : ''); set('target_service_item_uuid', ''); }}
+ placeholder="انتخاب بخش" isLoading={sectionsQ.isLoading} isClearable height={38}
+ />
+
+
+ سرویس
+ ({ value: s.uuid, label: s.name ?? '' }))}
+ value={f.target_service_item_uuid || null} onChange={(v) => set('target_service_item_uuid', v ? String(v) : '')}
+ placeholder="انتخاب سرویس" isDisabled={!sectionUuid} isLoading={itemsQ.isLoading} isClearable height={38}
+ />
+
+
+ )}
+ {f.type === 'visit_count' && (
+
+ حداقل تعداد مراجعه
+ set('min_visit_count', Number(digitsOnly(e.target.value)) || 0)} />
+
+ )}
+ {f.type === 'occasion' && (
+
+ زیرنوع مناسبت
+ set('occasion_kind', (v as '' | 'birthday'))} height={38}
+ />
+
+ )}
+
+ {/* بازهی اعتبار (اختیاری؛ برای مناسبتی/موقت) */}
+
+
+
اعتبار از (اختیاری)
+
set('valid_from', v)} />
+
+
+
اعتبار تا (اختیاری)
+
set('valid_to', v)} />
+
+
+
+
+
+ set('combinable', e.target.checked)} /> قابل ترکیب با سایر تخفیفها
+
+
+ set('active', e.target.checked)} /> فعال
+
+
+
+
+ انصراف
+ save.mutate()}>
+ {save.isPending ? 'در حال ذخیره...' : 'ذخیره'}
+
+
+
+
+ );
+}
diff --git a/assets/admin/components/FreeVisitPrice.test.tsx b/assets/admin/components/FreeVisitPrice.test.tsx
new file mode 100644
index 00000000..3ccbfb2b
--- /dev/null
+++ b/assets/admin/components/FreeVisitPrice.test.tsx
@@ -0,0 +1,76 @@
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { screen, fireEvent, waitFor } from '@testing-library/react';
+import { renderWithProviders } from '../test/utils';
+
+vi.mock('../lib/api', () => ({
+ api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() },
+ ApiError: class extends Error {},
+}));
+
+import { api } from '../lib/api';
+import FreeVisitPrice from './FreeVisitPrice';
+
+const get = api.get as ReturnType;
+const put = api.put as ReturnType;
+
+const pricing = (priceRials: number, require: boolean) => ({
+ success: true,
+ data: { free_visit_price_rials: priceRials, require_visit_price: require },
+});
+
+beforeEach(() => {
+ get.mockReset();
+ put.mockReset();
+ put.mockResolvedValue({ success: true });
+});
+
+describe('FreeVisitPrice — الزامی کردن هزینه ویزیت', () => {
+ it('toggle فعال + قیمت صفر → خطای inline و عدم ارسال درخواست', async () => {
+ get.mockResolvedValue(pricing(0, false));
+ renderWithProviders( );
+ await waitFor(() => expect(screen.getByRole('textbox')).toHaveValue('0'));
+
+ fireEvent.click(screen.getByRole('switch', { name: 'الزامی کردن هزینه ویزیت' }));
+ fireEvent.click(screen.getByText('ذخیره'));
+
+ expect(await screen.findByText('با فعال بودن «الزامی کردن هزینه ویزیت»، قیمت ویزیت آزاد الزامی است')).toBeInTheDocument();
+ expect(put).not.toHaveBeenCalled();
+ });
+
+ it('toggle فعال + قیمت معتبر → PUT با هر دو کلید (تومان → ریال)', async () => {
+ get.mockResolvedValue(pricing(0, false));
+ renderWithProviders( );
+ await waitFor(() => expect(screen.getByRole('textbox')).toHaveValue('0'));
+
+ fireEvent.click(screen.getByRole('switch', { name: 'الزامی کردن هزینه ویزیت' }));
+ fireEvent.change(screen.getByRole('textbox'), { target: { value: '50000' } });
+ fireEvent.click(screen.getByText('ذخیره'));
+
+ await waitFor(() => expect(put).toHaveBeenCalledWith('/api/v1/insurance-pricing', {
+ free_visit_price_rials: 500_000,
+ require_visit_price: true,
+ }));
+ });
+
+ it('toggle غیرفعال + قیمت صفر → رفتار قبلی حفظ میشود (ارسال مجاز)', async () => {
+ get.mockResolvedValue(pricing(0, false));
+ renderWithProviders( );
+ await waitFor(() => expect(screen.getByRole('textbox')).toHaveValue('0'));
+
+ fireEvent.click(screen.getByText('ذخیره'));
+
+ await waitFor(() => expect(put).toHaveBeenCalledWith('/api/v1/insurance-pricing', {
+ free_visit_price_rials: 0,
+ require_visit_price: false,
+ }));
+ });
+
+ it('فلگ ذخیرهشده true → سوییچ روشن و ستاره روی label قیمت', async () => {
+ get.mockResolvedValue(pricing(500_000, true));
+ renderWithProviders( );
+
+ await waitFor(() => expect(screen.getByRole('switch', { name: 'الزامی کردن هزینه ویزیت' })).toBeChecked());
+ expect(screen.getByText('قیمت (تومان)').querySelector('span')?.textContent).toContain('*');
+ expect(screen.getByRole('textbox')).toHaveValue('50000');
+ });
+});
diff --git a/assets/admin/components/FreeVisitPrice.tsx b/assets/admin/components/FreeVisitPrice.tsx
index dafdf824..351c4a07 100644
--- a/assets/admin/components/FreeVisitPrice.tsx
+++ b/assets/admin/components/FreeVisitPrice.tsx
@@ -3,32 +3,54 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { api } from '../lib/api';
import { formatRial, rialToToman, tomanToRial } from '../lib/utils';
+import { digitsOnly } from '../lib/utils';
-interface Pricing { free_visit_price_rials: number }
+interface Pricing { free_visit_price_rials: number; require_visit_price: boolean }
-export default function FreeVisitPrice() {
+/** بدون doctorUuid روی موجودیت کاربر جاری کار میکند؛ با آن، قیمت همان پزشک. */
+export default function FreeVisitPrice({ doctorUuid }: { doctorUuid?: string }) {
const qc = useQueryClient();
const [value, setValue] = useState('');
+ const [required, setRequired] = useState(false);
+ const [error, setError] = useState('');
const { data } = useQuery<{ data: Pricing }>({
- queryKey: ['insurance-pricing'],
- queryFn: () => api.get('/api/v1/insurance-pricing'),
+ queryKey: ['insurance-pricing', doctorUuid ?? 'self'],
+ queryFn: () => api.get(doctorUuid
+ ? `/api/v1/insurance-pricing?doctor_uuid=${doctorUuid}`
+ : '/api/v1/insurance-pricing'),
});
const pricing = (data as any)?.data as Pricing | undefined;
useEffect(() => {
- if (pricing) setValue(String(rialToToman(pricing.free_visit_price_rials ?? 0)));
+ if (pricing) {
+ setValue(String(rialToToman(pricing.free_visit_price_rials ?? 0)));
+ setRequired(!!pricing.require_visit_price);
+ }
}, [pricing]);
const saveMut = useMutation({
- mutationFn: () => api.put('/api/v1/insurance-pricing', { free_visit_price_rials: tomanToRial(Number(value) || 0) }),
+ mutationFn: () => api.put('/api/v1/insurance-pricing', {
+ free_visit_price_rials: tomanToRial(Number(value) || 0),
+ require_visit_price: required,
+ ...(doctorUuid ? { doctor_uuid: doctorUuid } : {}),
+ }),
onSuccess: () => {
toast.success('قیمت ویزیت ذخیره شد');
- qc.invalidateQueries({ queryKey: ['insurance-pricing'] });
+ qc.invalidateQueries({ queryKey: ['insurance-pricing', doctorUuid ?? 'self'] });
},
onError: (e: Error) => toast.error(e.message),
});
+ const save = () => {
+ if (required && (Number(value) || 0) <= 0) {
+ setError('با فعال بودن «الزامی کردن هزینه ویزیت»، قیمت ویزیت آزاد الزامی است');
+ return;
+ }
+ setError('');
+ saveMut.mutate();
+ };
+
return (
قیمت ویزیت آزاد
@@ -37,19 +59,49 @@ export default function FreeVisitPrice() {
- قیمت (تومان)
+
+ قیمت (تومان){required && * }
+
setValue(e.target.value)}
+ type="text" inputMode="numeric" dir="ltr" className="input" style={{ width: 200 }}
+ aria-invalid={!!error}
+ value={value} onChange={(e) => { setValue(digitsOnly(e.target.value)); setError(''); }}
/>
-
saveMut.mutate()}>
- {saveMut.isPending ? '...' : 'ذخیره'}
-
{value !== '' && (
{formatRial(tomanToRial(Number(value) || 0))}
)}
+ {error && (
+
{error}
+ )}
+
+
+
+ { setRequired(e.target.checked); setError(''); }}
+ style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', margin: 0, opacity: 0, cursor: 'pointer' }}
+ />
+
+
+ الزامی کردن هزینه ویزیت
+
+
+ با فعال شدن این گزینه، وارد کردن هزینه ویزیت در تنظیمات، ثبت مراجعه (سرویس)، فاکتور سرویس و ثبت نوبت الزامی میشود و بدون آن امکان ذخیره وجود ندارد.
+
+
+
+
+ {saveMut.isPending ? '...' : 'ذخیره'}
+
+
);
}
diff --git a/assets/admin/components/InsuranceModal.test.tsx b/assets/admin/components/InsuranceModal.test.tsx
new file mode 100644
index 00000000..99ba1a3d
--- /dev/null
+++ b/assets/admin/components/InsuranceModal.test.tsx
@@ -0,0 +1,89 @@
+import { describe, it, expect, vi } from 'vitest';
+import { screen, fireEvent } from '@testing-library/react';
+import { renderWithProviders } from '../test/utils';
+import InsuranceModal, {
+ buildInsurancePayload, contractToForm, EMPTY_FORM, type Contract, type InsuranceOption,
+} from './InsuranceModal';
+
+const mkContract = (over: Partial = {}): Contract => ({
+ uuid: 'c-1', insurance_id: 3, insurance_name: 'بیمه ایران', insurance_kind: 'basic',
+ version: 1, is_active: true, coverage_percent: 70, franchise_rials: 500_000,
+ annual_ceiling_rials: 20_000_000, kind: 'basic', effective_from: 1_700_000_000,
+ effective_to: null, ...over,
+});
+
+const options: InsuranceOption[] = [
+ { insurance_id: 3, insurance_name: 'بیمه ایران', type: 'basic' },
+ { insurance_id: 5, insurance_name: 'بیمه آسیا', type: 'supplementary' },
+];
+
+describe('buildInsurancePayload', () => {
+ it('converts toman → rials, percent, and Y-m-d → unix', () => {
+ const payload = buildInsurancePayload({
+ ...EMPTY_FORM, insuranceId: '3', kind: 'supplementary',
+ coverage: '80', franchise: '50000', ceiling: '2000000',
+ effectiveFrom: '2024-01-01', effectiveTo: '2025-01-01',
+ });
+ expect(payload.insurance_id).toBe(3);
+ expect(payload.kind).toBe('supplementary');
+ expect(payload.coverage_percent).toBe(80);
+ expect(payload.franchise_rials).toBe(500_000); // 50000 toman × 10
+ expect(payload.annual_ceiling_rials).toBe(20_000_000);
+ expect(typeof payload.effective_from).toBe('number');
+ expect(payload.effective_to).toBeGreaterThan(payload.effective_from!);
+ });
+
+ it('empty ceiling → null (بینهایت), empty dates → null', () => {
+ const payload = buildInsurancePayload({ ...EMPTY_FORM, insuranceId: '3', coverage: '50' });
+ expect(payload.annual_ceiling_rials).toBeNull();
+ expect(payload.effective_from).toBeNull();
+ expect(payload.effective_to).toBeNull();
+ });
+});
+
+describe('contractToForm', () => {
+ it('maps rials → toman and uses contract kind', () => {
+ const form = contractToForm(mkContract({ franchise_rials: 300_000, kind: 'supplementary' }));
+ expect(form.franchise).toBe('30000');
+ expect(form.kind).toBe('supplementary');
+ expect(form.coverage).toBe('70');
+ });
+});
+
+describe('InsuranceModal', () => {
+ it('renders the fields in add mode with no manual kind select', () => {
+ renderWithProviders(
+ {}} onSubmit={() => {}} />,
+ );
+ expect(screen.getByText('افزودن بیمه')).toBeInTheDocument();
+ expect(screen.getByText('نام بیمه')).toBeInTheDocument();
+ // Manual "نوع بیمه" select is gone; kind is shown as a read-only chip from the tab.
+ expect(screen.queryByText('نوع بیمه')).not.toBeInTheDocument();
+ expect(screen.getByText('پایه')).toBeInTheDocument();
+ expect(screen.getByText('تاریخ شروع قرارداد')).toBeInTheDocument();
+ expect(screen.getByText('تاریخ پایان قرارداد')).toBeInTheDocument();
+ expect(screen.getByText('درصد پوشش')).toBeInTheDocument();
+ expect(screen.getByText('فرانشیز (تومان)')).toBeInTheDocument();
+ expect(screen.getByText('سقف تعهد (تومان)')).toBeInTheDocument();
+ expect(screen.getByText('ثبت بیمه')).toBeInTheDocument();
+ });
+
+ it('shows the tab kind chip and carries it into a new payload', () => {
+ const onSubmit = vi.fn();
+ renderWithProviders(
+ {}} onSubmit={onSubmit} />,
+ );
+ expect(screen.getByText('تکمیلی')).toBeInTheDocument();
+ });
+
+ it('submits the built payload for an edited contract', () => {
+ const onSubmit = vi.fn();
+ renderWithProviders(
+ {}} onSubmit={onSubmit} />,
+ );
+ fireEvent.click(screen.getByText('ثبت بیمه'));
+ expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({
+ insurance_id: 3, coverage_percent: 70, franchise_rials: 500_000, kind: 'basic',
+ }));
+ });
+});
diff --git a/assets/admin/components/InsuranceModal.tsx b/assets/admin/components/InsuranceModal.tsx
new file mode 100644
index 00000000..a85a3cc8
--- /dev/null
+++ b/assets/admin/components/InsuranceModal.tsx
@@ -0,0 +1,179 @@
+import { useEffect, useState } from 'react';
+import Modal from './ui/Modal';
+import SearchableSelect from './ui/SearchableSelect';
+import PersianDateInput from './ui/PersianDateInput';
+import { isoToUnix, rialToToman, tomanToRial, unixToIso } from '../lib/utils';
+import { digitsOnly } from '../lib/utils';
+
+export interface InsuranceOption {
+ insurance_id: number;
+ insurance_name: string;
+ type: string;
+}
+
+export interface Contract {
+ uuid: string;
+ insurance_id: number;
+ insurance_name: string | null;
+ insurance_kind: string | null;
+ version: number;
+ is_active: boolean;
+ coverage_percent: number;
+ franchise_rials: number;
+ annual_ceiling_rials: number | null;
+ kind: string | null;
+ effective_from: number;
+ effective_to: number | null;
+}
+
+export interface InsuranceFormValues {
+ insuranceId: string;
+ kind: string;
+ effectiveFrom: string; // Y-m-d
+ effectiveTo: string; // Y-m-d
+ coverage: string;
+ franchise: string; // toman
+ ceiling: string; // toman
+}
+
+export const KIND_LABEL: Record = {
+ basic: 'پایه',
+ supplementary: 'تکمیلی',
+};
+
+export const EMPTY_FORM: InsuranceFormValues = {
+ insuranceId: '', kind: 'basic', effectiveFrom: '', effectiveTo: '',
+ coverage: '', franchise: '', ceiling: '',
+};
+
+/** Map a contract to editable form values (rials → toman, unix → Y-m-d). */
+export function contractToForm(c: Contract): InsuranceFormValues {
+ return {
+ insuranceId: String(c.insurance_id),
+ kind: c.kind ?? c.insurance_kind ?? 'basic',
+ effectiveFrom: unixToIso(c.effective_from),
+ effectiveTo: unixToIso(c.effective_to),
+ coverage: String(c.coverage_percent ?? ''),
+ franchise: c.franchise_rials != null ? String(rialToToman(c.franchise_rials)) : '',
+ ceiling: c.annual_ceiling_rials != null ? String(rialToToman(c.annual_ceiling_rials)) : '',
+ };
+}
+
+/** Build the API payload from form values (toman → rials, Y-m-d → unix). */
+export function buildInsurancePayload(v: InsuranceFormValues) {
+ return {
+ insurance_id: Number(v.insuranceId),
+ kind: v.kind || null,
+ coverage_percent: Number(v.coverage) || 0,
+ franchise_rials: tomanToRial(Number(v.franchise) || 0),
+ annual_ceiling_rials: v.ceiling === '' ? null : tomanToRial(Number(v.ceiling)),
+ effective_from: isoToUnix(v.effectiveFrom),
+ effective_to: isoToUnix(v.effectiveTo),
+ };
+}
+
+interface Props {
+ open: boolean;
+ editContract: Contract | null;
+ /** Insurance catalog options; in edit mode all are shown, in add mode only the available ones. */
+ options: InsuranceOption[];
+ /** Insurance kind of the active tab ('basic'|'supplementary'); assigned to new contracts, not user-editable. */
+ kind: string;
+ onClose: () => void;
+ onSubmit: (payload: ReturnType) => void;
+ isPending?: boolean;
+}
+
+/**
+ * Add/edit insurance contract modal (افزودن/ویرایش بیمه). Presentational: owns form
+ * state, emits the built payload via onSubmit. Fields mirror the Figma "افزودن بیمه"
+ * modal plus the injected coverage/franchise/ceiling controls.
+ */
+export default function InsuranceModal({ open, editContract, options, kind, onClose, onSubmit, isPending }: Props) {
+ const [form, setForm] = useState(EMPTY_FORM);
+
+ useEffect(() => {
+ if (!open) return;
+ setForm(editContract ? contractToForm(editContract) : { ...EMPTY_FORM, kind });
+ }, [open, editContract, kind]);
+
+ const set = (patch: Partial) => setForm((f) => ({ ...f, ...patch }));
+ const isEdit = editContract !== null;
+
+ const submit = () => {
+ if (!form.insuranceId) return;
+ onSubmit(buildInsurancePayload(form));
+ };
+
+ const field = { display: 'flex', flexDirection: 'column' as const, gap: 6 };
+ const label = { fontSize: 12, fontWeight: 600, color: 'var(--text-2)' };
+
+ return (
+
+ لغو
+
+ {isPending ? '...' : 'ثبت بیمه'}
+
+ >
+ }
+ >
+
+
+
+ نام بیمه
+
+ {KIND_LABEL[form.kind] ?? form.kind}
+
+
+
({ value: String(i.insurance_id), label: i.insurance_name }))}
+ value={form.insuranceId}
+ onChange={(v) => set({ insuranceId: v ? String(v) : '' })}
+ isDisabled={isEdit}
+ placeholder="انتخاب کنید..."
+ />
+
+
+
+
+
تاریخ شروع قرارداد
+
set({ effectiveFrom: v })} placeholder="انتخاب" />
+
+
+
تاریخ پایان قرارداد
+
set({ effectiveTo: v })} placeholder="انتخاب" />
+
+
+
+
+
+
+ );
+}
diff --git a/assets/admin/components/InvoiceSummaryModal.test.tsx b/assets/admin/components/InvoiceSummaryModal.test.tsx
new file mode 100644
index 00000000..33bbaa79
--- /dev/null
+++ b/assets/admin/components/InvoiceSummaryModal.test.tsx
@@ -0,0 +1,104 @@
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { screen, waitFor } from '@testing-library/react';
+import { renderWithProviders } from '../test/utils';
+
+vi.mock('../lib/api', () => ({
+ api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() },
+ ApiError: class extends Error {},
+}));
+
+import { api } from '../lib/api';
+import InvoiceSummaryModal from './InvoiceSummaryModal';
+
+const get = api.get as ReturnType;
+
+const baseInvoice = {
+ uuid: 'iv1', status: 'finalized', issued_at: 1700000000, total_rials: 2_400_000,
+ base_insurance_rials: 0, supplementary_rials: 0, patient_rials: 900_000,
+ items: [{ uuid: 'it1', title: 'فول بادی', quantity: 1, total_rials: 2_400_000, patient_rials: 900_000 }],
+};
+
+const fullSession = {
+ session_at: 1700000000, paid_at: 1700100000,
+ services_total_rials: 2_400_000, consumables_total_rials: 40_000,
+ discount_rials: 200_000, final_price_rials: 2_240_000, paid_total_rials: 1_500_000,
+ // API واقعی همیشه این را میفرستد: max(0, final − discount − paid)
+ remaining_rials: 540_000,
+ payments: [
+ { uuid: 'p1', method: 'wallet', amount_rials: 1_500_000, paid_at: 1700100000, created_by_name: 'منشی تست' },
+ ],
+ consumables: [
+ { uuid: 'c1', item_name: 'عینک', quantity: 2, line_total_rials: 40_000 },
+ ],
+};
+
+beforeEach(() => {
+ get.mockReset();
+});
+
+function mockInvoice(invoice: object) {
+ get.mockResolvedValue({ success: true, data: { data: invoice } });
+}
+
+describe('InvoiceSummaryModal', () => {
+ it('با session کامل: جدول کالای مصرفی، پرداختیها و تخفیف را نشان میدهد', async () => {
+ mockInvoice({ ...baseInvoice, session: fullSession });
+ renderWithProviders( {}} />);
+
+ await waitFor(() => expect(screen.getByText('اطلاعات فاکتور')).toBeInTheDocument());
+
+ // کالای مصرفی
+ expect(screen.getByText('اطلاعات کالای مصرفی')).toBeInTheDocument();
+ expect(screen.getByText('عینک')).toBeInTheDocument();
+
+ // پرداختیها با label فارسی روش و ثبتکننده
+ expect(screen.getByText('پرداختی ها')).toBeInTheDocument();
+ expect(screen.getByText('پرداخت از کیف پول')).toBeInTheDocument();
+ expect(screen.getByText('منشی تست')).toBeInTheDocument();
+
+ // خلاصه مالی با ستون تخفیف و جمع کالا
+ expect(screen.getByText('تخفیف')).toBeInTheDocument();
+ expect(screen.getByText('جمع مبلغ کالا')).toBeInTheDocument();
+
+ // وضعیت — مبالغ واقعی (نمایش تومان = ریال ÷ ۱۰):
+ // پرداختشده ۱۵۰٬۰۰۰ (هم در جدول پرداختیها هم وضعیت) و باقیمانده ۵۴٬۰۰۰
+ // (۲۲۴۰۰۰۰ − ۲۰۰۰۰۰ تخفیف − ۱۵۰۰۰۰۰ پرداختی = ۵۴۰۰۰۰ ریال؛ final_price پیش از تخفیف است)
+ expect(screen.getAllByText(/۱۵۰٬۰۰۰/).length).toBeGreaterThanOrEqual(2);
+ expect(screen.getByText(/۵۴٬۰۰۰/)).toBeInTheDocument();
+ });
+
+ it('بدون session (فاکتور قدیمی): رفتار قبلی حفظ میشود', async () => {
+ mockInvoice({ ...baseInvoice, session: null });
+ renderWithProviders( {}} />);
+
+ await waitFor(() => expect(screen.getByText('اطلاعات فاکتور')).toBeInTheDocument());
+
+ // جدولهای session-محور رندر نمیشوند
+ expect(screen.queryByText('اطلاعات کالای مصرفی')).not.toBeInTheDocument();
+ expect(screen.queryByText('پرداختی ها')).not.toBeInTheDocument();
+
+ // خلاصه مالی قدیمی با سهم بیمار
+ expect(screen.getByText('سهم بیمار')).toBeInTheDocument();
+ });
+
+ it('session با payments/consumables خالی: ردیف خط تیره', async () => {
+ mockInvoice({
+ ...baseInvoice,
+ session: { ...fullSession, payments: [], consumables: [], paid_total_rials: 0, discount_rials: 0 },
+ });
+ renderWithProviders( {}} />);
+
+ await waitFor(() => expect(screen.getByText('اطلاعات فاکتور')).toBeInTheDocument());
+
+ expect(screen.getByText('اطلاعات کالای مصرفی')).toBeInTheDocument();
+ expect(screen.getByText('پرداختی ها')).toBeInTheDocument();
+ // ردیفهای '-' برای هر دو جدول خالی + ستون تخفیف صفر
+ expect(screen.getAllByText('-').length).toBeGreaterThanOrEqual(8);
+ });
+
+ it('invoiceUuid=null: مودال بسته و بدون fetch', () => {
+ renderWithProviders( {}} />);
+ expect(get).not.toHaveBeenCalled();
+ expect(screen.queryByText('خلاصه فاکتور')).not.toBeInTheDocument();
+ });
+});
diff --git a/assets/admin/components/InvoiceSummaryModal.tsx b/assets/admin/components/InvoiceSummaryModal.tsx
new file mode 100644
index 00000000..128eb383
--- /dev/null
+++ b/assets/admin/components/InvoiceSummaryModal.tsx
@@ -0,0 +1,161 @@
+import { useQuery } from '@tanstack/react-query';
+import { api } from '../lib/api';
+import type { ApiResponse } from '../lib/api';
+import Modal from './ui/Modal';
+import { formatDate, formatDateTime, formatRial } from '../lib/utils';
+import { METHOD_LABELS } from './session/PaymentStep';
+
+interface InvoiceItem { uuid: string; title: string; quantity: number; total_rials: number; patient_rials: number }
+interface SessionPayment { uuid: string; method: string; amount_rials: number; paid_at: number; created_by_name: string | null }
+interface SessionConsumable { uuid: string; item_name: string; quantity: number; line_total_rials: number }
+interface SessionData {
+ session_at: number | null; paid_at: number | null;
+ services_total_rials: number; consumables_total_rials: number;
+ discount_rials: number; final_price_rials: number; paid_total_rials: number;
+ gross_total_rials?: number; base_insurance_rials?: number;
+ supplementary_insurance_rials?: number; patient_share_rials?: number;
+ remaining_rials?: number;
+ payments: SessionPayment[]; consumables: SessionConsumable[];
+}
+interface Invoice {
+ uuid: string; status: string; issued_at: number; total_rials: number;
+ base_insurance_rials: number; supplementary_rials: number; patient_rials: number;
+ items: InvoiceItem[];
+ session?: SessionData | null;
+}
+
+const STATUS_LABEL: Record