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_percent > 0) { parts.push(`فرانشیز ${formatNumber(c.franchise_percent)}٪`); } 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('basic'); const [modalOpen, setModalOpen] = useState(false); const [editContract, setEditContract] = useState(null); const [search, setSearch] = useState(''); const [expanded, setExpanded] = useState(null); const [pickedDoctorUuid, setPickedDoctorUuid] = useState(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>(`/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 ?? []; // فرم قرارداد فقط نوع خدمت‌هایی را می‌گیرد که همین tenant بیمه‌ای‌شان می‌کند؛ // لیست سراسری service-categories اینجا اشتباه است چون نوع خاموش را هم می‌آورد. const enabledCategories: ServiceCategoryOption[] = useMemo( () => ((pricingQuery.data as any)?.data?.service_categories ?? []) .filter((c: { enabled: boolean }) => c.enabled) .map((c: { key: string; label: string }) => ({ key: c.key, label: c.label })), [pricingQuery.data], ); 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 = {}; 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) => 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 (

مدیریت بیمه

{canCreate && ( )}
{showDoctorPicker && (
({ value: d.uuid, label: d.name }))} value={doctorUuid ?? ''} onChange={(v) => { setPickedDoctorUuid(v ? String(v) : null); setExpanded(null); }} placeholder="انتخاب پزشک..." /> تنظیمات بیمه برای هر پزشک جداگانه ذخیره می‌شود.
)}
{KINDS.map((k) => { const active = k.key === tab; return ( ); })}
setSearch(e.target.value)} />
{contractsQuery.isLoading ? (
در حال بارگذاری...
) : rows.length === 0 ? (
{search ? 'بیمه‌ای یافت نشد.' : activeKind.emptyLabel}
) : (
{rows.map((c) => ( toggleRow(c.uuid)} onEdit={() => openEdit(c)} onToggleStatus={() => toggleMut.mutate(c)} statusPending={toggleMut.isPending} canUpdate={canUpdate} /> ))}
)} saveMut.mutate(payload)} isPending={saveMut.isPending} />
); } 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 (
{c.insurance_name ?? `#${c.insurance_id}`}
{canUpdate && ( )}
{contractSummary(c, categories)}
{canUpdate && (
{})}>
)} {open &&
}
); } /** 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 (
{perCategory.length === 0 && ( )} {perCategory.map((cat) => ( {formatNumber(percents[cat.key])}٪ {c.category_coverage_source?.[cat.key] === SOURCE_ADMIN_DEFAULT && ( پیش‌فرض ادمین )} } /> ))} {isSupplementary && ( 0 ? `${formatNumber(c.franchise_percent)}٪` : '—'} /> )} {formatNumber(c.version)}} /> {c.insurance_id}} /> {c.is_active ? 'فعال' : 'غیرفعال'} } />
); } function DetailCell({ label, value }: { label: string; value: ReactNode }) { return (
{label} {value}
); } function StatusToggle({ contract, onToggle, disabled }: { contract: Contract; onToggle: (e: React.MouseEvent) => void; disabled?: boolean }) { return ( ); }