Files
clinicpro/assets/admin/components/ServiceInsuranceModal.tsx
T
hamed 27840da78d feat: integrate insurance coverage management for clinic services
- Updated NewSessionPage to calculate patient share based on insurance coverage rules.
- Refactored billing calculations to utilize new patientShareOf function for service items.
- Enhanced API documentation to reflect changes in service coverage structure.
- Implemented ServiceInsuranceModal for managing insurance coverage per service.
- Added UI components for displaying and editing insurance coverage details.
- Removed obsolete toggle switch styles and adjusted CSS for new components.
- Ensured backend endpoints support both service_item_id and service_item_uuid for flexibility.
2026-06-24 04:23:50 +03:30

222 lines
9.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { ShieldCheckIcon, CheckIcon } from '@heroicons/react/24/outline';
import { toast } from 'sonner';
import { api } from '../lib/api';
import Modal from './ui/Modal';
import PriceInput from './ui/PriceInput';
import type { ServiceItem } from '../types';
interface TenantInsurance {
uuid: string;
insurance_name: string | null;
insurance_kind: 'basic' | 'supplementary' | null;
coverage_percent: number;
}
interface CoverageRow {
service_item_uuid: string | null;
covered: boolean;
coverage_percent: number | null;
franchise_rials: number | null;
ceiling_rials: number | null;
}
interface Draft {
covered: boolean;
coverage_percent: number | null;
franchise_rials: number | null;
ceiling_rials: number | null;
}
const KIND = {
basic: { label: 'پایه', cls: 'blue' },
supplementary: { label: 'تکمیلی', cls: 'violet' },
} as const;
function ContractCard({ contract, item }: { contract: TenantInsurance; item: ServiceItem }) {
const qc = useQueryClient();
const { data, isLoading } = useQuery<{ data: { data: CoverageRow[] } }>({
queryKey: ['service-coverage', contract.uuid],
queryFn: () => api.get(`/api/v1/billing/tenant-insurances/${contract.uuid}/service-coverage`),
});
const rows = (data as any)?.data?.data as CoverageRow[] | undefined;
const existing = rows?.find((r) => r.service_item_uuid === item.uuid);
const [draft, setDraft] = useState<Draft>({ covered: true, coverage_percent: null, franchise_rials: null, ceiling_rials: null });
useEffect(() => {
setDraft(existing
? {
covered: existing.covered,
coverage_percent: existing.coverage_percent,
franchise_rials: existing.franchise_rials,
ceiling_rials: existing.ceiling_rials,
}
: { covered: true, coverage_percent: null, franchise_rials: null, ceiling_rials: null });
}, [existing]);
const saveMut = useMutation({
mutationFn: () =>
api.put(`/api/v1/billing/tenant-insurances/${contract.uuid}/service-coverage`, {
service_item_uuid: item.uuid,
covered: draft.covered,
coverage_percent: draft.coverage_percent,
franchise_rials: draft.franchise_rials,
ceiling_rials: draft.ceiling_rials,
}),
onSuccess: () => {
toast.success('پوشش بیمه ذخیره شد');
qc.invalidateQueries({ queryKey: ['service-coverage', contract.uuid] });
},
onError: (e: Error) => toast.error(e.message),
});
const kind = contract.insurance_kind ? KIND[contract.insurance_kind] : null;
return (
<div style={{ border: '1px solid var(--border)', borderRadius: 'var(--r)', overflow: 'hidden' }}>
{/* سربرگ کارت */}
<div style={{
display: 'flex', alignItems: 'center', gap: 10, padding: '12px 14px',
background: 'var(--surface-2)', borderBottom: draft.covered ? '1px solid var(--border)' : 'none',
}}>
<div style={{
width: 32, height: 32, borderRadius: 9, flexShrink: 0,
display: 'grid', placeItems: 'center', background: 'var(--primary-subtle)',
}}>
<ShieldCheckIcon style={{ width: 18, color: 'var(--primary)' }} />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<b style={{ fontSize: 13.5, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{contract.insurance_name ?? 'بیمه'}
</b>
{kind && <span className={`badge ${kind.cls}`} style={{ fontSize: 10 }}>{kind.label}</span>}
{existing && (
<span className="badge green" style={{ fontSize: 10 }}>
<CheckIcon style={{ width: 10 }} /> تنظیم‌شده
</span>
)}
</div>
<div style={{ fontSize: 11.5, color: 'var(--text-3)', marginTop: 2 }}>
پوشش پیش‌فرض قرارداد: {contract.coverage_percent}٪
</div>
</div>
<label className="switch">
<input
type="checkbox"
checked={draft.covered}
disabled={isLoading}
onChange={(e) => setDraft((d) => ({ ...d, covered: e.target.checked }))}
/>
<span className="switch-track"><span className="switch-thumb" /></span>
</label>
</div>
{/* بدنه — فقط وقتی پوشش فعال است */}
{draft.covered && (
<div style={{ padding: 14 }}>
{isLoading ? (
<div className="muted" style={{ fontSize: 12.5 }}>در حال بارگذاری...</div>
) : (
<>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 10 }}>
<div style={{ minWidth: 0 }}>
<label className="field-label">درصد پوشش</label>
<input
type="number" min={0} max={100} dir="ltr" className="input"
style={{ height: 40, textAlign: 'left' }}
value={draft.coverage_percent ?? ''}
placeholder="ارث"
onChange={(e) => setDraft((d) => ({ ...d, coverage_percent: e.target.value === '' ? null : Number(e.target.value) }))}
/>
</div>
<div style={{ minWidth: 0 }}>
<label className="field-label">فرانشیز</label>
<PriceInput
className="input"
style={{ height: 40 }}
value={draft.franchise_rials ?? 0}
onChange={(v) => setDraft((d) => ({ ...d, franchise_rials: v || null }))}
placeholder="۰"
min={0}
/>
</div>
<div style={{ minWidth: 0 }}>
<label className="field-label">سقف پوشش</label>
<PriceInput
className="input"
style={{ height: 40 }}
value={draft.ceiling_rials ?? 0}
onChange={(v) => setDraft((d) => ({ ...d, ceiling_rials: v || null }))}
placeholder="بدون سقف"
min={0}
/>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10, marginTop: 12 }}>
<span style={{ fontSize: 11, color: 'var(--text-3)' }}>مقدار خالی از قرارداد بیمه ارث می‌برد</span>
<button className="btn primary sm" disabled={saveMut.isPending} onClick={() => saveMut.mutate()}>
{saveMut.isPending ? '...' : 'ذخیره'}
</button>
</div>
</>
)}
</div>
)}
{/* وقتی پوشش غیرفعال است — یک خط ذخیره */}
{!draft.covered && !isLoading && (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10, padding: '12px 14px' }}>
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>این خدمت تحت این بیمه پوشش ندارد</span>
<button className="btn sm" disabled={saveMut.isPending} onClick={() => saveMut.mutate()}>
{saveMut.isPending ? '...' : 'ذخیره'}
</button>
</div>
)}
</div>
);
}
export default function ServiceInsuranceModal({ item, onClose }: { item: ServiceItem | null; onClose: () => void }) {
const { data, isLoading } = useQuery<{ data: { data: TenantInsurance[] } }>({
queryKey: ['tenant-insurances'],
queryFn: () => api.get('/api/v1/billing/tenant-insurances'),
enabled: !!item,
});
const contracts = ((data as any)?.data?.data as TenantInsurance[] | undefined) ?? [];
return (
<Modal open={!!item} onClose={onClose} title={`پوشش بیمه — ${item?.name ?? ''}`} size="md">
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div style={{
display: 'flex', gap: 8, padding: '11px 13px', borderRadius: 'var(--r-sm)',
background: 'var(--primary-subtle)', fontSize: 12, color: 'var(--text-2)', lineHeight: 1.7,
}}>
<ShieldCheckIcon style={{ width: 16, flexShrink: 0, marginTop: 1, color: 'var(--primary)' }} />
<span>درصد پوشش، فرانشیز و سقف هر بیمه‌گر برای این خدمت تعیین می‌شود. این تنظیمات مبنای محاسبه‌ی سهم بیمار و ساخت مطالبات بیمه است.</span>
</div>
{isLoading ? (
<div className="muted" style={{ fontSize: 13, padding: '8px 0' }}>در حال بارگذاری...</div>
) : contracts.length === 0 ? (
<div style={{
border: '2px dashed var(--border)', borderRadius: 'var(--r)',
padding: '36px 16px', textAlign: 'center',
}}>
<ShieldCheckIcon style={{ width: 34, margin: '0 auto 10px', display: 'block', opacity: 0.35 }} />
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-2)' }}>قرارداد بیمه‌ی فعالی ندارید</div>
<div style={{ fontSize: 12, color: 'var(--text-3)', marginTop: 4 }}>ابتدا از بخش بیمه‌ها یک بیمه را فعال کنید.</div>
</div>
) : (
item && contracts.map((c) => <ContractCard key={c.uuid} contract={c} item={item} />)
)}
</div>
</Modal>
);
}