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:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user