import { useEffect, useMemo, useState } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; import { toast } from 'sonner'; import { ChevronRightIcon, PlusIcon, XMarkIcon } from '@heroicons/react/24/outline'; import { api } from '../lib/api'; import type { ApiResponse } from '../lib/api'; import type { PatientRecord, ServiceSection, ServiceItem } from '../types'; import { formatRial } from '../lib/utils'; import SearchableSelect from '../components/ui/SearchableSelect'; const schema = z.object({ visit_price_rials: z.coerce.number().min(0), base_insurance_discount_percent: z.coerce.number().min(0).max(100), supplementary_discount_percent: z.coerce.number().min(0).max(100), insurance_base_id: z.coerce.number().optional(), insurance_supplementary_id: z.coerce.number().optional(), payment_method: z.enum(['cash', 'card', 'insurance', 'online', 'pending']), notes: z.string().optional(), }); type FormData = z.infer; interface Contract { uuid: string; insurance_id: number; insurance_name: string | null; insurance_kind: string | null; coverage_percent: number; franchise_rials: number; annual_ceiling_rials: number | null } interface CoverageRow { service_item_uuid: string | null; covered: boolean; coverage_percent: number | null; franchise_rials: number | null; ceiling_rials: number | null } const PAYMENT_LABELS: Record = { cash: 'نقدی', card: 'کارت', insurance: 'بیمه', online: 'آنلاین', pending: 'در انتظار', }; interface Rule { covered: boolean; percent: number; franchise: number; ceiling: number | null } // (calcFinal قدیمی حذف شد — حالا سهم بیمار خدمات با patientShareOf و قاعده‌ی هر بیمه‌گر محاسبه می‌شود) // آینه‌ی BillingCalculator سمت سرور: سهم بیمار یک خدمت با پوشش پایه/مکمل. function patientShareOf(total: number, base: Rule | null, supp: Rule | null): number { let baseShare = 0; let remaining = total; if (base && base.covered) { baseShare = Math.round(total * (base.percent / 100)); if (base.ceiling !== null) baseShare = Math.min(baseShare, base.ceiling); remaining = total - baseShare; } let suppShare = 0; if (supp && supp.covered) { suppShare = Math.round(remaining * (supp.percent / 100)); if (supp.ceiling !== null) suppShare = Math.min(suppShare, supp.ceiling); remaining = remaining - suppShare; } const franchise = (base?.franchise ?? 0) + (supp?.franchise ?? 0); return Math.min(remaining + franchise, total); } const sectionTitle: React.CSSProperties = { fontWeight: 700, fontSize: 13.5, color: 'var(--text-2)', margin: '0 0 12px' }; const fieldLabel: React.CSSProperties = { fontSize: 12.5, fontWeight: 600, marginBottom: 5, display: 'block' }; export default function NewSessionPage() { const { recordUuid = '' } = useParams(); const nav = useNavigate(); const qc = useQueryClient(); const [selectedServices, setSelectedServices] = useState<{ uuid: string; name: string; price: number; qty: number; insured: boolean }[]>([]); const [sectionUuid, setSectionUuid] = useState(''); const [itemUuid, setItemUuid] = useState(''); const [baseId, setBaseId] = useState(''); const [suppId, setSuppId] = useState(''); const form = useForm({ resolver: zodResolver(schema), defaultValues: { visit_price_rials: 0, base_insurance_discount_percent: 0, supplementary_discount_percent: 0, payment_method: 'cash' }, }); const recordQ = useQuery>({ queryKey: ['patient-detail', recordUuid], queryFn: () => api.get(`/api/v1/patient/${recordUuid}`), enabled: !!recordUuid, }); const record = (recordQ.data?.data as PatientRecord | undefined) ?? undefined; const patientName = record?.user_name || record?.profile?.full_name || '—'; const { data: sectionsData } = useQuery>({ queryKey: ['service-sections'], queryFn: () => api.get('/api/v1/service-sections'), }); const { data: itemsData } = useQuery>({ queryKey: ['service-items-for-session', sectionUuid], queryFn: () => api.get(`/api/v1/service-items/${sectionUuid}`), enabled: !!sectionUuid, }); const { data: contractsData } = useQuery<{ data: { data: Contract[] } }>({ queryKey: ['tenant-insurances'], queryFn: () => api.get('/api/v1/billing/tenant-insurances'), }); const { data: pricingData } = useQuery<{ data: { free_visit_price_rials: number } }>({ queryKey: ['insurance-pricing'], queryFn: () => api.get('/api/v1/insurance-pricing'), }); const freeVisit = (pricingData as any)?.data?.free_visit_price_rials ?? 0; useEffect(() => { if (freeVisit > 0 && !form.getValues('visit_price_rials')) { form.setValue('visit_price_rials', freeVisit); } }, [freeVisit]); const contracts = (contractsData as any)?.data?.data as Contract[] | undefined ?? []; const baseOpts = contracts.filter(c => c.insurance_kind === 'basic').map(c => ({ value: String(c.insurance_id), label: c.insurance_name ?? `#${c.insurance_id}` })); const suppOpts = contracts.filter(c => c.insurance_kind === 'supplementary').map(c => ({ value: String(c.insurance_id), label: c.insurance_name ?? `#${c.insurance_id}` })); const sectionOptions = (sectionsData?.data ?? []).map(s => ({ value: s.uuid, label: s.name })); const itemOptions = (itemsData?.data ?? []).filter(i => i.active).map(i => ({ value: i.uuid, label: `${i.name} — ${formatRial(i.price_rials)}` })); const baseContract = contracts.find(c => String(c.insurance_id) === baseId) ?? null; const suppContract = contracts.find(c => String(c.insurance_id) === suppId) ?? null; const baseCoverageQ = useQuery<{ data: { data: CoverageRow[] } }>({ queryKey: ['service-coverage', baseContract?.uuid], queryFn: () => api.get(`/api/v1/billing/tenant-insurances/${baseContract!.uuid}/service-coverage`), enabled: !!baseContract, }); const suppCoverageQ = useQuery<{ data: { data: CoverageRow[] } }>({ queryKey: ['service-coverage', suppContract?.uuid], queryFn: () => api.get(`/api/v1/billing/tenant-insurances/${suppContract!.uuid}/service-coverage`), enabled: !!suppContract, }); const baseCoverage = (baseCoverageQ.data as any)?.data?.data as CoverageRow[] | undefined ?? []; const suppCoverage = (suppCoverageQ.data as any)?.data?.data as CoverageRow[] | undefined ?? []; // قاعده‌ی پوشش یک خدمت تحت یک قرارداد: override خدمت اگر باشد، وگرنه پیش‌فرض قرارداد. const ruleFor = (contract: Contract | null, coverage: CoverageRow[], serviceUuid: string): Rule | null => { if (!contract) return null; const ov = coverage.find(r => r.service_item_uuid === serviceUuid); if (ov && !ov.covered) return { covered: false, percent: 0, franchise: 0, ceiling: null }; return { covered: true, percent: ov?.coverage_percent ?? contract.coverage_percent, franchise: ov?.franchise_rials ?? contract.franchise_rials, ceiling: ov?.ceiling_rials ?? contract.annual_ceiling_rials, }; }; const coverageOf = (id: string): number => contracts.find(c => String(c.insurance_id) === id)?.coverage_percent ?? 0; const applyBase = (id: string) => { setBaseId(id); form.setValue('insurance_base_id', id ? Number(id) : undefined); form.setValue('base_insurance_discount_percent', id ? coverageOf(id) : 0); }; const applySupp = (id: string) => { setSuppId(id); form.setValue('insurance_supplementary_id', id ? Number(id) : undefined); form.setValue('supplementary_discount_percent', id ? coverageOf(id) : 0); }; const addService = () => { if (!itemUuid) return; const found = itemsData?.data?.find(i => i.uuid === itemUuid); if (!found || selectedServices.some(s => s.uuid === found.uuid)) return; setSelectedServices(p => [...p, { uuid: found.uuid, name: found.name, price: found.price_rials, qty: 1, insured: !!found.insurance_covered }]); setItemUuid(''); }; const setQty = (uuid: string, qty: number) => setSelectedServices(p => p.map(s => s.uuid === uuid ? { ...s, qty: Math.max(1, qty) } : s)); const visit = Number(form.watch('visit_price_rials')) || 0; const base = Number(form.watch('base_insurance_discount_percent')) || 0; const supp = Number(form.watch('supplementary_discount_percent')) || 0; const servicesTotal = useMemo(() => selectedServices.reduce((s, x) => s + x.price * x.qty, 0), [selectedServices]); const servicesPatient = useMemo( () => selectedServices.reduce((sum, x) => { const total = x.price * x.qty; // پرچم سرویس gate نهایی: اگر «شامل بیمه» نباشد، کامل سهم بیمار است. if (!x.insured) return sum + total; return sum + patientShareOf( total, ruleFor(baseContract, baseCoverage, x.uuid), ruleFor(suppContract, suppCoverage, x.uuid), ); }, 0), [selectedServices, baseContract, suppContract, baseCoverage, suppCoverage], ); const servicesInsured = servicesTotal - servicesPatient; const afterBase = Math.round(visit * (1 - base / 100)); const afterSupp = Math.round(afterBase * (1 - supp / 100)); const finalPrice = Math.round(afterSupp) + servicesPatient; const createMut = useMutation({ mutationFn: (body: object) => api.post(`/api/v1/patient/${recordUuid}/session`, body), onSuccess: () => { qc.invalidateQueries({ queryKey: ['patient-sessions', recordUuid] }); toast.success('مراجعه ثبت شد'); nav(-1); }, onError: (e: Error) => toast.error(e.message), }); const submit = form.handleSubmit((d) => { createMut.mutate({ ...d, services: selectedServices.map(s => ({ service_item_uuid: s.uuid, quantity: s.qty })) }); }); const summaryRow = (label: string, val: number, strong = false) => (
{label} {formatRial(val)}
); return (

ثبت مراجعه جدید

بیمار: {patientName}

بیمه و مبلغ ویزیت

applyBase(v ? String(v) : '')} placeholder="بدون بیمه پایه" isClearable />
applySupp(v ? String(v) : '')} placeholder="بدون بیمه تکمیلی" isClearable />

خدمات

{ setSectionUuid(v ? String(v) : ''); setItemUuid(''); }} placeholder="انتخاب بخش..." />
setItemUuid(v ? String(v) : '')} placeholder="انتخاب سرویس..." isDisabled={!sectionUuid} />
{selectedServices.length > 0 && (
{selectedServices.map(svc => (
{svc.name}
{svc.qty}
{formatRial(svc.price * svc.qty)}
))}
)}

پرداخت و یادداشت

({ value: v, label: l }))} value={form.watch('payment_method')} onChange={v => form.setValue('payment_method', (v as FormData['payment_method']) || 'cash')} placeholder="روش پرداخت" />