# بازطراحی صفحه بیمه و قیمت‌گذاری — تفکیک نوع بیمه با Tab + ردیف Expandable + انتقال ویزیت آزاد

## پروژه

`clinicpro` (پنل ادمین React — فقط frontend؛ backend موجود کافی است)

## زمینه

مسیر `/admin/insurance-pricing` امروز سه بخش را در یک صفحه نشان می‌دهد: «قیمت ویزیت آزاد» (`FreeVisitPrice`)، و «مدیریت بیمه» (`TenantInsuranceContracts`) که همه‌ی قراردادهای بیمه‌ی پایه و تکمیلی را در یک جدول مسطح فهرست می‌کند. مدیر مطب هنگام افزودن بیمه باید نوع بیمه را دستی از یک `select` انتخاب کند و اطلاعات کلیدی هر بیمه (پوشش، فرانشیز، سقف) فقط به‌صورت یک زیرنویس کمرنگ در ستون نام دیده می‌شود. جزئیات کامل قرارداد جایی نمایش داده نمی‌شود.

هدف: تجربه‌ی مدیریت بیمه را برای یک مدیر حرفه‌ای مطب/کلینیک سریع و خوانا کنیم — تفکیک پایه/تکمیلی با Tab، حذف انتخاب دستی نوع، نمایش خلاصه‌ی خوانا در ردیف، و ردیف‌های Expandable برای جزئیات کامل. همچنین «قیمت ویزیت آزاد» به صفحه‌ی «تنظیمات نوبت‌دهی» منتقل شود.

## backend — نیازی به تغییر نیست (اول گشتم)

طبق قاعده‌ی «اول بگرد، بعد بساز» endpointهای موجود کافی‌اند؛ **هیچ تغییر backend لازم نیست**:

- `GET /api/v1/insurance-pricing` (در [src/Insurance/Controller/InsuranceController.php](src/Insurance/Controller/InsuranceController.php) خط ۲۲۰) هر بیمه‌ی فعال را با فیلد `type` (`'basic'` | `'supplementary'`) و `free_visit_price_rials` برمی‌گرداند.
- `GET /api/v1/billing/tenant-insurances` (خط ۳۰۶) برای هر قرارداد `insurance_kind` (`kind` قرارداد یا در نبودش `type` کاتالوگ) و همه‌ی فیلدهای پوشش/فرانشیز/سقف/تاریخ را برمی‌گرداند.
- `POST /api/v1/billing/tenant-insurances` (خط ۳۳۳) فیلد `kind` را در payload می‌پذیرد و ذخیره می‌کند.
- `PUT /api/v1/insurance-pricing` (خط ۲۵۸) با `{ free_visit_price_rials }` قیمت ویزیت آزاد را ذخیره می‌کند.

پس فیلتر بر اساس نوع بیمه کاملاً **سمت frontend** انجام می‌شود (روی داده‌های موجود همین دو endpoint).

## فایل‌های مرتبط

| فایل | نقش | تغییر |
|------|-----|-------|
| [assets/admin/pages/InsurancePricingPage.tsx](assets/admin/pages/InsurancePricingPage.tsx) | صفحه‌ی بیمه و قیمت‌گذاری | حذف `<FreeVisitPrice/>` و توضیح مربوطه |
| [assets/admin/pages/AppointmentSettingsPage.tsx](assets/admin/pages/AppointmentSettingsPage.tsx) | صفحه‌ی تنظیمات نوبت‌دهی | افزودن `<FreeVisitPrice/>` |
| [assets/admin/components/FreeVisitPrice.tsx](assets/admin/components/FreeVisitPrice.tsx) | کارت قیمت ویزیت آزاد | بدون تغییر (فقط جابه‌جا می‌شود) |
| [assets/admin/components/TenantInsuranceContracts.tsx](assets/admin/components/TenantInsuranceContracts.tsx) | جدول مدیریت بیمه | بازنویسی: Tab پایه/تکمیلی + ردیف Expandable + خلاصه‌ی خوانا |
| [assets/admin/components/InsuranceModal.tsx](assets/admin/components/InsuranceModal.tsx) | مودال افزودن/ویرایش بیمه | حذف `select` نوع بیمه؛ `kind` از prop می‌آید |

## وضعیت فعلی

### `InsurancePricingPage.tsx`

```tsx
<PageHeader
  title="بیمه و قیمت‌گذاری"
  description="قیمت ویزیت آزاد و قراردادهای بیمه (پایه و تکمیلی)"
/>
<FreeVisitPrice />
<TenantInsuranceContracts />
```

### `AppointmentSettingsPage.tsx` (بخش render)

