diff --git a/assets/admin/components/icons/FilesServiceIcons.tsx b/assets/admin/components/icons/FilesServiceIcons.tsx index 02452c0a..864f431e 100644 --- a/assets/admin/components/icons/FilesServiceIcons.tsx +++ b/assets/admin/components/icons/FilesServiceIcons.tsx @@ -358,3 +358,29 @@ export function CloseModalD() { ); } + +/** «پذیرش کننده» — tauri UserTick, verbatim. */ +export function UserTick({ color = '#0D9F43', size = 24 }: { color?: string; size?: number }) { + return ( + + + + + + + ); +} + +/** آیکون «قیمت» کنار سرویس/کالا — tauri FilesServiceAddCard, verbatim. */ +export function FilesServiceAddCard({ color = '#616161' }: { color?: string }) { + return ( + + + + + + + + + ); +} diff --git a/assets/admin/components/session/CreateStep.tsx b/assets/admin/components/session/CreateStep.tsx new file mode 100644 index 00000000..cc7a6210 --- /dev/null +++ b/assets/admin/components/session/CreateStep.tsx @@ -0,0 +1,425 @@ +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 } 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'; + +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 } +interface Rule { covered: boolean; percent: number; franchise: number; ceiling: 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 } + +// آینه‌ی 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 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; +} + +/** + * گام «ایجاد سرویس» — پورت tauri Step1Details با دیتای واقعی: + * تاریخ/ساعت پذیرش، بخش/سرویس/پرسنل، کالای مصرفی با شمارنده، پکیج، و بلوک بیمه‌ی + * موجودِ NewSessionPage (نمایش شرطی: سرویسِ تحت پوشش بیمه یا بیمه در پروفایل بیمار). + */ +export default function CreateStep({ recordUuid, profile, onCreated, onCancel }: Props) { + const userName = useAuthStore((s) => s.userName); + + // ── 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 }[]>([]); + 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 [baseId, setBaseId] = useState(''); + const [suppId, setSuppId] = useState(''); + const [basePercent, setBasePercent] = useState('0'); + const [suppPercent, setSuppPercent] = useState('0'); + const [notes, setNotes] = useState(''); + + // ── داده‌ها ────────────────────────────────────────────────────────────── + 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: 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 } }>({ + 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 && (!visitPrice || visitPrice === '0')) setVisitPrice(String(freeVisit)); + }, [freeVisit]); // eslint-disable-line react-hooks/exhaustive-deps + + 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 = (itemsData?.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 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); setBasePercent(id ? String(coverageOf(id)) : '0'); }; + const applySupp = (id: string) => { setSuppId(id); setSuppPercent(id ? String(coverageOf(id)) : '0'); }; + + // نمایش شرطی بلوک بیمه: سرویسِ تحت پوشش بیمه انتخاب شده یا پروفایل بیمار بیمه دارد. + const showInsurance = 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 }]); + 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)); + }; + + // ── قیمت‌ها (آینه‌ی سرور) ──────────────────────────────────────────────── + const visit = 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; + 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], // 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) => api.post(`/api/v1/patient/${recordUuid}/session`, body), + onSuccess: (res: any) => { + toast.success('مراجعه ثبت شد'); + onCreated(res?.data?.uuid as string); + }, + onError: (e: Error) => toast.error(e.message), + }); + + const submit = () => { + 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) } : {}), + 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="انتخاب کنید..." isDisabled={!sectionUuid} /> +
+
+ + + قیمت: {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 /> +
+ + {/* قیمت ویزیت */} +
+ قیمت ویزیت (تومان) + setVisitPrice(e.target.value)} /> +
+ + {/* بیمه — منطق موجود NewSessionPage؛ فقط وقتی سرویس تحت پوشش یا بیمار بیمه دارد */} + {showInsurance && ( +
+ بیمه +
+
+ بیمه پایه + applyBase(v ? String(v) : '')} placeholder="بدون بیمه پایه" isClearable /> +
+
+ بیمه تکمیلی + applySupp(v ? String(v) : '')} placeholder="بدون بیمه تکمیلی" isClearable /> +
+
+
+
+ تخفیف بیمه پایه (%) + setBasePercent(e.target.value)} /> +
+
+ تخفیف تکمیلی (%) + setSuppPercent(e.target.value)} /> +
+
+
+ )} + + {/* یادداشت */} +
+ یادداشت +