Multi-tenant insurance contracts, service coverage, versioned tariffs, invoice calculation, and insurance claims with debt reporting. - TenantInsurance: per-tenant insurance contracts (coverage/franchise/ceiling, versioning, soft-deactivate) + active guard - ServiceItem.insuranceCovered + TenantServiceCoverage per-service overrides - Tariff: versioned yearly tariffs with fallback to ServiceItem price - Billing domain: Money/ShareBreakdown VOs, BillingCalculator (unit-tested), Invoice/InvoiceItem aggregate, InvoiceService.createFromSession - Claim/ClaimItem with state machine (pending->submitted->approved/rejected->paid), ClaimService, insurance-debt report - ClaimSubmitterInterface + ManualClaimSubmitter (future insurance API ready) - Admin UI: insurance-pricing page, claims page, service tariff modal, service insurance toggle; routes + sidebar entries - Architecture doc + billing/insurance/clinic-services API docs Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
170 lines
6.1 KiB
TypeScript
170 lines
6.1 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import { toast } from 'sonner';
|
|
import { api } from '../lib/api';
|
|
import { formatRial } from '../lib/utils';
|
|
|
|
interface InsuranceRow {
|
|
insurance_id: number;
|
|
insurance_name: string;
|
|
type: string;
|
|
patient_share_rials: number | null;
|
|
}
|
|
|
|
interface PricingResponse {
|
|
free_visit_price_rials: number;
|
|
insurances: InsuranceRow[];
|
|
}
|
|
|
|
const TYPE_LABEL: Record<string, string> = {
|
|
basic: 'بیمه پایه',
|
|
supplementary: 'بیمه تکمیلی',
|
|
};
|
|
|
|
export default function InsurancePricingSection() {
|
|
const qc = useQueryClient();
|
|
const [freeVisit, setFreeVisit] = useState('');
|
|
const [shares, setShares] = useState<Record<number, string>>({});
|
|
|
|
const { data, isLoading } = useQuery<{ data: PricingResponse }>({
|
|
queryKey: ['insurance-pricing'],
|
|
queryFn: () => api.get('/api/v1/insurance-pricing'),
|
|
});
|
|
|
|
const pricing = (data as any)?.data as PricingResponse | undefined;
|
|
|
|
useEffect(() => {
|
|
if (!pricing) return;
|
|
setFreeVisit(String(pricing.free_visit_price_rials ?? 0));
|
|
const next: Record<number, string> = {};
|
|
pricing.insurances.forEach((i) => {
|
|
next[i.insurance_id] = i.patient_share_rials != null ? String(i.patient_share_rials) : '';
|
|
});
|
|
setShares(next);
|
|
}, [pricing]);
|
|
|
|
const saveMut = useMutation({
|
|
mutationFn: () =>
|
|
api.put('/api/v1/insurance-pricing', {
|
|
free_visit_price_rials: Number(freeVisit) || 0,
|
|
insurances: (pricing?.insurances ?? []).map((i) => ({
|
|
insurance_id: i.insurance_id,
|
|
patient_share_rials:
|
|
shares[i.insurance_id] === '' || shares[i.insurance_id] == null
|
|
? null
|
|
: Number(shares[i.insurance_id]) || 0,
|
|
})),
|
|
}),
|
|
onSuccess: () => {
|
|
toast.success('قیمتگذاری بیمه ذخیره شد');
|
|
qc.invalidateQueries({ queryKey: ['insurance-pricing'] });
|
|
},
|
|
onError: (e: Error) => toast.error(e.message),
|
|
});
|
|
|
|
const basics = (pricing?.insurances ?? []).filter((i) => i.type === 'basic');
|
|
const supps = (pricing?.insurances ?? []).filter((i) => i.type === 'supplementary');
|
|
|
|
const renderRow = (i: InsuranceRow) => {
|
|
const share = shares[i.insurance_id] ?? '';
|
|
const preview =
|
|
share !== '' && freeVisit !== ''
|
|
? `سهم بیمار: ${formatRial(Number(share) || 0)}`
|
|
: 'تعیین نشده';
|
|
return (
|
|
<div
|
|
key={i.insurance_id}
|
|
style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: 12,
|
|
padding: '10px 12px',
|
|
borderRadius: 10,
|
|
border: '1px solid var(--border)',
|
|
background: 'var(--surface)',
|
|
}}
|
|
>
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
<div style={{ fontWeight: 600, fontSize: 13.5 }}>{i.insurance_name}</div>
|
|
<div style={{ fontSize: 11.5, color: 'var(--text-3)', marginTop: 2 }}>{preview}</div>
|
|
</div>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
|
<input
|
|
type="number"
|
|
min={0}
|
|
dir="ltr"
|
|
className="input"
|
|
style={{ width: 150 }}
|
|
placeholder="سهم بیمار (ریال)"
|
|
value={share}
|
|
onChange={(e) =>
|
|
setShares((p) => ({ ...p, [i.insurance_id]: e.target.value }))
|
|
}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<div className="card" style={{ padding: 20 }}>
|
|
<div style={{ marginBottom: 14 }}>
|
|
<h2 style={{ fontSize: 14, fontWeight: 700, margin: 0 }}>قیمتگذاری ویزیت بر اساس بیمه</h2>
|
|
<p style={{ fontSize: 12, color: 'var(--text-3)', marginTop: 4, lineHeight: 1.7 }}>
|
|
مبلغ ویزیت آزاد و سهم بیمار به ازای هر بیمه را تعیین کنید. خالیگذاشتن یک بیمه یعنی پذیرفته نمیشود.
|
|
</p>
|
|
</div>
|
|
|
|
{isLoading ? (
|
|
<div className="muted" style={{ fontSize: 13 }}>در حال بارگذاری...</div>
|
|
) : (
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 18 }}>
|
|
<div className="field" style={{ flexDirection: 'column', alignItems: 'stretch', height: 'auto', gap: 6, padding: 0, border: 'none', background: 'none' }}>
|
|
<label style={{ fontSize: 12.5, fontWeight: 600 }}>مبلغ ویزیت آزاد (ریال)</label>
|
|
<input
|
|
type="number"
|
|
min={0}
|
|
dir="ltr"
|
|
className="input"
|
|
style={{ maxWidth: 220 }}
|
|
value={freeVisit}
|
|
onChange={(e) => setFreeVisit(e.target.value)}
|
|
/>
|
|
<span style={{ fontSize: 11.5, color: 'var(--text-3)' }}>
|
|
{freeVisit !== '' ? formatRial(Number(freeVisit) || 0) : '—'}
|
|
</span>
|
|
</div>
|
|
|
|
{basics.length > 0 && (
|
|
<div>
|
|
<div style={{ fontSize: 12.5, fontWeight: 600, marginBottom: 8, color: 'var(--text-2)' }}>{TYPE_LABEL.basic}</div>
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>{basics.map(renderRow)}</div>
|
|
</div>
|
|
)}
|
|
|
|
{supps.length > 0 && (
|
|
<div>
|
|
<div style={{ fontSize: 12.5, fontWeight: 600, marginBottom: 8, color: 'var(--text-2)' }}>{TYPE_LABEL.supplementary}</div>
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>{supps.map(renderRow)}</div>
|
|
</div>
|
|
)}
|
|
|
|
{basics.length === 0 && supps.length === 0 && (
|
|
<div style={{ fontSize: 12.5, color: 'var(--text-3)' }}>بیمه فعالی در سیستم تعریف نشده است.</div>
|
|
)}
|
|
|
|
<div>
|
|
<button
|
|
className="btn primary sm"
|
|
disabled={saveMut.isPending}
|
|
onClick={() => saveMut.mutate()}
|
|
>
|
|
{saveMut.isPending ? 'در حال ذخیره...' : 'ذخیره قیمتگذاری'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|