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
@@ -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"