Files
clinicpro/assets/admin/components/TenantInsuranceContracts.tsx
T
hamed 5507b42fd8 feat: implement per-doctor insurance settings in multi-doctor clinics
- Updated InsuranceModal to include doctorUuid in the payload for insurance contracts.
- Enhanced TenantInsuranceContracts to allow selection of doctors and pass doctorUuid in API requests.
- Modified InsuranceController to handle doctorUuid for tenant insurance endpoints, ensuring contracts are stored per doctor.
- Updated API documentation to reflect the new optional doctor_uuid parameter for tenant insurance endpoints.
- Added tests to verify the functionality of per-doctor insurance contracts and ensure isolation of contracts between doctors.
2026-07-21 19:21:55 +03:30

340 lines
16 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 SearchableSelect from './ui/SearchableSelect';
import type { ClinicDoctorItem } from './ClinicDoctorsManager';
import InsuranceModal, { Contract, InsuranceOption, KIND_LABEL, 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): string {
const parts = [`پوشش ${formatNumber(c.coverage_percent)}٪`];
if (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();
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 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>
<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}
open={expanded === c.uuid}
onToggleRow={() => toggleRow(c.uuid)}
onEdit={() => openEdit(c)}
onToggleStatus={() => toggleMut.mutate(c)}
statusPending={toggleMut.isPending}
/>
))}
</div>
)}
<InsuranceModal
open={modalOpen}
editContract={editContract}
options={editContract ? allInsurances : available}
kind={editContract ? kindOf(editContract) : tab}
doctorUuid={doctorUuid}
onClose={closeModal}
onSubmit={(payload) => saveMut.mutate(payload)}
isPending={saveMut.isPending}
/>
</div>
);
}
interface RowProps {
contract: Contract;
open: boolean;
onToggleRow: () => void;
onEdit: () => void;
onToggleStatus: () => void;
statusPending?: boolean;
}
function ContractCard({ contract: c, open, onToggleRow, onEdit, onToggleStatus, statusPending }: 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>
<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)}</div>
<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} /></div>}
</div>
);
}
/** Expanded full detail of a contract (all fields), shown when its card is open. */
function ContractDetails({ contract: c }: { contract: Contract }) {
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,
}}>
<DetailCell label="درصد پوشش" value={`${formatNumber(c.coverage_percent)}٪`} />
<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>
);
}