diff --git a/assets/admin/App.tsx b/assets/admin/App.tsx index 064b2af5..96be04d3 100644 --- a/assets/admin/App.tsx +++ b/assets/admin/App.tsx @@ -30,6 +30,7 @@ import MyClinicPage from './pages/MyClinicPage'; import SettingsPage from './pages/SettingsPage'; import DoctorProfilePage from './pages/DoctorProfilePage'; import MyPatientsPage from './pages/MyPatientsPage'; +import NewSessionPage from './pages/NewSessionPage'; import InsurancePricingPage from './pages/InsurancePricingPage'; import ClaimsPage from './pages/ClaimsPage'; import MyFinancialPage from './pages/MyFinancialPage'; @@ -170,6 +171,7 @@ export default function App() { {/* دکتر / منشی / کلینیک */} } /> + } /> } /> } /> } /> diff --git a/assets/admin/pages/MyPatientsPage.tsx b/assets/admin/pages/MyPatientsPage.tsx index f92d1be9..e1e7557c 100644 --- a/assets/admin/pages/MyPatientsPage.tsx +++ b/assets/admin/pages/MyPatientsPage.tsx @@ -15,6 +15,7 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import React, { useCallback, useRef, useState } from "react"; import { useForm } from "react-hook-form"; +import { useNavigate } from "react-router-dom"; import { toast } from "sonner"; import { z } from "zod"; import FeatureGate from "../components/ui/FeatureGate"; @@ -103,6 +104,7 @@ function calcFinalPrice( function MyPatientsPageInner() { const qc = useQueryClient(); + const navigate = useNavigate(); const [selectedRecord, setSelectedRecord] = useState( null, ); @@ -828,13 +830,7 @@ function MyPatientsPageInner() { @@ -1005,13 +1001,7 @@ function MyPatientsPageInner() { diff --git a/assets/admin/pages/NewSessionPage.tsx b/assets/admin/pages/NewSessionPage.tsx new file mode 100644 index 00000000..0ee82b62 --- /dev/null +++ b/assets/admin/pages/NewSessionPage.tsx @@ -0,0 +1,252 @@ +import { 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 PricingInsurance { insurance_id: number; insurance_name: string; type: string; patient_share_rials: number | null } +interface InsurancePricing { free_visit_price_rials: number; insurances: PricingInsurance[] } + +const PAYMENT_LABELS: Record = { + cash: 'نقدی', card: 'کارت', insurance: 'بیمه', online: 'آنلاین', pending: 'در انتظار', +}; + +function calcFinal(visit: number, base: number, supp: number, services: number) { + const afterBase = visit * (1 - base / 100); + const afterSupp = afterBase * (1 - supp / 100); + return Math.round(afterSupp) + services; +} + +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 }[]>([]); + 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: pricingData } = useQuery>({ + queryKey: ['insurance-pricing'], queryFn: () => api.get('/api/v1/insurance-pricing'), + }); + + const pricing = (pricingData?.data as InsurancePricing | undefined) ?? undefined; + const freeVisit = pricing?.free_visit_price_rials ?? 0; + const baseOpts = (pricing?.insurances ?? []).filter(i => i.type === 'basic').map(i => ({ value: String(i.insurance_id), label: i.insurance_name })); + const suppOpts = (pricing?.insurances ?? []).filter(i => i.type === 'supplementary').map(i => ({ value: String(i.insurance_id), label: i.insurance_name })); + + 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 shareToPercent = (id: string): number => { + if (!id || freeVisit <= 0) return 0; + const ins = pricing?.insurances.find(i => String(i.insurance_id) === id); + if (!ins || ins.patient_share_rials == null) return 0; + return Math.round((Math.max(0, freeVisit - ins.patient_share_rials) / freeVisit) * 1000) / 10; + }; + const applyBase = (id: string) => { + setBaseId(id); + form.setValue('insurance_base_id', id ? Number(id) : undefined); + if (id && freeVisit > 0) { + form.setValue('visit_price_rials', freeVisit); + form.setValue('base_insurance_discount_percent', shareToPercent(id)); + } + }; + const applySupp = (id: string) => { + setSuppId(id); + form.setValue('insurance_supplementary_id', id ? Number(id) : undefined); + if (id) form.setValue('supplementary_discount_percent', shareToPercent(id)); + }; + + 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 }]); + setItemUuid(''); + }; + + 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, 0), [selectedServices]); + const afterBase = Math.round(visit * (1 - base / 100)); + const afterSupp = Math.round(afterBase * (1 - supp / 100)); + const finalPrice = calcFinal(visit, base, supp, servicesTotal); + + 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 })) }); + }); + + const summaryRow = (label: string, val: number, strong = false) => ( +
+ {label} + {formatRial(val)} +
+ ); + + return ( +
+
+ +
+

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

+
بیمار: {patientName}
+
+
+ +
+
+
+

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

+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+

خدمات

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

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

+
+ + +
+
+ +