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>
This commit is contained in:
@@ -0,0 +1,94 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '../lib/api';
|
||||
import { formatRial, formatNumber } from '../lib/utils';
|
||||
import Modal from './ui/Modal';
|
||||
import type { ServiceItem } from '../types';
|
||||
|
||||
interface TariffRow {
|
||||
uuid: string;
|
||||
year: number;
|
||||
price_rials: number;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
interface TariffResponse {
|
||||
current_year: number;
|
||||
default_price_rials: number;
|
||||
data: TariffRow[];
|
||||
}
|
||||
|
||||
export default function ServiceTariffModal({ item, onClose }: { item: ServiceItem | null; onClose: () => void }) {
|
||||
const qc = useQueryClient();
|
||||
const [year, setYear] = useState('');
|
||||
const [price, setPrice] = useState('');
|
||||
|
||||
const { data, isLoading } = useQuery<{ data: TariffResponse }>({
|
||||
queryKey: ['service-tariffs', item?.uuid],
|
||||
queryFn: () => api.get(`/api/v1/service-items/${item!.uuid}/tariffs`),
|
||||
enabled: !!item,
|
||||
});
|
||||
|
||||
const resp = (data as any)?.data as TariffResponse | undefined;
|
||||
const tariffs = resp?.data ?? [];
|
||||
const currentYear = resp?.current_year;
|
||||
|
||||
const saveMut = useMutation({
|
||||
mutationFn: () =>
|
||||
api.put(`/api/v1/service-items/${item!.uuid}/tariffs/${Number(year)}`, {
|
||||
price_rials: Number(price) || 0,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast.success('تعرفه ذخیره شد');
|
||||
setYear('');
|
||||
setPrice('');
|
||||
qc.invalidateQueries({ queryKey: ['service-tariffs', item?.uuid] });
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
|
||||
return (
|
||||
<Modal open={!!item} onClose={onClose} title={`تعرفههای سالانه — ${item?.name ?? ''}`}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
{resp && (
|
||||
<div style={{ fontSize: 12.5, color: 'var(--text-3)', lineHeight: 1.7 }}>
|
||||
قیمت پیشفرض خدمت: <b>{formatRial(resp.default_price_rials)}</b>. اگر تعرفهی سالی ثبت نشود، همین قیمت اعمال میشود.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 10, alignItems: 'flex-end', padding: 12, borderRadius: 10, border: '1px solid var(--border)', background: 'var(--surface)' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<label style={{ fontSize: 11.5, fontWeight: 600 }}>سال (شمسی)</label>
|
||||
<input type="number" dir="ltr" className="input" style={{ width: 100 }} placeholder={currentYear ? String(currentYear) : '۱۴۰۴'} value={year} onChange={(e) => setYear(e.target.value)} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<label style={{ fontSize: 11.5, fontWeight: 600 }}>تعرفه (ریال)</label>
|
||||
<input type="number" min={0} dir="ltr" className="input" style={{ width: 150 }} value={price} onChange={(e) => setPrice(e.target.value)} />
|
||||
</div>
|
||||
<button className="btn primary sm" disabled={!year || saveMut.isPending} onClick={() => saveMut.mutate()}>
|
||||
{saveMut.isPending ? '...' : 'ذخیره'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="muted" style={{ fontSize: 13 }}>در حال بارگذاری...</div>
|
||||
) : tariffs.length === 0 ? (
|
||||
<div style={{ fontSize: 12.5, color: 'var(--text-3)' }}>هنوز تعرفهی سالانهای ثبت نشده است.</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{tariffs.map((t) => (
|
||||
<div key={t.uuid} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '8px 12px', borderRadius: 8, border: '1px solid var(--border)' }}>
|
||||
<span style={{ fontWeight: 600, fontSize: 13 }}>
|
||||
سال {formatNumber(t.year)}
|
||||
{t.year === currentYear && <span className="badge green" style={{ fontSize: 10, marginInlineStart: 6 }}><span className="bdot" />جاری</span>}
|
||||
</span>
|
||||
<span style={{ color: 'var(--primary)', fontWeight: 600, fontSize: 13 }}>{formatRial(t.price_rials)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user