Files
clinicpro/assets/admin/components/TenantInsuranceContracts.tsx
T
hamedandClaude Opus 5 58c6d9ac18 feat(insurance): resolve coverage percent per service category
Base insurance is a percentage-only rule: patient share is now total minus the
base share, and the contract franchise no longer inflates it (franchise stays
meaningful for supplementary contracts only).

Coverage percentages are managed centrally by admin per service category
(outpatient/inpatient, extensible via the ServiceCategory enum). A tenant
contract may override a category, otherwise it follows the admin default live —
changing the central value immediately applies to every contract that did not
override it.

- add ServiceCategory enum + GET /api/v1/service-categories as the single source
  of the category list for every client
- add insurance_coverage_defaults (+ GET/PUT admin coverage-defaults endpoints)
  and expose coverage_defaults on the insurance list and insurance-pricing
- add tenant_insurance_category_coverage; tenant-insurances accepts optional
  category_coverages (needs insurances.update) and returns the effective
  percentages with their source
- add service_items.service_category; visits always resolve as outpatient
- drop the reverse-engineered percent from patient_share_rials in MyPatientsPage
  and align the client-side BillingCalculator mirror in CreateStep

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 16:21:19 +03:30

389 lines
18 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 type { ApiResponse } from '../lib/api';
import { formatRial, formatNumber, formatDate } from '../lib/utils';
import { useAuthStore } from '../stores/authStore';
import { usePermissions } from '../hooks/usePermissions';
import { useServiceCategories, type ServiceCategoryOption } from '../hooks/useServiceCategories';
import SearchableSelect from './ui/SearchableSelect';
import type { ClinicDoctorItem } from './ClinicDoctorsManager';
import InsuranceModal, { Contract, InsuranceOption, KIND_LABEL, SOURCE_ADMIN_DEFAULT, 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, categories: ServiceCategoryOption[] = []): string {
const percents = c.category_coverages ?? {};
const labelled = categories.length > 0
? categories.filter((cat) => cat.key in percents).map((cat) => `${cat.label} ${formatNumber(percents[cat.key])}٪`)
: Object.entries(percents).map(([key, percent]) => `${key} ${formatNumber(percent)}٪`);
const parts = labelled.length > 0 ? labelled : [`پوشش ${formatNumber(c.coverage_percent)}٪`];
if (c.insurance_kind === 'supplementary' && 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 { dbUuid, context, availableContexts } = useAuthStore();
// مجوزهای منشی؛ برای owner/پزشک همیشه true (usePermissions بدون context آزاد است).
const { can } = usePermissions();
const canCreate = can('insurances', 'create');
const canUpdate = can('insurances', 'update');
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 [pickedDoctorUuid, setPickedDoctorUuid] = useState<string | null>(null);
// A user who is both a doctor and a clinic owner may have a doctor db_uuid active;
// fall back to the clinic context so the roster query targets the clinic. Same
// resolution as ClinicAppointmentSettingsPage.
const clinicUuid = useMemo(() => {
if (context?.type === 'clinic') return dbUuid;
return availableContexts.find((c) => c.type === 'clinic')?.db_uuid ?? null;
}, [context, dbUuid, availableContexts]);
const doctorsQuery = useQuery({
queryKey: ['clinic-doctors', clinicUuid],
queryFn: () => api.get<ApiResponse<{ data: ClinicDoctorItem[] }>>(`/api/v1/clinic/doctor-list/${clinicUuid}`),
enabled: !!clinicUuid,
});
const doctorList: ClinicDoctorItem[] = useMemo(() => {
const raw = doctorsQuery.data?.data;
return (raw as any)?.data ?? raw ?? [];
}, [doctorsQuery.data]);
// In a clinic, insurance is per-doctor: default to the first doctor. Solo doctors /
// personal offices have no clinic context → doctorUuid stays null → backend keeps the
// legacy tenant-scoped behavior.
const isClinic = !!clinicUuid;
const doctorUuid = useMemo(
() => (isClinic ? pickedDoctorUuid ?? doctorList[0]?.uuid ?? null : null),
[isClinic, pickedDoctorUuid, doctorList],
);
const showDoctorPicker = isClinic && doctorList.length > 1;
const dq = doctorUuid ? `?doctor_uuid=${encodeURIComponent(doctorUuid)}` : '';
const contractsQuery = useQuery({
queryKey: ['tenant-insurances', doctorUuid],
queryFn: () => api.get(`/api/v1/billing/tenant-insurances${dq}`),
});
const { categories } = useServiceCategories();
const pricingQuery = useQuery({
queryKey: ['insurance-pricing', doctorUuid],
queryFn: () => api.get(`/api/v1/insurance-pricing${dq}`),
});
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', doctorUuid] });
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,
...(doctorUuid ? { doctor_uuid: doctorUuid } : {}),
}),
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>
{canCreate && (
<button className="btn primary sm" onClick={openAdd}>
<PlusIcon style={{ width: 15 }} /> {activeKind.addLabel}
</button>
)}
</div>
{showDoctorPicker && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginBottom: 16, maxWidth: 320 }}>
<label style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-2)' }}>پزشک</label>
<SearchableSelect
options={doctorList.map((d) => ({ value: d.uuid, label: d.name }))}
value={doctorUuid ?? ''}
onChange={(v) => { setPickedDoctorUuid(v ? String(v) : null); setExpanded(null); }}
placeholder="انتخاب پزشک..."
/>
<span style={{ fontSize: 11, color: 'var(--text-3)' }}>
تنظیمات بیمه برای هر پزشک جداگانه ذخیره می‌شود.
</span>
</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}
categories={categories}
open={expanded === c.uuid}
onToggleRow={() => toggleRow(c.uuid)}
onEdit={() => openEdit(c)}
onToggleStatus={() => toggleMut.mutate(c)}
statusPending={toggleMut.isPending}
canUpdate={canUpdate}
/>
))}
</div>
)}
<InsuranceModal
open={modalOpen}
editContract={editContract}
options={editContract ? allInsurances : available}
categories={categories}
kind={editContract ? kindOf(editContract) : tab}
doctorUuid={doctorUuid}
canUpdate={canUpdate}
onClose={closeModal}
onSubmit={(payload) => saveMut.mutate(payload)}
isPending={saveMut.isPending}
/>
</div>
);
}
interface RowProps {
contract: Contract;
categories: ServiceCategoryOption[];
open: boolean;
onToggleRow: () => void;
onEdit: () => void;
onToggleStatus: () => void;
statusPending?: boolean;
canUpdate?: boolean;
}
function ContractCard({ contract: c, categories, open, onToggleRow, onEdit, onToggleStatus, statusPending, canUpdate }: 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>
{canUpdate && (
<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, categories)}</div>
{canUpdate && (
<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} categories={categories} /></div>}
</div>
);
}
/** Expanded full detail of a contract (all fields), shown when its card is open. */
function ContractDetails({ contract: c, categories }: { contract: Contract; categories: ServiceCategoryOption[] }) {
const percents = c.category_coverages ?? {};
const isSupplementary = c.insurance_kind === 'supplementary';
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,
}}>
{categories.filter((cat) => cat.key in percents).map((cat) => (
<DetailCell
key={cat.key}
label={`درصد پوشش — ${cat.label}`}
value={
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
{formatNumber(percents[cat.key])}٪
{c.category_coverage_source?.[cat.key] === SOURCE_ADMIN_DEFAULT && (
<span style={{ fontSize: 10.5, fontWeight: 600, color: 'var(--text-3)' }}>پیش‌فرض ادمین</span>
)}
</span>
}
/>
))}
{isSupplementary && (
<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>
);
}