Files
clinicpro/assets/admin/components/InsurancePricingSection.tsx
T
hamedandClaude Opus 4.8 89191eee57 feat: insurance & medical billing system (6 phases)
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>
2026-06-23 15:05:24 +03:30

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>
);
}