```tsx
) : (
  <WeeklyScheduleTab doctorUuid={uuid} addresses={addresses} />
)}
```

### `InsuranceModal.tsx` — انتخاب دستی نوع (حذف شود)

```tsx
<div style={field}>
  <label style={label}>نوع بیمه</label>
  <select className="input" value={form.kind} onChange={(e) => set({ kind: e.target.value })}>
    <option value="basic">پایه</option>
    <option value="supplementary">تکمیلی</option>
  </select>
</div>
```

### `TenantInsuranceContracts.tsx` — یک جدول مسطح، خلاصه فقط در زیرنویس نام

```tsx
const contracts: Contract[] = (contractsQuery.data as any)?.data?.data ?? [];
const allInsurances: InsuranceOption[] = (pricingQuery.data as any)?.data?.insurances ?? [];
const activeIds = new Set(contracts.map((c) => c.insurance_id));
const available = allInsurances.filter((i) => !activeIds.has(i.insurance_id));
...
<td style={{ padding: '12px', fontWeight: 600 }}>
  {c.insurance_name ?? `#${c.insurance_id}`}
  <div style={{ fontSize: 11, color: 'var(--text-3)', ... }}>
    پوشش {c.coverage_percent}٪
    {c.franchise_rials > 0 && ` · فرانشیز ${formatRial(c.franchise_rials)}`}
    {c.annual_ceiling_rials != null && ` · سقف ${formatRial(c.annual_ceiling_rials)}`}
  </div>
</td>
```

## وظایف

### ۱. انتقال «قیمت ویزیت آزاد» به تنظیمات نوبت‌دهی

**۱.۱ حذف از `InsurancePricingPage.tsx`:** خط `import FreeVisitPrice ...` و `<FreeVisitPrice />` را بردار. توضیح `PageHeader` را به «قراردادهای بیمه پایه و تکمیلی» تغییر بده (دیگر ویزیت آزاد اینجا نیست).

**۱.۲ افزودن به `AppointmentSettingsPage.tsx`:** `import FreeVisitPrice from '../components/FreeVisitPrice';` و کارت را **بالای** `WeeklyScheduleTab` رندر کن.

نکته‌ی مهم — گاردِ «فقط پزشک»: `FreeVisitPrice` از `GET/PUT /api/v1/insurance-pricing` استفاده می‌کند که entity را از روی نقش کاربر (`ROLE_DOCTOR` یا `ROLE_CLINIC`) resolve می‌کند (خط ۴۸ کنترلر)، پس هم برای پزشک و هم کلینیک کار می‌کند — اما `AppointmentSettingsPage` وقتی `uuid` پزشک نباشد کل محتوا را با پیام «این بخش فقط برای پزشک در دسترس است» جایگزین می‌کند. کارت قیمت ویزیت آزاد را **بیرون از** شرط `!uuid` و **قبل از** آن قرار بده تا مستقل از داشتن `doctorUuid` همیشه نمایش داده شود:

```tsx
return (
  <SettingsLayout active="appointment">
    <div className="fade-in">
      <h1 className="section-title" style={{ marginBottom: 16 }}>مدیریت نوبت دهی</h1>

      <FreeVisitPrice />

      {!uuid ? (
        <div className="card" ...>این بخش فقط برای پزشک در دسترس است.</div>
      ) : isLoading ? (
        ...
      ) : (
        <WeeklyScheduleTab doctorUuid={uuid} addresses={addresses} />
      )}
    </div>
  </SettingsLayout>
);
```

### ۲. حذف انتخاب دستی نوع بیمه از `InsuranceModal`

نوع بیمه دیگر دستی انتخاب نمی‌شود؛ از Tab فعال می‌آید.

**۲.۱** بلوک `<select>` نوع بیمه را کامل حذف کن.

**۲.۲** یک prop جدید `kind: 'basic' | 'supplementary'` به `Props` اضافه کن (نوع بیمه‌ی جاری بر اساس Tab). در حالت افزودن، `EMPTY_FORM.kind` را با این prop مقداردهی کن؛ در حالت ویرایش، `kind` قرارداد حفظ می‌شود:

```tsx
interface Props {
  open: boolean;
  editContract: Contract | null;
  options: InsuranceOption[];
  kind: string;                 // نوع بیمه‌ی Tab فعال — برای رکورد جدید
  onClose: () => void;
  onSubmit: (payload: ReturnType<typeof buildInsurancePayload>) => void;
  isPending?: boolean;
}

