285 lines
13 KiB
TypeScript
285 lines
13 KiB
TypeScript
import { useMemo, useState, type ReactNode } from 'react';
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import { PlusIcon, PencilIcon, MagnifyingGlassIcon, ChevronDownIcon } from '@heroicons/react/24/outline';
|
|
import { toast } from 'sonner';
|
|
import { api } from '../lib/api';
|
|
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();
|
|
if (!q) return list;
|
|
return list.filter((c) =>
|
|
(c.insurance_name ?? '').toLowerCase().includes(q) ||
|
|
String(c.insurance_id).includes(q),
|
|
);
|
|
}
|
|
|
|
/** 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'],
|
|
queryFn: () => api.get('/api/v1/billing/tenant-insurances'),
|
|
});
|
|
|
|
const pricingQuery = useQuery({
|
|
queryKey: ['insurance-pricing'],
|
|
queryFn: () => api.get('/api/v1/insurance-pricing'),
|
|
});
|
|
|
|
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));
|
|
// 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));
|
|
|
|
// 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>) =>
|
|
editContract
|
|
? api.patch(`/api/v1/billing/tenant-insurances/${editContract.uuid}`, payload)
|
|
: api.post('/api/v1/billing/tenant-insurances', payload),
|
|
onSuccess: () => {
|
|
toast.success(editContract ? 'قرارداد بیمه ویرایش شد' : 'قرارداد بیمه فعال شد');
|
|
closeModal();
|
|
invalidate();
|
|
},
|
|
onError: (e: Error) => toast.error(e.message),
|
|
});
|
|
|
|
const toggleMut = useMutation({
|
|
mutationFn: (c: Contract) =>
|
|
api.patch(`/api/v1/billing/tenant-insurances/${c.uuid}`, { is_active: !c.is_active }),
|
|
onSuccess: () => invalidate(),
|
|
onError: (e: Error) => toast.error(e.message),
|
|
});
|
|
|
|
const openAdd = () => { setEditContract(null); setModalOpen(true); };
|
|
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 }} /> {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
|
|
className="input"
|
|
style={{ paddingInlineStart: 36 }}
|
|
placeholder="جستجو در بیمه ها..."
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
{contractsQuery.isLoading ? (
|
|
<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 ? 'بیمهای یافت نشد.' : activeKind.emptyLabel}
|
|
</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}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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={{ 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>
|
|
);
|
|
}
|
|
|
|
/** 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"
|
|
role="switch"
|
|
aria-checked={contract.is_active}
|
|
aria-label={contract.is_active ? 'غیرفعال کردن' : 'فعال کردن'}
|
|
onClick={onToggle}
|
|
disabled={disabled}
|
|
style={{ display: 'inline-flex', alignItems: 'center', gap: 8, background: 'none', border: 'none', cursor: disabled ? 'default' : 'pointer', padding: 0 }}
|
|
>
|
|
<span style={{
|
|
width: 36, height: 20, borderRadius: 999, position: 'relative', transition: 'background .2s',
|
|
background: contract.is_active ? 'var(--primary)' : 'var(--border)',
|
|
}}>
|
|
<span style={{
|
|
position: 'absolute', top: 2, width: 16, height: 16, borderRadius: 999, background: '#fff', transition: 'inset-inline .2s',
|
|
insetInlineStart: contract.is_active ? 18 : 2,
|
|
}} />
|
|
</span>
|
|
<span style={{ fontSize: 12, color: contract.is_active ? 'var(--success)' : 'var(--text-3)' }}>
|
|
{contract.is_active ? 'فعال' : 'غیرفعال'}
|
|
</span>
|
|
</button>
|
|
);
|
|
}
|