Files
clinicpro/assets/admin/pages/NewSessionPage.tsx
T

321 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<typeof schema>;
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<string, string> = {
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<FormData>({
resolver: zodResolver(schema),
defaultValues: { visit_price_rials: 0, base_insurance_discount_percent: 0, supplementary_discount_percent: 0, payment_method: 'cash' },
});
const recordQ = useQuery<ApiResponse<PatientRecord>>({
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<ApiResponse<ServiceSection[]>>({
queryKey: ['service-sections'], queryFn: () => api.get('/api/v1/service-sections'),
});
const { data: itemsData } = useQuery<ApiResponse<ServiceItem[]>>({
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) => (
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: strong ? 15 : 13, fontWeight: strong ? 700 : 400, color: strong ? 'var(--text)' : 'var(--text-3)' }}>
<span>{label}</span>
<span style={strong ? { color: 'var(--primary)' } : undefined}>{formatRial(val)}</span>
</div>
);
return (
<div className="fade-in" style={{ maxWidth: 1100, margin: '0 auto' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 16 }}>
<button className="btn ghost sm" onClick={() => nav(-1)}>
<ChevronRightIcon style={{ width: 15 }} /> بازگشت
</button>
<div>
<h1 className="section-title" style={{ margin: 0 }}>ثبت مراجعه جدید</h1>
<div className="muted" style={{ fontSize: 13, marginTop: 2 }}>بیمار: {patientName}</div>
</div>
</div>
<form onSubmit={submit} style={{ display: 'grid', gridTemplateColumns: 'minmax(0,1fr) 320px', gap: 16, alignItems: 'start' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div className="card" style={{ padding: 20 }}>
<h3 style={sectionTitle}>بیمه و مبلغ ویزیت</h3>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 12 }}>
<div>
<label style={fieldLabel}>بیمه پایه</label>
<SearchableSelect options={baseOpts} value={baseId} onChange={v => applyBase(v ? String(v) : '')} placeholder="بدون بیمه پایه" isClearable />
</div>
<div>
<label style={fieldLabel}>بیمه تکمیلی</label>
<SearchableSelect options={suppOpts} value={suppId} onChange={v => applySupp(v ? String(v) : '')} placeholder="بدون بیمه تکمیلی" isClearable />
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 12 }}>
<div>
<label style={fieldLabel}>قیمت ویزیت (تومان)</label>
<input className="input" type="number" min={0} dir="ltr" {...form.register('visit_price_rials')} />
</div>
<div>
<label style={fieldLabel}>تخفیف بیمه پایه (%)</label>
<input className="input" type="number" min={0} max={100} dir="ltr" {...form.register('base_insurance_discount_percent')} />
</div>
<div>
<label style={fieldLabel}>تخفیف تکمیلی (%)</label>
<input className="input" type="number" min={0} max={100} dir="ltr" {...form.register('supplementary_discount_percent')} />
</div>
</div>
</div>
<div className="card" style={{ padding: 20 }}>
<h3 style={sectionTitle}>خدمات</h3>
<div style={{ display: 'flex', gap: 8, marginBottom: selectedServices.length ? 12 : 0 }}>
<div style={{ flex: 1 }}>
<SearchableSelect options={sectionOptions} value={sectionUuid} onChange={v => { setSectionUuid(v ? String(v) : ''); setItemUuid(''); }} placeholder="انتخاب بخش..." />
</div>
<div style={{ flex: 1 }}>
<SearchableSelect options={itemOptions} value={itemUuid} onChange={v => setItemUuid(v ? String(v) : '')} placeholder="انتخاب سرویس..." isDisabled={!sectionUuid} />
</div>
<button type="button" className="btn primary sm" onClick={addService} disabled={!itemUuid}>
<PlusIcon style={{ width: 14 }} />
</button>
</div>
{selectedServices.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{selectedServices.map(svc => (
<div key={svc.uuid} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '8px 12px', borderRadius: 10, border: '1px solid var(--border)' }}>
<span style={{ flex: 1, fontWeight: 500, fontSize: 13 }}>{svc.name}</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<button type="button" className="mini-btn" onClick={() => setQty(svc.uuid, svc.qty - 1)} disabled={svc.qty <= 1}></button>
<span style={{ minWidth: 26, textAlign: 'center', fontWeight: 600, fontSize: 13 }}>{svc.qty}</span>
<button type="button" className="mini-btn" onClick={() => setQty(svc.uuid, svc.qty + 1)}>+</button>
</div>
<span style={{ color: 'var(--primary)', fontWeight: 600, fontSize: 13, minWidth: 90, textAlign: 'left' }} dir="ltr">{formatRial(svc.price * svc.qty)}</span>
<button type="button" style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--danger)', padding: 0, display: 'flex' }}
onClick={() => setSelectedServices(p => p.filter(s => s.uuid !== svc.uuid))}>
<XMarkIcon style={{ width: 15 }} />
</button>
</div>
))}
</div>
)}
</div>
<div className="card" style={{ padding: 20 }}>
<h3 style={sectionTitle}>پرداخت و یادداشت</h3>
<div style={{ marginBottom: 12 }}>
<label style={fieldLabel}>روش پرداخت</label>
<SearchableSelect
options={Object.entries(PAYMENT_LABELS).map(([v, l]) => ({ value: v, label: l }))}
value={form.watch('payment_method')}
onChange={v => form.setValue('payment_method', (v as FormData['payment_method']) || 'cash')}
placeholder="روش پرداخت"
/>
</div>
<div>
<label style={fieldLabel}>یادداشت</label>
<textarea className="input" rows={3} dir="rtl" placeholder="یادداشت پزشک..." {...form.register('notes')} />
</div>
</div>
</div>
<div className="card" style={{ padding: 20, position: 'sticky', top: 16, display: 'flex', flexDirection: 'column', gap: 8 }}>
<h3 style={sectionTitle}>خلاصه</h3>
{summaryRow('ویزیت آزاد', visit)}
{base > 0 && summaryRow('پس از بیمه پایه', afterBase)}
{supp > 0 && summaryRow('پس از بیمه تکمیلی', afterSupp)}
{servicesTotal > 0 && summaryRow('جمع خدمات', servicesTotal)}
{servicesInsured > 0 && summaryRow('سهم بیمه از خدمات', servicesInsured)}
{servicesInsured > 0 && summaryRow('سهم بیمار از خدمات', servicesTotal - servicesInsured)}
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 8, marginTop: 2 }}>
{summaryRow('مبلغ نهایی (سهم بیمار)', finalPrice, true)}
</div>
<button type="submit" className="btn primary block" style={{ marginTop: 8 }} disabled={createMut.isPending}>
{createMut.isPending ? 'در حال ذخیره...' : 'ثبت مراجعه'}
</button>
<button type="button" className="btn ghost block" onClick={() => nav(-1)}>انصراف</button>
</div>
</form>
</div>
);
}