236 lines
16 KiB
Markdown
236 lines
16 KiB
Markdown
# بازطراحی صفحه بیمه و قیمتگذاری — تفکیک نوع بیمه با 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) تا انتقال چیزی را نشکند.
|