feat: redesign insurance pricing page with tabbed navigation for basic and supplementary insurance, expandable rows for contract details, and move free visit price card to appointment settings
This commit is contained in:
@@ -0,0 +1,235 @@
|
||||
# بازطراحی صفحه بیمه و قیمتگذاری — تفکیک نوع بیمه با 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) تا انتقال چیزی را نشکند.
|
||||
Reference in New Issue
Block a user