An appointment can now carry the insurance it is billed with: the service kind (outpatient/inpatient) and the basic insurance. Confirming it no longer hands the whole amount to the patient — the visit is split through BillingCalculator with the coverage percent of that service kind, and the choice travels to the encounter and the invoice built from it. The enabled service kinds are a tenant-wide setting (all of that tenant's insurances share it), so a tenant covering only one kind is never asked which one: the panel resolves it the same way the server does. - add tenant_service_category_settings + TenantServiceCategoryService, exposed on the existing insurance-pricing endpoint (service_categories, default_service_category); at least one kind must stay enabled - add appointments.insurance_service_category / insurance_base_id with AppointmentInsuranceService validating them against the tenant's own settings and active contracts (basic only), accepted by PATCH and by confirm - snapshot the kind on patient_sessions and invoices; the visit's coverage rule is resolved per kind (services keep using their own ServiceItem.service_category) - lib/insuranceShares becomes the single client-side mirror of BillingCalculator, shared by the confirm modal, the appointment edit page and the session form - surface the selection: confirm modal (with live shares), turns timeline chip, appointment edit page, patient record service card and invoice summary - the session form shows the insurance block whenever the tenant has an active contract and prefills the patient's own insurance, so it can be changed - fix: the confirm modal showed a zero visit price when the appointment had none — it now falls back to the tenant's free-visit price like the server - fix: useServiceCategories read one level too shallow, so Persian labels never arrived and raw enum keys leaked into the contract summary - fix: BlogsPage test asserted the public blogs endpoint after the page moved to the admin one Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
395 lines
18 KiB
TypeScript
395 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 ?? {};
|
|
// برچسبها فقط از سرور میآیند؛ تا نیامدهاند خطِ قدیمیِ «پوشش X٪» نشان داده میشود
|
|
// (کلید انگلیسیِ نوع خدمت هرگز به UI نمیرسد).
|
|
const labelled = categories
|
|
.filter((cat) => cat.key in percents)
|
|
.map((cat) => `${cat.label} ${formatNumber(percents[cat.key])}٪`);
|
|
|
|
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';
|
|
const perCategory = categories.filter((cat) => cat.key in percents);
|
|
|
|
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,
|
|
}}>
|
|
{perCategory.length === 0 && (
|
|
<DetailCell label="درصد پوشش" value={`${formatNumber(c.coverage_percent)}٪`} />
|
|
)}
|
|
{perCategory.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>
|
|
);
|
|
}
|