# اصلاحات بخش مدیریت سرویس‌ها (بیمه، ورودی‌های عددی، صفحه جزئیات)

## پروژه

`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
{/* بیمه */}
<div style={{ border: '1px solid var(--border)', ... }}>
  <label ...>
    <ShieldCheckIcon ... />
    <div>
      <div>این خدمت شامل بیمه می‌شود</div>
      <div>نشانه‌ی سریع برای فهرست سرویس‌ها</div>
    </div>
    <span className="switch">
      <input type="checkbox" checked={itemForm.watch('insurance_covered') ?? false} ... />
    </span>
  </label>
  {itemForm.watch('insurance_covered') && (
    <PriceInput value={itemForm.watch('insurance_price_rials') ?? 0} ... />   // قیمت تقریبی با بیمه
  )}
</div>
```

### ۲) باگ NaN — `ServiceInsuranceModal.tsx:129-135`

```tsx
<input
  type="text" inputMode="numeric" dir="ltr" className="input"
  value={draft.coverage_percent ?? ''}
  placeholder="ارث"
  onChange={(e) => 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
<Route path="clinic-services" element={<RoleRoute roles={['doctor', 'clinic']} blockClinicScope><ClinicServicesPage /></RoleRoute>} />
```

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)` روی `<input type="range">`؛ چون 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`:
  `<Route path="clinic-services/:uuid" element={...} />`.
- در `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` (نه `<select>` بومی)، تأییدها با `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`.
- در گزارش پایانی صریح بنویس چه چیزی ساخته نشد و چرا (کالاهای مرتبط، لاگ تغییرات).
