feat: port tauri create-service page as 3-step new-session wizard

Backend:
- Add session_at and inventory_package_id to patient_sessions, new
  session_consumables table (migration Version20260716102537)
- New SessionConsumable entity/repository mirroring SessionService;
  price snapshot, quantity >= 1, tenant-scoped silent skip
- PatientService::createSession accepts session_at, consumables[] and
  inventory_package_uuid; consumables are fully patient-paid (no
  insurance coverage) and added to final_price_rials
- Functional tests: success, foreign-tenant/unknown skip, empty and
  zero-quantity edges (tests/Patient/SessionConsumableTest.php)
- docs/api/patient.md updated for the new Create Session fields

Frontend (admin):
- NewSessionPage rewritten as the tauri /files/create-service 3-step
  wizard (ایجاد سرویس ← پرداخت ← جزییات) using SessionStepper
- New CreateStep: acceptance date/time (Jalali), section/service/staff,
  consumables with counters, package select, conditional insurance
  block (insured service or insured patient profile), price summary
- PaymentStep/DetailsStep extracted from SessionPaymentPage and shared
  between both pages (behavior unchanged, tests still green)
- UserTick and FilesServiceAddCard icons ported verbatim from tauri
- Vitest coverage for the wizard incl. empty-data states

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-16 14:20:32 +03:30
co-authored by Claude Opus 4.8
parent d7b06108f0
commit 07222826c3
14 changed files with 1462 additions and 550 deletions
+86 -287
View File
@@ -1,320 +1,119 @@
import { useEffect, useMemo, useState } from 'react';
import { 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 { useQuery } from '@tanstack/react-query';
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' };
import type { PatientRecord } from '../types';
import type { SessionCardData } from '../components/SessionServiceCard';
import SessionStepper from '../components/SessionStepper';
import CreateStep from '../components/session/CreateStep';
import PaymentStep from '../components/session/PaymentStep';
import DetailsStep from '../components/session/DetailsStep';
import { ArrowLeftPH, ArrowLeftD, CloseModalD } from '../components/icons/FilesServiceIcons';
/**
* ثبت مراجعه جدید — پورت کامل tauri /files/create-service (حالت ایجاد):
* ویزارد سه‌گامی «ایجاد سرویس ← پرداخت ← جزییات». گام ۱ session را می‌سازد
* (تاریخ/ساعت پذیرش، خدمات + پرسنل، کالای مصرفی، پکیج، بیمه‌ی شرطی)؛
* گام‌های ۲ و ۳ همان کامپوننت‌های مشترک SessionPaymentPage هستند.
*/
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 steps = ['ایجاد سرویس', 'پرداخت', 'جزییات'];
const [activeStep, setActiveStep] = useState(0);
const [createdUuid, setCreatedUuid] = useState('');
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 record = recordQ.data?.data as PatientRecord | 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'),
// بعد از ساخت session در گام ۱، گام‌های ۲/۳ از لیست sessions تغذیه می‌شوند.
const sessionsQ = useQuery<ApiResponse<SessionCardData[]>>({
queryKey: ['patient-sessions', recordUuid],
queryFn: () => api.get(`/api/v1/patient/${recordUuid}/sessions`),
enabled: !!recordUuid && !!createdUuid,
});
const { data: itemsData } = useQuery<ApiResponse<ServiceItem[]>>({
queryKey: ['service-items-for-session', sectionUuid],
queryFn: () => api.get(`/api/v1/service-items/${sectionUuid}`),
enabled: !!sectionUuid,
const session = (sessionsQ.data?.data ?? []).find((s) => s.uuid === createdUuid);
const walletQ = useQuery<ApiResponse<{ balance_rials: number }>>({
queryKey: ['patient-wallet', recordUuid],
queryFn: () => api.get(`/api/v1/patient/${recordUuid}/wallet`),
enabled: !!recordUuid && !!createdUuid,
});
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;
const walletBalance = (walletQ.data?.data as any)?.balance_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>
);
const finish = () => nav(`/admin/patients/${recordUuid}?tab=services`);
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 className="fade-in" style={{ width: '100%' }}>
{/* breadcrumb — tauri AddService header */}
<div style={{ display: 'flex', justifyContent: 'flex-start', marginBottom: 30 }}>
<div className="dark:text-[#A1A1A1]" style={{ display: 'flex', alignItems: 'center', gap: 8, color: '#6B7280', fontSize: 12, padding: '0 16px' }}>
<div
onClick={() => nav(-1)}
className="bg-white dark:bg-[#222433]"
style={{ display: 'flex', alignItems: 'center', gap: 4, padding: '6px 8px', borderRadius: 12, cursor: 'pointer' }}
>
<ArrowLeftPH style={{ width: 18, height: 18, rotate: '180deg' }} />
<span>بازگشت</span>
</div>
<ArrowLeftD />
<span>پرونده</span>
<ArrowLeftD />
<span className="dark:text-[#D7D8ED]" style={{ color: '#111827' }}>{patientName}</span>
</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>
{/* card — tauri width 748 centered */}
<div style={{ width: '100%', display: 'flex', justifyContent: 'center' }}>
<div className="bg-white dark:bg-[#222433]" style={{ width: 748, maxWidth: '100%', padding: 24 }}>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 8 }}>
<button
type="button"
aria-label="بستن"
onClick={() => nav(-1)}
style={{ minWidth: 40, height: 40, marginBottom: 8, borderRadius: '50%', border: 'none', background: 'transparent', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
>
<CloseModalD />
</button>
</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>
<SessionStepper activeStep={activeStep} steps={steps} />
<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="روش پرداخت"
{activeStep === 0 && (
<CreateStep
recordUuid={recordUuid}
profile={record?.profile}
onCreated={(uuid) => { setCreatedUuid(uuid); setActiveStep(1); }}
onCancel={() => nav(-1)}
/>
)}
{activeStep === 1 && (
session ? (
<PaymentStep
recordUuid={recordUuid}
session={session}
walletBalance={walletBalance}
onContinue={() => setActiveStep(2)}
onCancel={finish}
/>
</div>
<div>
<label style={fieldLabel}>یادداشت</label>
<textarea className="input" rows={3} dir="rtl" placeholder="یادداشت پزشک..." {...form.register('notes')} />
</div>
</div>
</div>
) : (
<div style={{ padding: '40px 0', textAlign: 'center', color: 'var(--text-3)', fontSize: 14 }}>در حال بارگذاری...</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>
{activeStep === 2 && session && (
<DetailsStep session={session} onBack={() => setActiveStep(1)} onFinish={finish} />
)}
</div>
</form>
</div>
</div>
);
}