import { useEffect, useMemo, useState } from 'react'; import { useQuery, useMutation } from '@tanstack/react-query'; import { toast } from 'sonner'; import { PlusIcon, MinusIcon } from '@heroicons/react/24/outline'; import { api } from '../../lib/api'; import type { ApiResponse } from '../../lib/api'; import type { PatientProfile, ServiceSection, ServiceItem } from '../../types'; import { formatRial, rialToToman, tomanToRial } from '../../lib/utils'; import SearchableSelect from '../ui/SearchableSelect'; import PersianDateInput from '../ui/PersianDateInput'; import { UserTick, FilesServiceAddCard, ClockP, TrashRed } from '../icons/FilesServiceIcons'; import { useAuthStore } from '../../stores/authStore'; import { digitsOnly } from '../../lib/utils'; import { DEFAULT_SERVICE_CATEGORY, contractPercentFor, patientShareOf, type CoverageRule as Rule, type TenantContract, } from '../../lib/insuranceShares'; interface Contract extends TenantContract { uuid: string } interface CoverageRow { service_item_uuid: string | null; covered: boolean; coverage_percent: number | null; franchise_rials: number | null; ceiling_rials: number | null } interface InventoryItemRow { uuid: string; name: string; unit: string; price: number; stock: number; status: string } interface PackageRow { uuid: string; title: string; total: number; available: boolean } interface StaffRow { uuid: string; full_name: string; active?: boolean } /** ویزیت خدمتِ سرپایی است. */ const VISIT_SERVICE_CATEGORY = DEFAULT_SERVICE_CATEGORY; // آینه‌ی BillingCalculator در lib/insuranceShares است؛ اینجا فقط re-export می‌شود تا // مصرف‌کنندگان قبلی (و تست‌ها) نشکنند. export { patientShareOf }; const todayISO = () => new Date().toISOString().slice(0, 10); const nowHHMM = () => { const d = new Date(); return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; }; /** تاریخ میلادی + ساعت → unix زمان پذیرش */ const toSessionAt = (iso: string, time: string) => Math.floor(new Date(`${iso}T${time || '12:00'}:00`).getTime() / 1000); // برچسب فیلد — tauri Step1Details Typography (fontWeight 500, 0.875rem, #6B7280) const fieldLabel: React.CSSProperties = { fontWeight: 500, lineHeight: '130%', fontSize: '0.875rem', marginBottom: 8, color: '#6B7280', display: 'block' }; const primaryBtn: React.CSSProperties = { background: '#5559CE', color: '#fff', border: 'none', borderRadius: 4, height: 46, fontSize: 14, fontWeight: 500, cursor: 'pointer' }; const ghostBtn: React.CSSProperties = { background: 'transparent', color: '#5559CE', border: '1px solid #5559CE', borderRadius: 4, height: 46, fontSize: 14, fontWeight: 500, cursor: 'pointer' }; interface Props { recordUuid: string; profile?: PatientProfile | null; onCreated: (sessionUuid: string) => void; onCancel: () => void; /** وقتی داده شود فرم در حالت ویرایش است و با PATCH به همان مراجعه ارسال می‌کند. */ editSession?: import('../SessionServiceCard').SessionCardData; } /** * گام «ایجاد سرویس» — پورت tauri Step1Details با دیتای واقعی: * تاریخ/ساعت پذیرش، بخش/سرویس/پرسنل، کالای مصرفی با شمارنده، پکیج، و بلوک بیمه‌ی * موجودِ NewSessionPage (نمایش شرطی: سرویسِ تحت پوشش بیمه یا بیمه در پروفایل بیمار). */ export default function CreateStep({ recordUuid, profile, onCreated, onCancel, editSession }: Props) { const userName = useAuthStore((s) => s.userName); const isEdit = !!editSession; // ── state گام ایجاد ────────────────────────────────────────────────────── const [dateISO, setDateISO] = useState(todayISO()); const [time, setTime] = useState(nowHHMM()); const [sectionUuid, setSectionUuid] = useState(''); const [itemUuid, setItemUuid] = useState(''); const [staffUuid, setStaffUuid] = useState(''); const [selectedServices, setSelectedServices] = useState<{ uuid: string; name: string; price: number; qty: number; insured: boolean; category: string }[]>([]); const [consumableUuid, setConsumableUuid] = useState(''); const [selectedConsumables, setSelectedConsumables] = useState<{ uuid: string; name: string; price: number; qty: number }[]>([]); const [packageUuid, setPackageUuid] = useState(''); const [visitPrice, setVisitPrice] = useState('0'); const [visitPriceError, setVisitPriceError] = useState(''); const [baseId, setBaseId] = useState(''); const [suppId, setSuppId] = useState(''); const [basePercent, setBasePercent] = useState('0'); const [suppPercent, setSuppPercent] = useState('0'); /** نوع خدمتِ بیمه‌ایِ این مراجعه؛ خالی یعنی «پیش‌فرضِ tenant». */ const [serviceCategory, setServiceCategory] = useState(''); const [notes, setNotes] = useState(''); // ── داده‌ها ────────────────────────────────────────────────────────────── const { data: sectionsData } = useQuery>({ queryKey: ['service-sections'], queryFn: () => api.get('/api/v1/service-sections'), }); // همه‌ی سرویس‌ها (سراسری) تا بتوان بدون انتخاب بخش هم جستجو و انتخاب کرد. const { data: allItemsData } = useQuery>({ queryKey: ['service-items-all'], queryFn: () => api.get('/api/v1/service-items'), }); const { data: itemsData } = useQuery>({ queryKey: ['service-items-for-session', sectionUuid], queryFn: () => api.get(`/api/v1/service-items/${sectionUuid}`), enabled: !!sectionUuid, }); const { data: staffData } = useQuery>({ queryKey: ['staff'], queryFn: () => api.get('/api/v1/staff'), }); const { data: inventoryData } = useQuery>({ queryKey: ['inventory-items'], queryFn: () => api.get('/api/v1/inventory-items'), }); const { data: packagesData } = useQuery>({ queryKey: ['inventory-packages'], queryFn: () => api.get('/api/v1/inventory-packages'), }); 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; require_visit_price: boolean } }>({ queryKey: ['insurance-pricing'], queryFn: () => api.get('/api/v1/insurance-pricing'), }); const freeVisit = (pricingData as any)?.data?.free_visit_price_rials ?? 0; const requireVisit = (pricingData as any)?.data?.require_visit_price ?? false; // نوع خدماتِ بیمه‌ایِ فعالِ این tenant — همان تنظیم سراسری «مدیریت بیمه». const enabledCategories: { key: string; label: string }[] = ((pricingData as any)?.data?.service_categories ?? []).filter((c: any) => c.enabled); const needsCategoryChoice = enabledCategories.length > 1; const defaultCategory: string = (pricingData as any)?.data?.default_service_category ?? enabledCategories[0]?.key ?? VISIT_SERVICE_CATEGORY; useEffect(() => { if (!isEdit && freeVisit > 0 && (!visitPrice || visitPrice === '0')) setVisitPrice(String(rialToToman(freeVisit))); }, [freeVisit]); // eslint-disable-line react-hooks/exhaustive-deps // پیش‌پرکردن فرم در حالت ویرایش (یک‌بار). const [prefilled, setPrefilled] = useState(false); useEffect(() => { if (!editSession || prefilled) return; setVisitPrice(String(rialToToman(editSession.visit_price_rials ?? 0))); if (editSession.session_at) { const d = new Date(editSession.session_at * 1000); setDateISO(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`); setTime(`${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`); } setNotes((editSession as any).notes ?? ''); if (editSession.insurance_base_id) { setBaseId(String(editSession.insurance_base_id)); setBasePercent(String(editSession.base_insurance_discount_percent ?? 0)); } if (editSession.insurance_supplementary_id) { setSuppId(String(editSession.insurance_supplementary_id)); setSuppPercent(String(editSession.supplementary_discount_percent ?? 0)); } setServiceCategory(editSession.insurance_service_category ?? ''); setSelectedServices((editSession.services ?? []).map((s) => ({ uuid: s.service_item_uuid ?? '', name: s.service_name || s.name || '', price: s.price_rials ?? 0, qty: s.quantity ?? 1, insured: false, category: VISIT_SERVICE_CATEGORY, })).filter((s) => s.uuid)); setSelectedConsumables((editSession.consumables ?? []).map((c) => ({ uuid: c.inventory_item_uuid ?? '', name: c.item_name ?? '', price: c.price_rials ?? 0, qty: c.quantity ?? 1, })).filter((c) => c.uuid)); setPrefilled(true); }, [editSession, prefilled]); 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 serviceItems = (sectionUuid ? (itemsData?.data ?? []) : (allItemsData?.data ?? [])).filter(i => i.active); const itemOptions = serviceItems.map(i => ({ value: i.uuid, label: i.name })); const currentItem = serviceItems.find(i => i.uuid === itemUuid); const staffOptions = ((staffData?.data as StaffRow[] | undefined) ?? []).filter(s => s.active !== false).map(s => ({ value: s.uuid, label: s.full_name })); const inventoryItems = ((inventoryData?.data as any)?.items as InventoryItemRow[] | undefined) ?? []; const consumableOptions = inventoryItems.map(i => ({ value: i.uuid, label: i.name })); const currentConsumable = inventoryItems.find(i => i.uuid === consumableUuid); const packageOptions = ((packagesData?.data as PackageRow[] | undefined) ?? []).map(p => ({ value: p.uuid, label: `${p.title} — ${formatRial(p.total)}` })); const baseContract = contracts.find(c => String(c.insurance_id) === baseId) ?? null; const suppContract = contracts.find(c => String(c.insurance_id) === suppId) ?? null; /** * مراجعهٔ جدید: بیمهٔ پروفایل بیمار پیش‌انتخاب می‌شود — فقط اگر برای همان بیمه * قرارداد فعال وجود داشته باشد. یک‌بار، و بعدش انتخاب کاربر دست‌نخورده می‌ماند. */ const [insurancePrefilled, setInsurancePrefilled] = useState(false); useEffect(() => { if (isEdit || insurancePrefilled || contracts.length === 0) return; const profileBase = profile?.basic_insurance_id ? String(profile.basic_insurance_id) : ''; const profileSupp = profile?.supplementary_insurance_id ? String(profile.supplementary_insurance_id) : ''; if (profileBase && baseOpts.some(o => o.value === profileBase)) applyBase(profileBase); if (profileSupp && suppOpts.some(o => o.value === profileSupp)) applySupp(profileSupp); setInsurancePrefilled(true); }, [contracts.length, profile, isEdit, insurancePrefilled]); // eslint-disable-line react-hooks/exhaustive-deps 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, category: 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 }; const isSupplementary = contract.insurance_kind === 'supplementary'; return { covered: true, percent: ov?.coverage_percent ?? contractPercentFor(contract, category), franchise: isSupplementary ? (ov?.franchise_rials ?? contract.franchise_rials) : 0, ceiling: ov?.ceiling_rials ?? contract.annual_ceiling_rials, }; }; /** نوع خدمتِ مؤثرِ ویزیت: انتخاب کاربر، وگرنه تنها نوع فعالِ tenant. */ const visitCategory = serviceCategory || defaultCategory; const coverageOf = (id: string, category = visitCategory): number => { const contract = contracts.find(c => String(c.insurance_id) === id); return contract ? contractPercentFor(contract, category) : 0; }; const applyBase = (id: string) => { setBaseId(id); setBasePercent(id ? String(coverageOf(id)) : '0'); }; const applySupp = (id: string) => { setSuppId(id); setSuppPercent(id ? String(coverageOf(id)) : '0'); }; /** تغییر نوع خدمت، درصدهای ویزیت را با همان نوع همگام می‌کند. */ const applyServiceCategory = (category: string) => { setServiceCategory(category); if (baseId) setBasePercent(String(coverageOf(baseId, category || defaultCategory))); if (suppId) setSuppPercent(String(coverageOf(suppId, category || defaultCategory))); }; /** * بلوک بیمه هر وقت این پزشک/کلینیک قرارداد بیمهٔ فعال دارد نمایش داده می‌شود تا * بیمه قابل انتخاب و تغییر باشد؛ پیش‌تر تنها با سرویسِ تحت‌پوشش یا بیمهٔ پروفایل * ظاهر می‌شد و کاربر راهی برای انتخاب بیمه نداشت. مراجعه‌ای که بیمه دارد هم * (حالت ویرایش) همیشه بلوک را نشان می‌دهد. */ const showInsurance = contracts.length > 0 || !!baseId || !!suppId || selectedServices.some(s => s.insured) || !!profile?.basic_insurance_id || !!profile?.supplementary_insurance_id; // ── خدمات ──────────────────────────────────────────────────────────────── const addService = () => { if (!currentItem || selectedServices.some(s => s.uuid === currentItem.uuid)) return; setSelectedServices(p => [...p, { uuid: currentItem.uuid, name: currentItem.name, price: currentItem.price_rials, qty: 1, insured: !!currentItem.insurance_covered, category: currentItem.service_category ?? VISIT_SERVICE_CATEGORY, }]); setItemUuid(''); }; const setServiceQty = (uuid: string, qty: number) => { if (qty <= 0) { setSelectedServices(p => p.filter(s => s.uuid !== uuid)); return; } setSelectedServices(p => p.map(s => s.uuid === uuid ? { ...s, qty } : s)); }; // ── کالای مصرفی (tauri addConsumable/increase/decrease) ───────────────── const addConsumable = () => { if (!currentConsumable) return; setSelectedConsumables(p => { const found = p.find(c => c.uuid === currentConsumable.uuid); if (found) return p.map(c => c.uuid === found.uuid ? { ...c, qty: c.qty + 1 } : c); return [...p, { uuid: currentConsumable.uuid, name: currentConsumable.name, price: currentConsumable.price, qty: 1 }]; }); setConsumableUuid(''); }; const setConsumableQty = (uuid: string, qty: number) => { if (qty <= 0) { setSelectedConsumables(p => p.filter(c => c.uuid !== uuid)); return; } setSelectedConsumables(p => p.map(c => c.uuid === uuid ? { ...c, qty } : c)); }; // ── قیمت‌ها (آینه‌ی سرور) ──────────────────────────────────────────────── // فیلد «قیمت ویزیت» تومان است؛ محاسبات و API ریالی‌اند. const visit = tomanToRial(Number(visitPrice) || 0); const base = Number(basePercent) || 0; const supp = Number(suppPercent) || 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; // نوع خدمت و پرچم پوشش از کاتالوگ خوانده می‌شوند تا ردیف‌های پیش‌پرشدهٔ ویرایش // هم مثل سرور حساب شوند (هنگام prefill این دو را نداریم). const catalogItem = serviceItems.find(i => i.uuid === x.uuid); const insured = catalogItem?.insurance_covered ?? x.insured; if (!insured) return sum + total; const category = catalogItem?.service_category ?? x.category; return sum + patientShareOf( total, ruleFor(baseContract, baseCoverage, x.uuid, category), ruleFor(suppContract, suppCoverage, x.uuid, category), ); }, 0), [selectedServices, baseContract, suppContract, baseCoverage, suppCoverage, serviceItems], // eslint-disable-line react-hooks/exhaustive-deps ); const consumablesTotal = useMemo(() => selectedConsumables.reduce((s, c) => s + c.price * c.qty, 0), [selectedConsumables]); const afterBase = Math.round(visit * (1 - base / 100)); const afterSupp = Math.round(afterBase * (1 - supp / 100)); const finalPrice = afterSupp + servicesPatient + consumablesTotal; // ── ثبت ────────────────────────────────────────────────────────────────── const createMut = useMutation({ mutationFn: (body: object) => isEdit ? api.patch(`/api/v1/session/${editSession!.uuid}`, body) : api.post(`/api/v1/patient/${recordUuid}/session`, body), onSuccess: (res: any) => { toast.success(isEdit ? 'مراجعه ویرایش شد' : 'مراجعه ثبت شد'); onCreated((isEdit ? editSession!.uuid : res?.data?.uuid) as string); }, onError: (e: Error) => toast.error(e.message), }); const submit = () => { if (requireVisit && visit <= 0) { setVisitPriceError('هزینه ویزیت الزامی است'); toast.error('هزینه ویزیت الزامی است'); return; } createMut.mutate({ visit_price_rials: visit, base_insurance_discount_percent: showInsurance ? base : 0, supplementary_discount_percent: showInsurance ? supp : 0, ...(showInsurance && baseId ? { insurance_base_id: Number(baseId) } : {}), ...(showInsurance && suppId ? { insurance_supplementary_id: Number(suppId) } : {}), // نوع خدمتِ ویزیت؛ سرور درصد پوشش را بر پایهٔ همین resolve می‌کند. insurance_service_category: showInsurance && (baseId || suppId) ? visitCategory : null, ...(isEdit ? {} : { payment_method: 'pending' }), ...(notes ? { notes } : {}), session_at: toSessionAt(dateISO, time), ...(packageUuid ? { inventory_package_uuid: packageUuid } : {}), services: selectedServices.map(s => ({ service_item_uuid: s.uuid, quantity: s.qty, ...(staffUuid ? { staff_uuid: staffUuid } : {}) })), consumables: selectedConsumables.map(c => ({ inventory_item_uuid: c.uuid, quantity: c.qty })), }); }; /** ردیف شمارنده‌ی tauri (باکس ۷۴px، +/− نارنجی #FF7A45، سطل روی تعداد ۱) */ const counter = (qty: number, set: (q: number) => void) => (
{qty}
); return (
{/* پذیرش کننده — tauri UserTick + کاربر لاگین‌شده */}
پذیرش کننده: {userName || '—'}
{/* تاریخ و ساعت پذیرش */}
تاریخ پذیرش
ساعت پذیرش
setTime(e.target.value)} style={{ border: 'none', outline: 'none', background: 'transparent', fontSize: '0.875rem', color: 'inherit', flex: 1 }} />
{/* انتخاب بخش */}
انتخاب بخش { setSectionUuid(v ? String(v) : ''); setItemUuid(''); }} placeholder="انتخاب کنید..." />
{/* انتخاب سرویس + قیمت + افزودن */}
انتخاب سرویس setItemUuid(v ? String(v) : '')} placeholder="جستجو و انتخاب سرویس..." />
قیمت: {currentItem ? formatRial(currentItem.price_rials) : '—'}
{/* خدمات انتخاب شده */} {selectedServices.length > 0 && (
خدمات انتخاب شده {selectedServices.map(item => (
{item.name} {counter(item.qty, (q) => setServiceQty(item.uuid, q))} قیمت: {formatRial(item.price * item.qty)}
))} مجموع قیمت خدمات: {formatRial(servicesTotal)}
)} {/* پرسنل */}
پرسنل setStaffUuid(v ? String(v) : '')} placeholder="انتخاب کنید..." isClearable />
{/* انتخاب کالای مصرفی */}
انتخاب کالای مصرفی
setConsumableUuid(v ? String(v) : '')} placeholder="انتخاب کنید..." />
قیمت: {currentConsumable ? formatRial(currentConsumable.price) : '—'}
{/* کالاهای انتخاب شده — باکس خط‌چین tauri */}
کالاهای انتخاب شده {selectedConsumables.length === 0 ? ( کالایی انتخاب نشده است ) : selectedConsumables.map(item => (
{item.name} {counter(item.qty, (q) => setConsumableQty(item.uuid, q))} قیمت: {formatRial(item.price * item.qty)}
))} مجموع قیمت کالاها: {formatRial(consumablesTotal)}
{/* انتخاب پکیج */}
انتخاب پکیج setPackageUuid(v ? String(v) : '')} placeholder="انتخاب کنید..." isClearable />
{/* قیمت ویزیت */}
قیمت ویزیت (تومان){requireVisit && *} { setVisitPrice(digitsOnly(e.target.value)); setVisitPriceError(''); }} /> {visitPriceError && ( {visitPriceError} )}
{/* بیمه — منطق موجود NewSessionPage؛ فقط وقتی سرویس تحت پوشش یا بیمار بیمه دارد */} {showInsurance && (
بیمه {/* نوع خدمت فقط وقتی چند نوع فعال است پرسیده می‌شود؛ وگرنه همان نوعِ فعال. */} {needsCategoryChoice && (
نوع خدمت ({ value: c.key, label: c.label }))} value={serviceCategory || null} onChange={v => applyServiceCategory(v ? String(v) : '')} placeholder="انتخاب نوع خدمت" isClearable />
)}
بیمه پایه applyBase(v ? String(v) : '')} placeholder="بدون بیمه پایه" isClearable />
بیمه تکمیلی applySupp(v ? String(v) : '')} placeholder="بدون بیمه تکمیلی" isClearable />
تخفیف بیمه پایه (%) setBasePercent(digitsOnly(e.target.value, 3))} />
تخفیف تکمیلی (%) setSuppPercent(digitsOnly(e.target.value, 3))} />
)} {/* یادداشت */}
یادداشت