Files
clinicpro/assets/admin/pages/NewSessionPage.tsx
T
2026-06-24 01:55:19 +03:30

258 lines
14 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 { insurance_id: number; insurance_name: string | null; insurance_kind: string | null; coverage_percent: number }
const PAYMENT_LABELS: Record<string, string> = {
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; qty: number }[]>([]);
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 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 }]);
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 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, 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)}
<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>
);
}