useEffect(() => {
  if (!open) return;
  setForm(editContract ? contractToForm(editContract) : { ...EMPTY_FORM, kind });
}, [open, editContract, kind]);
```

`buildInsurancePayload` بدون تغییر می‌ماند (همان `kind` را در payload می‌گذارد). یک نشانگر فقط‌خواندنی از نوع بیمه در مودال نشان بده تا کاربر بداند در کدام دسته اضافه می‌کند (مثلاً یک `chip` کوچک با `KIND_LABEL[kind]` نزدیک عنوان یا فیلد نام)، اما قابل تغییر نباشد.

### ۳. Tab پایه/تکمیلی در `TenantInsuranceContracts`

**۳.۱** یک state برای Tab فعال:

```tsx
type Kind = 'basic' | 'supplementary';
const [tab, setTab] = useState<Kind>('basic');
```

**۳.۲** نوار Tab بالای جدول (بعد از هدر «مدیریت بیمه»). از توکن‌های موجود استفاده کن (`--primary`, `--border`, `--text-3`) — الگوی Tab را از یک کامپوننت موجود که Tab دارد (مثلاً tabهای صفحه‌ی جزئیات) هم‌راستا کن. هر Tab تعداد قراردادهای همان نوع را به‌صورت badge نشان دهد:

```tsx
const KINDS: { key: Kind; label: string }[] = [
  { key: 'basic', label: 'بیمه پایه' },
  { key: 'supplementary', label: 'بیمه تکمیلی' },
];
```

**۳.۳** فیلتر قراردادها بر اساس Tab (روی `insurance_kind`) — سپس جستجو روی همان زیرمجموعه اعمال شود:

```tsx
const byKind = contracts.filter((c) => (c.insurance_kind ?? 'basic') === tab);
const rows = useMemo(() => filterInsurances(byKind, search), [byKind, search]);
```

**۳.۴** فیلتر لیست انتخاب هنگام افزودن بر اساس Tab (روی `type` کاتالوگ). فقط بیمه‌های همان نوع که هنوز قرارداد ندارند:

```tsx
const available = allInsurances
  .filter((i) => i.type === tab)
  .filter((i) => !activeIds.has(i.insurance_id));
```

توجه: `InsuranceOption.type` از پیش در interface هست (`assets/admin/components/InsuranceModal.tsx` خط ۱۰) و از `pricingQuery` می‌آید (`data.insurances[].type`).

**۳.۵** مودال را با `kind={tab}` صدا بزن و در حالت افزودن `options={available}` (که حالا بر اساس Tab فیلتر شده):

```tsx
<InsuranceModal
  open={modalOpen}
  editContract={editContract}
  options={editContract ? allInsurances : available}
  kind={tab}
  onClose={closeModal}
  onSubmit={(payload) => saveMut.mutate(payload)}
  isPending={saveMut.isPending}
/>
```

دکمه‌ی «افزودن بیمه» برچسبش را بر اساس Tab دقیق‌تر کن: «افزودن بیمه پایه» / «افزودن بیمه تکمیلی».

### ۴. خلاصه‌ی خوانا + ردیف Expandable

ستون «نوع بیمه» در جدول دیگر لازم نیست (با Tab مشخص است) — حذفش کن و جایش خلاصه‌ی اطلاعات کلیدی را مستقیماً در ردیف نشان بده.

**۴.۱ خلاصه‌ی ردیف (سطر جمع‌شده):** به‌جای زیرنویس کمرنگ، اطلاعات کلیدی را در یک ستون خوانا با جداکننده‌ی `·` نشان بده:

```
پوشش ۹۰٪ · فرانشیز ۵۰٬۰۰۰ تومان · سقف پوشش ۲٬۰۰۰٬۰۰۰ تومان
```

از `formatRial` (`assets/admin/lib/utils.ts`) برای مبالغ و ارقام فارسی استفاده کن. اگر `annual_ceiling_rials == null` به‌جای سقف «سقف پوشش نامحدود» نشان بده.

**۴.۲ Expandable Row:** هر ردیف با کلیک باز/بسته شود. state:

```tsx
const [expanded, setExpanded] = useState<string | null>(null); // contract.uuid
const toggleRow = (uuid: string) => setExpanded((p) => (p === uuid ? null : uuid));
```

- کل ردیف `clickable` باشد (`cursor: pointer`) و یک آیکون chevron (`ChevronDownIcon`/`ChevronUpIcon` از `@heroicons/react/24/outline`) وضعیت باز/بسته را نشان دهد.
- کلیک روی دکمه‌های عملیات (ویرایش) و روی سوییچ وضعیت **نباید** ردیف را toggle کند → در `onClick` آن‌ها `e.stopPropagation()`.
- ردیف جزئیات (`<tr>` دوم با `<td colSpan>`) وقتی `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) تا انتقال چیزی را نشکند.
