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:
hamed
2026-07-15 14:55:16 +03:30
parent 3e5dee0ad5
commit 9f75aeedf5
8 changed files with 495 additions and 96 deletions
@@ -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) تا انتقال چیزی را نشکند.
@@ -51,13 +51,15 @@ describe('contractToForm', () => {
});
describe('InsuranceModal', () => {
it('renders all seven fields in add mode', () => {
it('renders the fields in add mode with no manual kind select', () => {
renderWithProviders(
<InsuranceModal open editContract={null} options={options} onClose={() => {}} onSubmit={() => {}} />,
<InsuranceModal open editContract={null} options={options} kind="basic" onClose={() => {}} onSubmit={() => {}} />,
);
expect(screen.getByText('افزودن بیمه')).toBeInTheDocument();
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();
@@ -66,10 +68,18 @@ describe('InsuranceModal', () => {
expect(screen.getByText('ثبت بیمه')).toBeInTheDocument();
});
it('shows the tab kind chip and carries it into a new payload', () => {
const onSubmit = vi.fn();
renderWithProviders(
<InsuranceModal open editContract={null} options={options} kind="supplementary" onClose={() => {}} onSubmit={onSubmit} />,
);
expect(screen.getByText('تکمیلی')).toBeInTheDocument();
});
it('submits the built payload for an edited contract', () => {
const onSubmit = vi.fn();
renderWithProviders(
<InsuranceModal open editContract={mkContract()} options={options} onClose={() => {}} onSubmit={onSubmit} />,
<InsuranceModal open editContract={mkContract()} options={options} kind="basic" onClose={() => {}} onSubmit={onSubmit} />,
);
fireEvent.click(screen.getByText('ثبت بیمه'));
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({
+14 -12
View File
@@ -76,6 +76,8 @@ interface Props {
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<typeof buildInsurancePayload>) => void;
isPending?: boolean;
@@ -86,13 +88,13 @@ interface Props {
* 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, onClose, onSubmit, isPending }: Props) {
export default function InsuranceModal({ open, editContract, options, kind, onClose, onSubmit, isPending }: Props) {
const [form, setForm] = useState<InsuranceFormValues>(EMPTY_FORM);
useEffect(() => {
if (!open) return;
setForm(editContract ? contractToForm(editContract) : EMPTY_FORM);
}, [open, editContract]);
setForm(editContract ? contractToForm(editContract) : { ...EMPTY_FORM, kind });
}, [open, editContract, kind]);
const set = (patch: Partial<InsuranceFormValues>) => setForm((f) => ({ ...f, ...patch }));
const isEdit = editContract !== null;
@@ -127,7 +129,15 @@ export default function InsuranceModal({ open, editContract, options, onClose, o
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div style={field}>
<label style={label}>نام بیمه</label>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
<label style={label}>نام بیمه</label>
<span style={{
fontSize: 11, fontWeight: 600, padding: '2px 10px', borderRadius: 'var(--r-pill)',
background: 'var(--primary-soft)', color: 'var(--primary)',
}}>
{KIND_LABEL[form.kind] ?? form.kind}
</span>
</div>
<SearchableSelect
options={options.map((i) => ({ value: String(i.insurance_id), label: i.insurance_name }))}
value={form.insuranceId}
@@ -137,14 +147,6 @@ export default function InsuranceModal({ open, editContract, options, onClose, o
/>
</div>
<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>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div style={field}>
<label style={label}>تاریخ شروع قرارداد</label>
@@ -9,7 +9,7 @@ vi.mock('../lib/api', () => ({
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
import { api } from '../lib/api';
import TenantInsuranceContracts, { filterInsurances } from './TenantInsuranceContracts';
import TenantInsuranceContracts, { filterInsurances, contractSummary } from './TenantInsuranceContracts';
import type { Contract } from './InsuranceModal';
const get = api.get as ReturnType<typeof vi.fn>;
@@ -38,6 +38,20 @@ describe('filterInsurances', () => {
});
});
describe('contractSummary', () => {
it('shows coverage, franchise and ceiling inline', () => {
const s = contractSummary(mk({ coverage_percent: 90, franchise_rials: 500_000, annual_ceiling_rials: 20_000_000 }));
expect(s).toContain('پوشش');
expect(s).toContain('فرانشیز');
expect(s).toContain('سقف پوشش');
});
it('omits franchise when zero and marks unlimited ceiling', () => {
const s = contractSummary(mk({ franchise_rials: 0, annual_ceiling_rials: null }));
expect(s).not.toContain('فرانشیز');
expect(s).toContain('سقف پوشش نامحدود');
});
});
describe('TenantInsuranceContracts', () => {
beforeEach(() => {
get.mockReset();
@@ -46,8 +60,9 @@ describe('TenantInsuranceContracts', () => {
get.mockImplementation((path: string) => {
if (path.includes('tenant-insurances')) {
return Promise.resolve({ success: true, data: { data: [
mk({ uuid: 'u1', insurance_id: 3, insurance_name: 'بیمه ایران', is_active: true }),
mk({ uuid: 'u2', insurance_id: 5, insurance_name: 'بیمه آسیا', is_active: false }),
mk({ uuid: 'u1', insurance_id: 3, insurance_name: 'بیمه ایران', insurance_kind: 'basic', is_active: true }),
mk({ uuid: 'u2', insurance_id: 5, insurance_name: 'بیمه آسیا', insurance_kind: 'basic', is_active: false }),
mk({ uuid: 'u3', insurance_id: 7, insurance_name: 'بیمه دانا', insurance_kind: 'supplementary', is_active: true }),
] } });
}
return Promise.resolve({ success: true, data: { insurances: [] } });
@@ -56,10 +71,42 @@ describe('TenantInsuranceContracts', () => {
// Desktop table and mobile cards both render in jsdom (CSS `hidden`/`md:` is inert),
// so each row's text appears twice — assertions use *AllBy* accordingly.
it('lists active and inactive contracts', async () => {
it('lists only the active tab (basic) contracts', async () => {
renderWithProviders(<TenantInsuranceContracts />);
expect((await screen.findAllByText('بیمه ایران')).length).toBeGreaterThan(0);
expect(screen.getAllByText('بیمه آسیا').length).toBeGreaterThan(0);
// Supplementary contract is hidden under the other tab.
expect(screen.queryByText('بیمه دانا')).not.toBeInTheDocument();
});
it('switching to the supplementary tab filters by kind', async () => {
renderWithProviders(<TenantInsuranceContracts />);
await screen.findAllByText('بیمه ایران');
fireEvent.click(screen.getByRole('tab', { name: /بیمه تکمیلی/ }));
expect((await screen.findAllByText('بیمه دانا')).length).toBeGreaterThan(0);
expect(screen.queryByText('بیمه ایران')).not.toBeInTheDocument();
});
it('classifies by catalog type, overriding a stale contract kind', async () => {
// آسیا is stored on the contract as basic (legacy manual pick) but the catalog
// marks it supplementary — catalog type wins, so it must leave the basic tab.
get.mockImplementation((path: string) => {
if (path.includes('tenant-insurances')) {
return Promise.resolve({ success: true, data: { data: [
mk({ uuid: 'u1', insurance_id: 3, insurance_name: 'بیمه ایران', insurance_kind: 'basic' }),
mk({ uuid: 'u2', insurance_id: 5, insurance_name: 'بیمه آسیا', insurance_kind: 'basic' }),
] } });
}
return Promise.resolve({ success: true, data: { insurances: [
{ insurance_id: 3, insurance_name: 'بیمه ایران', type: 'basic' },
{ insurance_id: 5, insurance_name: 'بیمه آسیا', type: 'supplementary' },
] } });
});
renderWithProviders(<TenantInsuranceContracts />);
await screen.findByText('بیمه ایران');
expect(screen.queryByText('بیمه آسیا')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('tab', { name: /بیمه تکمیلی/ }));
expect(await screen.findByText('بیمه آسیا')).toBeInTheDocument();
});
it('search box filters rows by name', async () => {
@@ -70,12 +117,22 @@ describe('TenantInsuranceContracts', () => {
expect(screen.getAllByText('بیمه آسیا').length).toBeGreaterThan(0);
});
it('status toggle PATCHes is_active', async () => {
it('clicking a row expands its detail, clicking again collapses', async () => {
renderWithProviders(<TenantInsuranceContracts />);
const names = await screen.findAllByText('بیمه ایران');
expect(screen.queryByText('نسخه قرارداد')).not.toBeInTheDocument();
fireEvent.click(names[0]);
expect(screen.getAllByText('نسخه قرارداد').length).toBeGreaterThan(0);
fireEvent.click(names[0]);
expect(screen.queryByText('نسخه قرارداد')).not.toBeInTheDocument();
});
it('clicking the status toggle does not expand the row (stopPropagation)', async () => {
renderWithProviders(<TenantInsuranceContracts />);
await screen.findAllByText('بیمه ایران');
// The active row's toggle offers to deactivate it.
const toggles = screen.getAllByLabelText('غیرفعال کردن');
fireEvent.click(toggles[0]);
expect(screen.queryByText('نسخه قرارداد')).not.toBeInTheDocument();
await waitFor(() =>
expect(patch).toHaveBeenCalledWith('/api/v1/billing/tenant-insurances/u1', { is_active: false }),
);
@@ -1,11 +1,22 @@
import { useMemo, useState, type ReactNode } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { PlusIcon, PencilIcon, MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import { PlusIcon, PencilIcon, MagnifyingGlassIcon, ChevronDownIcon } from '@heroicons/react/24/outline';
import { toast } from 'sonner';
import { api } from '../lib/api';
import { formatRial } from '../lib/utils';
import { formatRial, formatNumber, formatDate } from '../lib/utils';
import InsuranceModal, { Contract, InsuranceOption, KIND_LABEL, buildInsurancePayload } from './InsuranceModal';
type Kind = 'basic' | 'supplementary';
const KINDS: { key: Kind; label: string; addLabel: string; emptyLabel: string }[] = [
{ key: 'basic', label: 'بیمه پایه', addLabel: 'افزودن بیمه پایه', emptyLabel: 'هنوز بیمه‌ی پایه‌ای اضافه نکرده‌اید.' },
{ key: 'supplementary', label: 'بیمه تکمیلی', addLabel: 'افزودن بیمه تکمیلی', emptyLabel: 'هنوز بیمه‌ی تکمیلی‌ای اضافه نکرده‌اید.' },
];
/** Contract's effective kind, falling back to 'basic' for legacy rows with no kind/type. */
const contractKind = (c: Contract): Kind =>
(c.insurance_kind === 'supplementary' ? 'supplementary' : 'basic');
/** Case-insensitive filter over insurance name (and code) — the "جستجو در بیمه ها..." box. */
export function filterInsurances(list: Contract[], query: string): Contract[] {
const q = query.trim().toLowerCase();
@@ -16,11 +27,21 @@ export function filterInsurances(list: Contract[], query: string): Contract[] {
);
}
/** One-line readable summary shown on the collapsed row: پوشش ۹۰٪ · فرانشیز … · سقف پوشش … */
export function contractSummary(c: Contract): string {
const parts = [`پوشش ${formatNumber(c.coverage_percent)}٪`];
if (c.franchise_rials > 0) parts.push(`فرانشیز ${formatRial(c.franchise_rials)}`);
parts.push(c.annual_ceiling_rials != null ? `سقف پوشش ${formatRial(c.annual_ceiling_rials)}` : 'سقف پوشش نامحدود');
return parts.join(' · ');
}
export default function TenantInsuranceContracts() {
const qc = useQueryClient();
const [tab, setTab] = useState<Kind>('basic');
const [modalOpen, setModalOpen] = useState(false);
const [editContract, setEditContract] = useState<Contract | null>(null);
const [search, setSearch] = useState('');
const [expanded, setExpanded] = useState<string | null>(null);
const contractsQuery = useQuery({
queryKey: ['tenant-insurances'],
@@ -35,11 +56,28 @@ export default function TenantInsuranceContracts() {
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));
// Add-mode options: only the active tab's kind, excluding already-contracted insurances.
const available = allInsurances.filter((i) => i.type === tab).filter((i) => !activeIds.has(i.insurance_id));
const rows = useMemo(() => filterInsurances(contracts, search), [contracts, search]);
// The insurance's real type comes from the catalog, not the contract's stored kind
// (legacy contracts carry a stale manually-picked kind). Catalog type is authoritative.
const catalogTypeById = useMemo(() => {
const m: Record<number, Kind> = {};
for (const i of allInsurances) m[i.insurance_id] = i.type === 'supplementary' ? 'supplementary' : 'basic';
return m;
}, [allInsurances]);
const kindOf = (c: Contract): Kind =>
catalogTypeById[c.insurance_id] ?? contractKind(c);
const byKind = useMemo(() => contracts.filter((c) => kindOf(c) === tab), [contracts, tab, catalogTypeById]);
const rows = useMemo(() => filterInsurances(byKind, search), [byKind, search]);
const counts = useMemo(() => ({
basic: contracts.filter((c) => kindOf(c) === 'basic').length,
supplementary: contracts.filter((c) => kindOf(c) === 'supplementary').length,
}), [contracts, catalogTypeById]);
const invalidate = () => qc.invalidateQueries({ queryKey: ['tenant-insurances'] });
const toggleRow = (uuid: string) => setExpanded((p) => (p === uuid ? null : uuid));
const saveMut = useMutation({
mutationFn: (payload: ReturnType<typeof buildInsurancePayload>) =>
@@ -65,15 +103,46 @@ export default function TenantInsuranceContracts() {
const openEdit = (c: Contract) => { setEditContract(c); setModalOpen(true); };
const closeModal = () => { setModalOpen(false); setEditContract(null); };
const activeKind = KINDS.find((k) => k.key === tab)!;
return (
<div className="card" style={{ padding: 20 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12, marginBottom: 16, flexWrap: 'wrap' }}>
<h2 style={{ fontSize: 15, fontWeight: 700, margin: 0 }}>مدیریت بیمه</h2>
<button className="btn primary sm" onClick={openAdd}>
<PlusIcon style={{ width: 15 }} /> افزودن بیمه
<PlusIcon style={{ width: 15 }} /> {activeKind.addLabel}
</button>
</div>
<div role="tablist" style={{ display: 'flex', gap: 4, borderBottom: '1px solid var(--border)', marginBottom: 16 }}>
{KINDS.map((k) => {
const active = k.key === tab;
return (
<button
key={k.key}
role="tab"
aria-selected={active}
onClick={() => { setTab(k.key); setExpanded(null); }}
style={{
display: 'inline-flex', alignItems: 'center', gap: 6, padding: '8px 14px',
background: 'none', border: 'none', cursor: 'pointer', fontSize: 13, fontWeight: 600,
color: active ? 'var(--primary)' : 'var(--text-3)',
borderBottom: `2px solid ${active ? 'var(--primary)' : 'transparent'}`,
marginBottom: -1, transition: 'color .2s, border-color .2s',
}}
>
{k.label}
<span style={{
fontSize: 11, fontWeight: 700, minWidth: 18, padding: '0 6px', borderRadius: 'var(--r-pill)',
background: active ? 'var(--primary-soft)' : 'var(--surface-2)', color: active ? 'var(--primary)' : 'var(--text-3)',
}}>
{formatNumber(counts[k.key])}
</span>
</button>
);
})}
</div>
<div style={{ position: 'relative', marginBottom: 16 }}>
<MagnifyingGlassIcon style={{ width: 16, position: 'absolute', insetInlineStart: 12, top: '50%', transform: 'translateY(-50%)', color: 'var(--text-3)' }} />
<input
@@ -89,74 +158,29 @@ export default function TenantInsuranceContracts() {
<div className="muted" style={{ fontSize: 13 }}>در حال بارگذاری...</div>
) : rows.length === 0 ? (
<div style={{ fontSize: 12.5, color: 'var(--text-3)', padding: '16px 0', textAlign: 'center' }}>
{search ? 'بیمه‌ای یافت نشد.' : 'هنوز با هیچ بیمه‌ای قرارداد ندارید.'}
{search ? 'بیمه‌ای یافت نشد.' : activeKind.emptyLabel}
</div>
) : (
<>
{/* Desktop table */}
<div className="hidden md:block" style={{ overflowX: 'auto' }}>
<table className="data-table" style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ textAlign: 'start', color: 'var(--text-3)', fontSize: 12 }}>
<th style={{ padding: '10px 12px', textAlign: 'start' }}>ردیف</th>
<th style={{ padding: '10px 12px', textAlign: 'start' }}>نام بیمه</th>
<th style={{ padding: '10px 12px', textAlign: 'start' }}>کد</th>
<th style={{ padding: '10px 12px', textAlign: 'start' }}>نوع بیمه</th>
<th style={{ padding: '10px 12px', textAlign: 'start' }}>وضعیت</th>
<th style={{ padding: '10px 12px', textAlign: 'start' }}>عملیات</th>
</tr>
</thead>
<tbody>
{rows.map((c, i) => (
<tr key={c.uuid} style={{ borderTop: '1px solid var(--border)', fontSize: 13 }}>
<td style={{ padding: '12px' }}>{i + 1}</td>
<td style={{ padding: '12px', fontWeight: 600 }}>
{c.insurance_name ?? `#${c.insurance_id}`}
<div style={{ fontSize: 11, color: 'var(--text-3)', fontWeight: 400, marginTop: 2 }}>
پوشش {c.coverage_percent}٪
{c.franchise_rials > 0 && ` · فرانشیز ${formatRial(c.franchise_rials)}`}
{c.annual_ceiling_rials != null && ` · سقف ${formatRial(c.annual_ceiling_rials)}`}
</div>
</td>
<td style={{ padding: '12px', color: 'var(--text-2)' }} dir="ltr">{c.insurance_id}</td>
<td style={{ padding: '12px' }}>{KIND_LABEL[c.insurance_kind ?? ''] ?? c.insurance_kind ?? '—'}</td>
<td style={{ padding: '12px' }}>
<StatusToggle contract={c} onToggle={() => toggleMut.mutate(c)} disabled={toggleMut.isPending} />
</td>
<td style={{ padding: '12px' }}>
<button className="mini-btn" title="ویرایش" onClick={() => openEdit(c)}>
<PencilIcon style={{ width: 15 }} />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Mobile cards */}
<div className="md:hidden" style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{rows.map((c) => (
<div key={c.uuid} style={{ border: '1px solid var(--border)', borderRadius: 12, padding: 14 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
<div style={{ fontWeight: 700, fontSize: 14 }}>{c.insurance_name ?? `#${c.insurance_id}`}</div>
<button className="mini-btn" title="ویرایش" onClick={() => openEdit(c)}>
<PencilIcon style={{ width: 15 }} />
</button>
</div>
<Row label="کد" value={<span dir="ltr">{c.insurance_id}</span>} />
<Row label="نوع بیمه" value={KIND_LABEL[c.insurance_kind ?? ''] ?? c.insurance_kind ?? '—'} />
<Row label="وضعیت" value={<StatusToggle contract={c} onToggle={() => toggleMut.mutate(c)} disabled={toggleMut.isPending} />} />
</div>
))}
</div>
</>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{rows.map((c) => (
<ContractCard
key={c.uuid}
contract={c}
open={expanded === c.uuid}
onToggleRow={() => toggleRow(c.uuid)}
onEdit={() => openEdit(c)}
onToggleStatus={() => toggleMut.mutate(c)}
statusPending={toggleMut.isPending}
/>
))}
</div>
)}
<InsuranceModal
open={modalOpen}
editContract={editContract}
options={editContract ? allInsurances : available}
kind={editContract ? kindOf(editContract) : tab}
onClose={closeModal}
onSubmit={(payload) => saveMut.mutate(payload)}
isPending={saveMut.isPending}
@@ -165,16 +189,74 @@ export default function TenantInsuranceContracts() {
);
}
function Row({ label, value }: { label: string; value: ReactNode }) {
interface RowProps {
contract: Contract;
open: boolean;
onToggleRow: () => void;
onEdit: () => void;
onToggleStatus: () => void;
statusPending?: boolean;
}
function ContractCard({ contract: c, open, onToggleRow, onEdit, onToggleStatus, statusPending }: RowProps) {
const stop = (fn: () => void) => (e: React.MouseEvent) => { e.stopPropagation(); fn(); };
return (
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '6px 0', fontSize: 12.5, borderTop: '1px solid var(--border-2)' }}>
<span style={{ color: 'var(--text-3)' }}>{label}:</span>
<span>{value}</span>
<div style={{ border: '1px solid var(--border)', borderRadius: 12, padding: 14, cursor: 'pointer' }} onClick={onToggleRow}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<ChevronDownIcon style={{ width: 15, color: 'var(--text-3)', transition: 'transform .2s var(--ease)', transform: open ? 'rotate(180deg)' : 'none' }} />
<div style={{ fontWeight: 700, fontSize: 14 }}>{c.insurance_name ?? `#${c.insurance_id}`}</div>
</div>
<button className="mini-btn" title="ویرایش" onClick={stop(onEdit)}>
<PencilIcon style={{ width: 15 }} />
</button>
</div>
<div style={{ fontSize: 12, color: 'var(--text-2)', marginBottom: 8 }}>{contractSummary(c)}</div>
<div style={{ display: 'flex', justifyContent: 'flex-end' }} onClick={stop(() => {})}>
<StatusToggle contract={c} onToggle={stop(onToggleStatus)} disabled={statusPending} />
</div>
{open && <div style={{ marginTop: 10 }}><ContractDetails contract={c} /></div>}
</div>
);
}
function StatusToggle({ contract, onToggle, disabled }: { contract: Contract; onToggle: () => void; disabled?: boolean }) {
/** Expanded full detail of a contract (all fields), shown when its card is open. */
function ContractDetails({ contract: c }: { contract: Contract }) {
return (
<div style={{
background: 'var(--surface-2)', border: '1px solid var(--border-2)', borderRadius: 'var(--r-sm)',
padding: 14,
display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(150px, 1fr))', gap: 14,
}}>
<DetailCell label="درصد پوشش" value={`${formatNumber(c.coverage_percent)}٪`} />
<DetailCell label="فرانشیز" value={c.franchise_rials > 0 ? formatRial(c.franchise_rials) : '—'} />
<DetailCell label="سقف تعهد سالانه" value={c.annual_ceiling_rials != null ? formatRial(c.annual_ceiling_rials) : 'نامحدود'} />
<DetailCell label="تاریخ شروع قرارداد" value={formatDate(c.effective_from)} />
<DetailCell label="تاریخ پایان قرارداد" value={c.effective_to != null ? formatDate(c.effective_to) : 'بدون تاریخ پایان'} />
<DetailCell label="نسخه قرارداد" value={<span dir="ltr">{formatNumber(c.version)}</span>} />
<DetailCell label="کد بیمه" value={<span dir="ltr">{c.insurance_id}</span>} />
<DetailCell
label="وضعیت"
value={
<span style={{ color: c.is_active ? 'var(--success)' : 'var(--text-3)', fontWeight: 700 }}>
{c.is_active ? 'فعال' : 'غیرفعال'}
</span>
}
/>
</div>
);
}
function DetailCell({ label, value }: { label: string; value: ReactNode }) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<span style={{ fontSize: 11, color: 'var(--text-3)' }}>{label}</span>
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text)' }}>{value}</span>
</div>
);
}
function StatusToggle({ contract, onToggle, disabled }: { contract: Contract; onToggle: (e: React.MouseEvent) => void; disabled?: boolean }) {
return (
<button
type="button"
@@ -31,4 +31,16 @@ describe('AppointmentSettingsPage', () => {
expect((await screen.findAllByText('مدیریت نوبت دهی')).length).toBeGreaterThan(1);
expect(screen.getByText('خرید اشتراک')).toBeInTheDocument(); // shell menu
});
it('renders the free-visit price card (moved here from insurance page)', async () => {
renderWithProviders(<AppointmentSettingsPage />, { route: '/admin/appointment-settings' });
expect(await screen.findByText('قیمت ویزیت آزاد')).toBeInTheDocument();
});
it('shows the free-visit price card even when no doctor uuid is present', async () => {
useAuthStore.setState({ primaryRole: 'clinic', doctorUuid: null, dbUuid: null });
renderWithProviders(<AppointmentSettingsPage />, { route: '/admin/appointment-settings' });
expect(await screen.findByText('قیمت ویزیت آزاد')).toBeInTheDocument();
expect(screen.getByText('این بخش فقط برای پزشک در دسترس است.')).toBeInTheDocument();
});
});
@@ -3,6 +3,7 @@ import { api } from '../lib/api';
import type { ApiResponse } from '../lib/api';
import { useAuthStore } from '../stores/authStore';
import SettingsLayout from '../components/layout/SettingsLayout';
import FreeVisitPrice from '../components/FreeVisitPrice';
import { WeeklyScheduleTab, type AddressData } from './DoctorDetailPage';
/**
@@ -29,6 +30,8 @@ export default function AppointmentSettingsPage() {
<div className="fade-in">
<h1 className="section-title" style={{ marginBottom: 16 }}>مدیریت نوبت دهی</h1>
<FreeVisitPrice />
{!uuid ? (
<div className="card" style={{ padding: 32, textAlign: 'center', color: 'var(--text-3)', fontSize: 14 }}>
این بخش فقط برای پزشک در دسترس است.
+2 -4
View File
@@ -1,6 +1,5 @@
import PageHeader from '../components/ui/PageHeader';
import TenantInsuranceContracts from '../components/TenantInsuranceContracts';
import FreeVisitPrice from '../components/FreeVisitPrice';
import FeatureGate from '../components/ui/FeatureGate';
import SettingsLayout from '../components/layout/SettingsLayout';
@@ -10,10 +9,9 @@ export default function InsurancePricingPage() {
<FeatureGate feature="insurance">
<div className="fade-in">
<PageHeader
title="بیمه و قیمت‌گذاری"
description=یمت ویزیت آزاد و قراردادهای بیمه (پایه و تکمیلی)"
title="مدیریت بیمه"
description="قراردادهای بیمه پایه و تکمیلی"
/>
<FreeVisitPrice />
<TenantInsuranceContracts />
</div>
</FeatureGate>