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
@@ -358,3 +358,29 @@ export function CloseModalD() {
</svg>
);
}
/** «پذیرش کننده» — tauri UserTick, verbatim. */
export function UserTick({ color = '#0D9F43', size = 24 }: { color?: string; size?: number }) {
return (
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none">
<path d="M12 12C14.7614 12 17 9.76142 17 7C17 4.23858 14.7614 2 12 2C9.23858 2 7 4.23858 7 7C7 9.76142 9.23858 12 12 12Z" stroke={color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
<path d="M3.41016 22C3.41016 18.13 7.26015 15 12.0002 15C12.9602 15 13.8902 15.13 14.7602 15.37" stroke={color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
<path d="M22 18C22 18.75 21.79 19.46 21.42 20.06C21.21 20.42 20.94 20.74 20.63 21C19.93 21.63 19.01 22 18 22C16.54 22 15.27 21.22 14.58 20.06C14.21 19.46 14 18.75 14 18C14 16.74 14.58 15.61 15.5 14.88C16.19 14.33 17.06 14 18 14C20.21 14 22 15.79 22 18Z" stroke={color} strokeWidth="1.5" strokeMiterlimit="10" strokeLinecap="round" strokeLinejoin="round" />
<path d="M16.4395 18L17.4294 18.99L19.5594 17.02" stroke={color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
/** آیکون «قیمت» کنار سرویس/کالا — tauri FilesServiceAddCard, verbatim. */
export function FilesServiceAddCard({ color = '#616161' }: { color?: string }) {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20" fill="none">
<path d="M1.66699 7.0835H11.2503" stroke={color} strokeWidth="1.5" strokeMiterlimit="10" strokeLinecap="round" strokeLinejoin="round" />
<path d="M5 13.75H6.66667" stroke={color} strokeWidth="1.5" strokeMiterlimit="10" strokeLinecap="round" strokeLinejoin="round" />
<path d="M8.75 13.75H12.0833" stroke={color} strokeWidth="1.5" strokeMiterlimit="10" strokeLinecap="round" strokeLinejoin="round" />
<path d="M18.3337 10.0248V13.4248C18.3337 16.3498 17.592 17.0832 14.6337 17.0832H5.36699C2.40866 17.0832 1.66699 16.3498 1.66699 13.4248V6.57484C1.66699 3.64984 2.40866 2.9165 5.36699 2.9165H11.2503" stroke={color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
<path d="M13.75 5.2085H18.3333" stroke={color} strokeWidth="1.5" strokeLinecap="round" />
<path d="M16.042 7.49984V2.9165" stroke={color} strokeWidth="1.5" strokeLinecap="round" />
</svg>
);
}
@@ -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<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: staffData } = useQuery<ApiResponse<StaffRow[]>>({
queryKey: ['staff'], queryFn: () => api.get('/api/v1/staff'),
});
const { data: inventoryData } = useQuery<ApiResponse<{ items: InventoryItemRow[] }>>({
queryKey: ['inventory-items'], queryFn: () => api.get('/api/v1/inventory-items'),
});
const { data: packagesData } = useQuery<ApiResponse<PackageRow[]>>({
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) => (
<div className="dark:border-[#404040]" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 74, height: 27, border: '1px solid #d1d1d1', borderRadius: 8, padding: '0 4px', gap: 4 }}>
<button type="button" aria-label="افزایش" onClick={() => set(qty + 1)} style={{ border: 'none', background: 'transparent', color: '#FF7A45', cursor: 'pointer', display: 'flex' }}>
<PlusIcon style={{ width: 16 }} />
</button>
<span className="dark:text-[#D7D8ED]" style={{ fontSize: 13, color: '#525252', minWidth: 20, textAlign: 'center' }}>{qty}</span>
<button type="button" aria-label="کاهش" onClick={() => set(qty - 1)} style={{ border: 'none', background: 'transparent', color: '#FF7A45', cursor: 'pointer', display: 'flex' }}>
{qty > 1 ? <MinusIcon style={{ width: 16 }} /> : <TrashRed size={16} color="#f17732" />}
</button>
</div>
);
return (
<div style={{ marginTop: 16 }}>
{/* پذیرش کننده — tauri UserTick + کاربر لاگین‌شده */}
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 28 }}>
<UserTick color="#6B7280" size={18} />
<span className="dark:text-[#D7D8ED]" style={{ fontSize: 14, color: '#616161' }}>پذیرش کننده: {userName || '—'}</span>
</div>
{/* تاریخ و ساعت پذیرش */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 24 }}>
<div>
<span style={fieldLabel}>تاریخ پذیرش</span>
<PersianDateInput value={dateISO} onChange={setDateISO} />
</div>
<div>
<span style={fieldLabel}>ساعت پذیرش</span>
<div className="bg-white dark:bg-[#222433] dark:border-[#404040]" style={{ display: 'flex', alignItems: 'center', gap: 8, height: 48, border: '1px solid #e1e1e1', borderRadius: 4, padding: '0 8px' }}>
<ClockP color="#6B7280" size={18} />
<input
type="time"
aria-label="ساعت پذیرش"
value={time}
onChange={(e) => setTime(e.target.value)}
style={{ border: 'none', outline: 'none', background: 'transparent', fontSize: '0.875rem', color: 'inherit', flex: 1 }}
/>
</div>
</div>
</div>
{/* انتخاب بخش */}
<div style={{ marginBottom: 16 }}>
<span style={fieldLabel}>انتخاب بخش</span>
<SearchableSelect inputId="section-select" options={sectionOptions} value={sectionUuid} onChange={v => { setSectionUuid(v ? String(v) : ''); setItemUuid(''); }} placeholder="انتخاب کنید..." />
</div>
{/* انتخاب سرویس + قیمت + افزودن */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<div style={{ width: '50%' }}>
<span style={fieldLabel}>انتخاب سرویس</span>
<SearchableSelect inputId="service-select" options={itemOptions} value={itemUuid} onChange={v => setItemUuid(v ? String(v) : '')} placeholder="انتخاب کنید..." isDisabled={!sectionUuid} />
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginTop: 20, flexShrink: 0 }}>
<FilesServiceAddCard color="#6B7280" />
<span className="dark:text-[#D7D8ED]" style={{ fontSize: 13, color: '#3b3b3b' }}>
قیمت: {currentItem ? formatRial(currentItem.price_rials) : '—'}
</span>
</div>
<button type="button" aria-label="افزودن سرویس" onClick={addService} disabled={!itemUuid} style={{ minWidth: 40, height: 40, borderRadius: 8, background: '#5559CE', border: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', marginTop: 20 }}>
<PlusIcon style={{ width: 18, color: '#fff' }} />
</button>
</div>
{/* خدمات انتخاب شده */}
{selectedServices.length > 0 && (
<div style={{ border: '1px dashed #C7C9F4', borderRadius: 8, padding: 16, marginBottom: 16 }}>
<span className="dark:text-[#D7D8ED]" style={{ fontSize: 13, fontWeight: 600, color: '#525252', display: 'block', marginBottom: 16 }}>خدمات انتخاب شده</span>
{selectedServices.map(item => (
<div key={item.uuid} style={{ display: 'flex', alignItems: 'center', gap: 24, padding: '8px 0' }}>
<span className="dark:text-[#D7D8ED]" style={{ fontSize: 14, color: '#525252', minWidth: 120 }}>{item.name}</span>
{counter(item.qty, (q) => setServiceQty(item.uuid, q))}
<span className="dark:text-[#A1A1A1]" style={{ fontSize: 12, fontWeight: 500, color: '#3b3b3b' }}>قیمت: {formatRial(item.price * item.qty)}</span>
</div>
))}
<span className="dark:text-[#D7D8ED]" style={{ fontSize: 13, fontWeight: 600, color: '#525252', display: 'block', marginTop: 16 }}>مجموع قیمت خدمات: {formatRial(servicesTotal)}</span>
</div>
)}
{/* پرسنل */}
<div style={{ marginBottom: 16 }}>
<span style={fieldLabel}>پرسنل</span>
<SearchableSelect inputId="staff-select" options={staffOptions} value={staffUuid} onChange={v => setStaffUuid(v ? String(v) : '')} placeholder="انتخاب کنید..." isClearable />
</div>
{/* انتخاب کالای مصرفی */}
<div style={{ marginBottom: 16 }}>
<span className="dark:text-[#D7D8ED]" style={{ fontSize: 14, color: '#525252', display: 'block', marginBottom: 8 }}>انتخاب کالای مصرفی</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div style={{ width: 313, maxWidth: '60%' }}>
<SearchableSelect inputId="consumable-select" options={consumableOptions} value={consumableUuid} onChange={v => setConsumableUuid(v ? String(v) : '')} placeholder="انتخاب کنید..." />
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 4, flexShrink: 0 }}>
<FilesServiceAddCard color="#6B7280" />
<span className="dark:text-[#A1A1A1]" style={{ fontSize: 13, color: '#6B7280' }}>
قیمت: {currentConsumable ? formatRial(currentConsumable.price) : '—'}
</span>
</div>
<button type="button" aria-label="افزودن کالا" onClick={addConsumable} disabled={!consumableUuid} style={{ minWidth: 40, height: 40, borderRadius: 8, background: '#5559CE', border: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<PlusIcon style={{ width: 18, color: '#fff' }} />
</button>
</div>
</div>
{/* کالاهای انتخاب شده — باکس خط‌چین tauri */}
<div style={{ border: '1px dashed #C7C9F4', borderRadius: 8, padding: 16, marginBottom: 16 }}>
<span className="dark:text-[#D7D8ED]" style={{ fontSize: 13, fontWeight: 600, color: '#525252', display: 'block', marginBottom: 16 }}>کالاهای انتخاب شده</span>
{selectedConsumables.length === 0 ? (
<span className="dark:text-[#A1A1A1]" style={{ fontSize: 13, color: '#6B7280' }}>کالایی انتخاب نشده است</span>
) : selectedConsumables.map(item => (
<div key={item.uuid} style={{ display: 'flex', alignItems: 'center', gap: 24, padding: '8px 0' }}>
<span className="dark:text-[#D7D8ED]" style={{ fontSize: 14, color: '#525252', minWidth: 120 }}>{item.name}</span>
{counter(item.qty, (q) => setConsumableQty(item.uuid, q))}
<span className="dark:text-[#A1A1A1]" style={{ fontSize: 12, fontWeight: 500, color: '#3b3b3b' }}>قیمت: {formatRial(item.price * item.qty)}</span>
</div>
))}
<span className="dark:text-[#D7D8ED]" style={{ fontSize: 13, fontWeight: 600, color: '#525252', display: 'block', marginTop: 16 }}>مجموع قیمت کالاها: {formatRial(consumablesTotal)}</span>
</div>
{/* انتخاب پکیج */}
<div style={{ marginBottom: 16 }}>
<span style={fieldLabel}>انتخاب پکیج</span>
<SearchableSelect inputId="package-select" options={packageOptions} value={packageUuid} onChange={v => setPackageUuid(v ? String(v) : '')} placeholder="انتخاب کنید..." isClearable />
</div>
{/* قیمت ویزیت */}
<div style={{ marginBottom: 16 }}>
<span style={fieldLabel}>قیمت ویزیت (تومان)</span>
<input className="input" type="number" min={0} dir="ltr" aria-label="قیمت ویزیت" value={visitPrice} onChange={(e) => setVisitPrice(e.target.value)} />
</div>
{/* بیمه — منطق موجود NewSessionPage؛ فقط وقتی سرویس تحت پوشش یا بیمار بیمه دارد */}
{showInsurance && (
<div style={{ marginBottom: 16 }}>
<span style={fieldLabel}>بیمه</span>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 12 }}>
<div>
<span style={fieldLabel}>بیمه پایه</span>
<SearchableSelect inputId="base-insurance-select" options={baseOpts} value={baseId} onChange={v => applyBase(v ? String(v) : '')} placeholder="بدون بیمه پایه" isClearable />
</div>
<div>
<span style={fieldLabel}>بیمه تکمیلی</span>
<SearchableSelect inputId="supp-insurance-select" options={suppOpts} value={suppId} onChange={v => applySupp(v ? String(v) : '')} placeholder="بدون بیمه تکمیلی" isClearable />
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div>
<span style={fieldLabel}>تخفیف بیمه پایه (%)</span>
<input className="input" type="number" min={0} max={100} dir="ltr" aria-label="تخفیف بیمه پایه" value={basePercent} onChange={(e) => setBasePercent(e.target.value)} />
</div>
<div>
<span style={fieldLabel}>تخفیف تکمیلی (%)</span>
<input className="input" type="number" min={0} max={100} dir="ltr" aria-label="تخفیف تکمیلی" value={suppPercent} onChange={(e) => setSuppPercent(e.target.value)} />
</div>
</div>
</div>
)}
{/* یادداشت */}
<div style={{ marginBottom: 16 }}>
<span style={fieldLabel}>یادداشت</span>
<textarea className="input" rows={3} dir="rtl" placeholder="یادداشت پزشک..." value={notes} onChange={(e) => setNotes(e.target.value)} />
</div>
{/* خلاصه مبلغ */}
<div className="dark:border-[#404040]" style={{ borderTop: '1px solid var(--border)', paddingTop: 12, marginBottom: 16, display: 'flex', flexDirection: 'column', gap: 6 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 13, color: 'var(--text-3)' }}><span>ویزیت</span><span>{formatRial(afterSupp)}</span></div>
{servicesTotal > 0 && <div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 13, color: 'var(--text-3)' }}><span>سهم بیمار خدمات</span><span>{formatRial(servicesPatient)}</span></div>}
{consumablesTotal > 0 && <div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 13, color: 'var(--text-3)' }}><span>کالاهای مصرفی</span><span>{formatRial(consumablesTotal)}</span></div>}
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 15, fontWeight: 700 }}>
<span>مبلغ نهایی (سهم بیمار)</span>
<span style={{ color: 'var(--primary)' }}>{formatRial(finalPrice)}</span>
</div>
</div>
{/* ACTIONS — tauri: انصراف / ایجاد سرویس */}
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 8, marginTop: 16, width: '100%', maxWidth: 340, margin: '16px auto 0' }}>
<button type="button" style={{ ...ghostBtn, width: 164 }} onClick={onCancel}>انصراف</button>
<button type="button" style={{ ...primaryBtn, width: 164 }} onClick={submit} disabled={createMut.isPending}>
{createMut.isPending ? 'در حال ذخیره...' : 'ایجاد سرویس'}
</button>
</div>
</div>
);
}
@@ -0,0 +1,78 @@
import { formatRial } from '../../lib/utils';
import type { SessionCardData } from '../SessionServiceCard';
import { METHOD_LABELS } from './PaymentStep';
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 {
session: SessionCardData;
onBack: () => void;
onFinish: () => void;
}
/**
* گام «جزییات» — پورت tauri Step3Final با دیتای واقعی:
* خلاصه‌ی سرویس/هزینه/تخفیف + لیست پرداخت‌شده‌ها + مانده. مشترک بین
* NewSessionPage (ویزارد سه‌گامه) و SessionPaymentPage (دوگامه).
*/
export default function DetailsStep({ session, onBack, onFinish }: Props) {
const finalPrice = session.final_price_rials ?? 0;
const discountRials = session.discount_rials ?? 0;
const debt = session.patient_debt_rials ?? 0;
const payments = session.payments ?? [];
const serviceNames = (session.services ?? []).map((s) => s.service_name || s.name).filter(Boolean) as string[];
if ((session.visit_price_rials ?? 0) > 0) serviceNames.unshift('ویزیت');
return (
<>
{/* گام جزییات — پورت tauri Step3Final با دیتای واقعی */}
<div dir="rtl" className="dark:border-[#404040] dark:bg-[#222433]" style={{ padding: 16, border: '1px dashed #A7A7E0' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 32, marginBottom: 16 }}>
<span className="dark:text-[#D7D8ED]" style={{ fontSize: 14, fontWeight: 500, color: '#2f2f2f' }}>
{serviceNames.length ? serviceNames.join(' - ') : 'ویزیت'}
</span>
<span className="dark:bg-[#404040]" style={{ width: 1, height: 38, background: '#E0E0E0' }} />
<span className="dark:text-[#D7D8ED]" style={{ fontSize: 14, fontWeight: 500, color: '#525252' }}>{session.doctor_name || '—'}</span>
</div>
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', gap: 72, marginBottom: 16 }}>
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span className="dark:text-[#D7D8ED]" style={{ fontSize: 14, fontWeight: 500, color: '#525252' }}>هزینه سرویس:</span>
<span className="dark:text-[#D7D8ED]" style={{ fontSize: 14, fontWeight: 500, color: '#525252' }}>{formatRial(finalPrice)}</span>
</span>
<span className="dark:bg-[#404040]" style={{ width: 1, height: 38, background: '#E0E0E0' }} />
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span className="dark:text-[#D7D8ED]" style={{ fontSize: 14, fontWeight: 500, color: '#525252' }}>تخفیف:</span>
<span className="dark:text-[#D7D8ED]" style={{ fontSize: 14, fontWeight: 500, color: '#525252' }}>{formatRial(discountRials)}</span>
</span>
</div>
<span className="dark:text-[#6A6AD9]" style={{ fontSize: 13, fontWeight: 600, color: '#5559CE', display: 'block', marginBottom: 12 }}>پرداخت شده ها:</span>
{payments.length === 0 ? (
<span className="dark:text-[#A1A1A1]" style={{ fontSize: 13, color: '#6B7280' }}>پرداختی ثبت نشده است</span>
) : payments.map((p) => (
<div key={p.uuid} className="dark:border-[#404040]" style={{ display: 'flex', alignItems: 'center', gap: 56, padding: '8px 0', borderBottom: '1px solid #EEE' }}>
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span className="dark:bg-[#6A6AD9]" style={{ width: 6, height: 6, borderRadius: '50%', background: '#5559CE', flexShrink: 0 }} />
<span className="dark:text-[#D7D8ED]" style={{ fontSize: 14, fontWeight: 600, color: '#525252' }}>{METHOD_LABELS[p.method] ?? p.method}</span>
</span>
<span className="dark:text-[#D7D8ED]" style={{ fontSize: 14, fontWeight: 600, color: '#111827' }}>مبلغ: {formatRial(p.amount_rials)}</span>
</div>
))}
<div style={{ display: 'flex', width: '100%', justifyContent: 'flex-end', alignItems: 'center' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 16 }}>
<span className="dark:text-[#D7D8ED]" style={{ fontSize: 14, fontWeight: 600, color: '#525252' }}>مبلغ باقی مانده:</span>
<span className="dark:text-[#FF5252]" style={{ fontSize: 14, fontWeight: 600, color: '#d32f2f' }}>{formatRial(debt)}</span>
</div>
</div>
</div>
<div style={{ display: 'flex', gap: 8, marginTop: 16, width: '100%', maxWidth: 320, margin: '16px auto 0' }}>
<button type="button" style={{ ...ghostBtn, flex: 1 }} onClick={onBack}>انصراف</button>
<button type="button" style={{ ...primaryBtn, flex: 1 }} onClick={onFinish}>صدور فاکتور</button>
</div>
</>
);
}
@@ -0,0 +1,231 @@
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { ChevronDownIcon } from '@heroicons/react/24/outline';
import { toast } from 'sonner';
import { api } from '../../lib/api';
import { formatRial } from '../../lib/utils';
import type { SessionCardData } from '../SessionServiceCard';
import SearchableSelect from '../ui/SearchableSelect';
import PersianDateInput from '../ui/PersianDateInput';
import { Step2PaymentCard, FilesServiceBalanceWallet, TrashRed } from '../icons/FilesServiceIcons';
/** روش‌های پرداخت — همان چهار گزینه‌ی آکاردئون tauri Step2Payment. */
const METHODS: { key: string; label: string }[] = [
{ key: 'wallet', label: 'پرداخت از طریق کیف پول' },
{ key: 'pos', label: 'پرداخت از طریق کارت خوان' },
{ key: 'cash', label: 'پرداخت نقدی' },
{ key: 'card', label: 'کارت به کارت' },
];
export const METHOD_LABELS: Record<string, string> = {
wallet: 'پرداخت از کیف پول', pos: 'پرداخت کارتخوان', cash: 'پرداخت نقدی', card: 'کارت به کارت',
};
const todayISO = () => new Date().toISOString().slice(0, 10);
/** YYYY-MM-DD → unix (ظهر همان روز تا با هر timezone یک روز بماند) */
const isoToUnix = (iso: string) => Math.floor(new Date(`${iso}T12:00:00`).getTime() / 1000);
const fieldLabel: React.CSSProperties = { fontSize: 14, color: '#6B7280', marginBottom: 8, 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;
session: SessionCardData;
walletBalance: number;
onContinue: () => void;
onCancel: () => void;
}
/**
* گام «پرداخت» — پورت tauri Step2Payment با دیتای واقعی:
* تخفیف تسویه (PATCH /session/{uuid}) + پرداخت چندتکه (POST /session/{uuid}/payments).
* بین NewSessionPage (ویزارد سه‌گامه) و SessionPaymentPage (دوگامه) مشترک است.
*/
export default function PaymentStep({ recordUuid, session, walletBalance, onContinue, onCancel }: Props) {
const qc = useQueryClient();
const sessionUuid = session.uuid;
const [discountType, setDiscountType] = useState('');
const [discountValue, setDiscountValue] = useState('');
const [paymentDate, setPaymentDate] = useState(todayISO());
const [expanded, setExpanded] = useState<string | null>(null);
const [amount, setAmount] = useState('');
const invalidate = () => {
qc.invalidateQueries({ queryKey: ['patient-sessions', recordUuid] });
qc.invalidateQueries({ queryKey: ['patient-wallet', recordUuid] });
};
const discountMut = useMutation({
mutationFn: (body: object) => api.patch(`/api/v1/session/${sessionUuid}`, body),
onSuccess: () => { invalidate(); toast.success('تخفیف به‌روزرسانی شد'); },
onError: (e: Error) => toast.error(e.message),
});
const payMut = useMutation({
mutationFn: (body: object) => api.post(`/api/v1/session/${sessionUuid}/payments`, body),
onSuccess: () => { invalidate(); setAmount(''); toast.success('پرداخت ثبت شد'); },
onError: (e: Error) => toast.error(e.message),
});
const applyDiscount = () => {
if (!discountType || !discountValue) return;
discountMut.mutate({ discount_type: discountType, discount_value: Number(discountValue) });
};
const removeDiscount = () => {
setDiscountType(''); setDiscountValue('');
discountMut.mutate({ discount_type: null });
};
const submitPayment = (method: string) => {
if (!amount) return;
payMut.mutate({ method, amount_rials: Number(amount), paid_at: isoToUnix(paymentDate) });
};
const finalPrice = session.final_price_rials ?? 0;
const discountRials = session.discount_rials ?? 0;
const debt = session.patient_debt_rials ?? 0;
const payments = session.payments ?? [];
return (
<>
{/* هزینه سرویس */}
<div style={{ display: 'flex', alignItems: 'center', gap: 12, margin: '12px 0 16px' }}>
<Step2PaymentCard />
<span style={{ fontSize: 14, color: '#F97316', fontWeight: 700 }}>هزینه سرویس:</span>
<span className="dark:text-[#D7D8ED]" style={{ fontSize: 14, fontWeight: 700, color: '#525252' }}>{formatRial(finalPrice)}</span>
</div>
{/* تخفیف — port of tauri DiscountInput (نوع + مقدار + ثبت) */}
<span style={fieldLabel}>تخفیف:</span>
<div style={{ display: 'flex', alignItems: 'stretch', gap: 8 }}>
<div className="bg-white dark:bg-[#222433] dark:border-[#35343D]" style={{ flex: 1, minWidth: 0, display: 'flex', alignItems: 'center', border: '1px solid #e0e0e0', borderRadius: 8, marginBottom: 16 }}>
<div style={{ minWidth: 150, borderLeft: '1px solid #e0e0e0', padding: '0 4px' }}>
<SearchableSelect
options={[{ value: 'percent', label: 'درصدی' }, { value: 'fixed', label: 'مبلغ ثابت' }]}
value={discountType}
onChange={(v) => setDiscountType(v ? String(v) : '')}
placeholder="مبلغ تخفیف"
/>
</div>
<input
type="number"
min={0}
dir="ltr"
placeholder="مقدار تخفیف را وارد نمایید"
value={discountValue}
onChange={(e) => setDiscountValue(e.target.value)}
style={{ flex: 1, minWidth: 0, height: 40, padding: '0 12px', fontSize: 13, border: 'none', outline: 'none', background: 'transparent', color: 'inherit' }}
/>
<button
type="button"
onClick={applyDiscount}
disabled={discountMut.isPending || !discountType || !discountValue}
style={{ height: 40, minWidth: 72, border: 'none', borderRadius: '0 4px 4px 0', background: '#E8EBFF', color: '#3B3F9F', fontSize: 13, fontWeight: 600, cursor: 'pointer' }}
>
ثبت
</button>
</div>
<div
onClick={removeDiscount}
style={{ display: 'flex', alignItems: 'center', gap: 4, flexShrink: 0, cursor: 'pointer', marginBottom: 16, minWidth: 120, justifyContent: 'center' }}
>
<TrashRed color="#EF4444" size={18} />
<span className="dark:text-[#A1A1A1]" style={{ fontSize: 13, color: '#6B7280' }}>حذف تخفیف</span>
</div>
</div>
<p style={{ fontSize: 13, marginBottom: 16 }}>
<span className="dark:text-[#D7D8ED]" style={{ color: '#2f2f2f' }}>مبلغ تخفیف:</span>{' '}
<span style={{ color: '#D32F2F' }}>{formatRial(discountRials)}</span>
</p>
{/* تاریخ پرداخت */}
<span className="dark:text-[#A1A1A1]" style={{ ...fieldLabel, color: '#3b3b3b' }}>تاریخ پرداخت</span>
<PersianDateInput value={paymentDate} onChange={setPaymentDate} />
{/* روش‌های پرداخت */}
<span className="dark:text-[#A1A1A1]" style={{ ...fieldLabel, color: '#3b3b3b', marginTop: 24 }}>انتخاب روش های پرداخت:</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div style={{ display: 'flex', gap: 4 }}>
<FilesServiceBalanceWallet />
<span style={{ fontSize: 14, color: '#F97316', marginBottom: 8, fontWeight: 600 }}>موجودی کیف پول:</span>
</div>
<span className="dark:text-[#D7D8ED]" style={{ fontSize: 14, fontWeight: 600, color: '#525252', marginBottom: 4 }}>{formatRial(walletBalance)}</span>
</div>
{/* آکاردئون چهار روش — باز شدن هر روش: مبلغ + ثبت پرداخت */}
<div style={{ marginTop: 8 }}>
{METHODS.map((m) => {
const open = expanded === m.key;
return (
<div key={m.key} className="dark:border-[#35343D]" style={{ borderBottom: '1px solid #e0e0e0' }}>
<button
type="button"
aria-expanded={open}
onClick={() => { setExpanded(open ? null : m.key); setAmount(''); }}
style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', width: '100%', padding: '14px 4px', background: 'transparent', border: 'none', cursor: 'pointer' }}
>
<span className="dark:text-[#D7D8ED]" style={{ fontSize: 14, fontWeight: 600, color: '#111827' }}>{m.label}</span>
<ChevronDownIcon style={{ width: 16, color: '#6B7280', transform: open ? 'rotate(180deg)' : undefined, transition: 'transform .15s' }} />
</button>
{open && (
<div style={{ display: 'flex', gap: 8, padding: '0 4px 14px' }}>
<input
type="number"
min={1}
dir="ltr"
className="input"
placeholder="مبلغ (تومان)"
value={amount}
onChange={(e) => setAmount(e.target.value)}
style={{ flex: 1, height: 40 }}
/>
<button
type="button"
onClick={() => submitPayment(m.key)}
disabled={payMut.isPending || !amount}
style={{ ...primaryBtn, height: 40, padding: '0 20px', borderRadius: 8 }}
>
ثبت پرداخت
</button>
</div>
)}
</div>
);
})}
</div>
{/* پرداخت شده‌ها — باکس خط‌چین tauri */}
<div className="dark:border-[#404040] dark:bg-[#222433]" style={{ border: '1px dashed #C7C9F4', borderRadius: 8, padding: 16, marginTop: 16, background: '#fff' }}>
<span className="dark:text-[#6A6AD9]" style={{ fontSize: 16, fontWeight: 500, color: '#636bd4', display: 'block', marginBottom: 16 }}>پرداخت شده ها:</span>
{payments.length === 0 ? (
<span className="dark:text-[#A1A1A1]" style={{ fontSize: 13, color: '#6B7280' }}>پرداختی ثبت نشده است</span>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{payments.map((p) => (
<div key={p.uuid} className="dark:border-[#404040]" style={{ display: 'flex', alignItems: 'center', gap: 32, padding: '4px 0', borderBottom: '1px solid #e0e0e0' }}>
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span className="dark:bg-[#6A6AD9]" style={{ width: 8, height: 8, borderRadius: '50%', background: '#636bd4' }} />
<span className="dark:text-[#D7D8ED]" style={{ fontSize: 14, color: '#111827' }}>{METHOD_LABELS[p.method] ?? p.method}</span>
</span>
<span className="dark:text-[#D7D8ED]" style={{ flex: 1, textAlign: 'left', fontSize: 14, color: '#111827' }}>مبلغ : {formatRial(p.amount_rials)}</span>
</div>
))}
</div>
)}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 8, marginTop: 16 }}>
<span className="dark:text-[#D7D8ED]" style={{ fontSize: 14, fontWeight: 500, color: '#111827' }}>مبلغ باقیمانده :</span>
<span className="dark:text-[#FF5252]" style={{ fontSize: 14, fontWeight: 600, color: '#d32f2f' }}>{formatRial(debt)}</span>
</div>
</div>
{/* ACTIONS */}
<div style={{ display: 'flex', gap: 8, marginTop: 16, width: '100%', justifyContent: 'flex-end' }}>
<div style={{ width: '50%', display: 'flex', gap: 4 }}>
<button type="button" style={{ ...ghostBtn, flex: 1 }} onClick={onCancel}>انصراف</button>
<button type="button" style={{ ...primaryBtn, flex: 1 }} onClick={onContinue}>ثبت و ادامه</button>
</div>
</div>
</>
);
}
+191
View File
@@ -0,0 +1,191 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { screen, fireEvent, waitFor } from '@testing-library/react';
import { Routes, Route } from 'react-router-dom';
import { renderWithProviders } from '../test/utils';
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
vi.mock('../lib/api', () => ({
api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() },
ApiError: class extends Error {},
}));
import { api } from '../lib/api';
import { formatRial } from '../lib/utils';
import NewSessionPage from './NewSessionPage';
const get = api.get as ReturnType<typeof vi.fn>;
const post = api.post as ReturnType<typeof vi.fn>;
const record = (profileOver: object = {}) => ({
uuid: 'r1',
user_name: 'ساغر صابری',
profile: { full_name: 'ساغر صابری', basic_insurance_id: null, supplementary_insurance_id: null, ...profileOver },
});
function mockGets(over: Partial<Record<string, unknown>> = {}) {
get.mockImplementation((url: string) => {
if (url === '/api/v1/patient/r1') return Promise.resolve({ success: true, data: over.record ?? record() });
if (url === '/api/v1/service-sections') {
return Promise.resolve({ success: true, data: over.sections ?? [{ uuid: 'sec1', name: 'بخش زیبایی' }] });
}
if (url === '/api/v1/service-items/sec1') {
return Promise.resolve({
success: true,
data: over.items ?? [
{ uuid: 'svc1', name: 'کاشت مو', price_rials: 2_400_000, active: true, insurance_covered: false, staff: null },
],
});
}
if (url === '/api/v1/staff') {
return Promise.resolve({ success: true, data: over.staff ?? [{ uuid: 'st1', full_name: 'علی رضایی', active: true }] });
}
if (url === '/api/v1/inventory-items') {
return Promise.resolve({
success: true,
data: { items: over.inventory ?? [{ uuid: 'inv1', name: 'عینک', unit: 'عدد', price: 1_200_000, stock: 10, status: 'in_stock' }], stats: {} },
});
}
if (url === '/api/v1/inventory-packages') {
return Promise.resolve({ success: true, data: over.packages ?? [{ uuid: 'pkg1', title: 'پکیج شماره یک', total: 2_400_000, available: true }] });
}
if (url === '/api/v1/billing/tenant-insurances') {
return Promise.resolve({ success: true, data: { data: over.contracts ?? [] } });
}
if (url === '/api/v1/insurance-pricing') {
return Promise.resolve({ success: true, data: { free_visit_price_rials: 500_000 } });
}
if (url === '/api/v1/patient/r1/sessions') {
return Promise.resolve({ success: true, data: over.sessions ?? [] });
}
if (url === '/api/v1/patient/r1/wallet') {
return Promise.resolve({ success: true, data: { balance_rials: 300_000, recent_transactions: [] } });
}
return Promise.resolve({ success: true, data: [] });
});
}
function renderPage() {
return renderWithProviders(
<Routes>
<Route path="/admin/patients/:recordUuid/session/new" element={<NewSessionPage />} />
</Routes>,
{ route: '/admin/patients/r1/session/new' },
);
}
/** انتخاب گزینه از SearchableSelect (react-select) با inputId */
async function pickOption(inputId: string, option: string) {
const input = document.getElementById(inputId) as HTMLInputElement;
fireEvent.focus(input);
fireEvent.keyDown(input, { key: 'ArrowDown' });
fireEvent.click(await screen.findByText(option));
}
beforeEach(() => {
get.mockReset();
post.mockReset();
});
describe('NewSessionPage (ویزارد ثبت مراجعه — پورت tauri create-service)', () => {
it('renders the 3-step stepper and step-1 fields (tauri Step1Details)', async () => {
mockGets();
renderPage();
// استپر سه گام («ایجاد سرویس» هم برچسب گام است هم دکمه ثبت)
expect((await screen.findAllByText('ایجاد سرویس')).length).toBeGreaterThanOrEqual(2);
expect(screen.getByText('پرداخت')).toBeInTheDocument();
expect(screen.getByText('جزییات')).toBeInTheDocument();
// فیلدهای گام ۱
expect(screen.getByText('تاریخ پذیرش')).toBeInTheDocument();
expect(screen.getByText('ساعت پذیرش')).toBeInTheDocument();
expect(screen.getByText('انتخاب بخش')).toBeInTheDocument();
expect(screen.getByText('انتخاب سرویس')).toBeInTheDocument();
expect(screen.getByText('پرسنل')).toBeInTheDocument();
expect(screen.getByText('انتخاب کالای مصرفی')).toBeInTheDocument();
expect(screen.getByText('انتخاب پکیج')).toBeInTheDocument();
// پذیرش‌کننده + breadcrumb بیمار
expect(screen.getByText(/پذیرش کننده:/)).toBeInTheDocument();
expect((await screen.findAllByText('ساغر صابری')).length).toBeGreaterThan(0);
// بدون سرویس بیمه‌دار و بدون بیمه‌ی پروفایل → بلوک بیمه پنهان
expect(screen.queryByText('بیمه پایه')).not.toBeInTheDocument();
});
it('shows the insurance block when patient profile has insurance', async () => {
mockGets({ record: record({ basic_insurance_id: 7 }) });
renderPage();
expect(await screen.findByText('بیمه پایه')).toBeInTheDocument();
expect(screen.getByText('بیمه تکمیلی')).toBeInTheDocument();
});
it('creates the session with session_at, consumables, package and staff then moves to payment step', async () => {
mockGets({
sessions: [{
uuid: 's-new', services: [], visit_price_rials: 500_000, final_price_rials: 1_700_000,
patient_debt_rials: 1_700_000, discount_rials: 0, payments: [], is_paid: false,
}],
});
post.mockResolvedValue({ success: true, data: { uuid: 's-new' } });
renderPage();
await screen.findByText('انتخاب بخش');
// بخش → سرویس → افزودن
await pickOption('section-select', 'بخش زیبایی');
await waitFor(() => expect(get).toHaveBeenCalledWith('/api/v1/service-items/sec1'));
await pickOption('service-select', 'کاشت مو');
fireEvent.click(screen.getByLabelText('افزودن سرویس'));
expect(screen.getByText('خدمات انتخاب شده')).toBeInTheDocument();
// کالای مصرفی: عینک
await pickOption('consumable-select', 'عینک');
fireEvent.click(screen.getByLabelText('افزودن کالا'));
expect(screen.getByText(`مجموع قیمت کالاها: ${formatRial(1_200_000)}`)).toBeInTheDocument();
// ثبت
fireEvent.click(screen.getByText('ایجاد سرویس', { selector: 'button' }));
await waitFor(() => expect(post).toHaveBeenCalledTimes(1));
const [url, body] = post.mock.calls[0] as [string, any];
expect(url).toBe('/api/v1/patient/r1/session');
expect(typeof body.session_at).toBe('number');
expect(body.payment_method).toBe('pending');
expect(body.services).toEqual([{ service_item_uuid: 'svc1', quantity: 1 }]);
expect(body.consumables).toEqual([{ inventory_item_uuid: 'inv1', quantity: 1 }]);
// بدون بیمه → درصدها صفر و id ارسال نمی‌شود
expect(body.base_insurance_discount_percent).toBe(0);
expect(body.insurance_base_id).toBeUndefined();
// ورود به گام پرداخت با دیتای واقعی session ساخته‌شده
expect(await screen.findByText('هزینه سرویس:')).toBeInTheDocument();
expect(screen.getByText('موجودی کیف پول:')).toBeInTheDocument();
expect(screen.getAllByText(formatRial(1_700_000)).length).toBeGreaterThan(0);
});
it('increments consumable quantity via the counter and reflects the total', async () => {
mockGets();
renderPage();
await screen.findByText('انتخاب کالای مصرفی');
await pickOption('consumable-select', 'عینک');
fireEvent.click(screen.getByLabelText('افزودن کالا'));
fireEvent.click(screen.getByLabelText('افزایش'));
expect(screen.getByText(`مجموع قیمت کالاها: ${formatRial(2_400_000)}`)).toBeInTheDocument();
// کاهش تا صفر → حذف
fireEvent.click(screen.getByLabelText('کاهش'));
fireEvent.click(screen.getByLabelText('کاهش'));
expect(screen.getByText('کالایی انتخاب نشده است')).toBeInTheDocument();
});
it('renders empty option lists without crashing (بدون بخش/کالا/پکیج)', async () => {
mockGets({ sections: [], inventory: [], packages: [], staff: [] });
renderPage();
expect(await screen.findByText('انتخاب بخش')).toBeInTheDocument();
expect(screen.getByText('کالایی انتخاب نشده است')).toBeInTheDocument();
// مجموع صفر
expect(screen.getByText(`مجموع قیمت کالاها: ${formatRial(0)}`)).toBeInTheDocument();
});
});
+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>
);
}
+13 -261
View File
@@ -1,52 +1,26 @@
import { useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { ChevronDownIcon } from '@heroicons/react/24/outline';
import { toast } from 'sonner';
import { useQuery } from '@tanstack/react-query';
import { api } from '../lib/api';
import type { ApiResponse } from '../lib/api';
import type { PatientRecord } from '../types';
import { formatRial } from '../lib/utils';
import type { SessionCardData } from '../components/SessionServiceCard';
import SessionStepper from '../components/SessionStepper';
import SearchableSelect from '../components/ui/SearchableSelect';
import PersianDateInput from '../components/ui/PersianDateInput';
import {
ArrowLeftPH, ArrowLeftD, CloseModalD, Step2PaymentCard,
FilesServiceBalanceWallet, TrashRed,
} from '../components/icons/FilesServiceIcons';
/** روش‌های پرداخت — همان چهار گزینه‌ی آکاردئون tauri Step2Payment. */
const METHODS: { key: string; label: string }[] = [
{ key: 'wallet', label: 'پرداخت از طریق کیف پول' },
{ key: 'pos', label: 'پرداخت از طریق کارت خوان' },
{ key: 'cash', label: 'پرداخت نقدی' },
{ key: 'card', label: 'کارت به کارت' },
];
const METHOD_LABELS: Record<string, string> = {
wallet: 'پرداخت از کیف پول', pos: 'پرداخت کارتخوان', cash: 'پرداخت نقدی', card: 'کارت به کارت',
};
const todayISO = () => new Date().toISOString().slice(0, 10);
/** YYYY-MM-DD → unix (ظهر همان روز تا با هر timezone یک روز بماند) */
const isoToUnix = (iso: string) => Math.floor(new Date(`${iso}T12:00:00`).getTime() / 1000);
import PaymentStep from '../components/session/PaymentStep';
import DetailsStep from '../components/session/DetailsStep';
import { ArrowLeftPH, ArrowLeftD, CloseModalD } from '../components/icons/FilesServiceIcons';
/**
* تکمیل پرداخت مراجعه — پورت صفحه‌ی tauri /files/create-service در حالت payment:
* استپر دوگامی «پرداخت ← جزییات» با تخفیف تسویه، پرداخت چندتکه و تاریخ پرداخت.
* بدنه‌ی گام‌ها در components/session/{PaymentStep,DetailsStep} مشترک با NewSessionPage است.
*/
export default function SessionPaymentPage() {
const { recordUuid = '', sessionUuid = '' } = useParams();
const nav = useNavigate();
const qc = useQueryClient();
const steps = ['پرداخت', 'جزییات'];
const [activeStep, setActiveStep] = useState(0);
const [discountType, setDiscountType] = useState('');
const [discountValue, setDiscountValue] = useState('');
const [paymentDate, setPaymentDate] = useState(todayISO());
const [expanded, setExpanded] = useState<string | null>(null);
const [amount, setAmount] = useState('');
const recordQ = useQuery<ApiResponse<PatientRecord>>({
queryKey: ['patient-detail', recordUuid],
@@ -70,43 +44,6 @@ export default function SessionPaymentPage() {
});
const walletBalance = (walletQ.data?.data as any)?.balance_rials ?? 0;
const invalidate = () => {
qc.invalidateQueries({ queryKey: ['patient-sessions', recordUuid] });
qc.invalidateQueries({ queryKey: ['patient-wallet', recordUuid] });
};
const discountMut = useMutation({
mutationFn: (body: object) => api.patch(`/api/v1/session/${sessionUuid}`, body),
onSuccess: () => { invalidate(); toast.success('تخفیف به‌روزرسانی شد'); },
onError: (e: Error) => toast.error(e.message),
});
const payMut = useMutation({
mutationFn: (body: object) => api.post(`/api/v1/session/${sessionUuid}/payments`, body),
onSuccess: () => { invalidate(); setAmount(''); toast.success('پرداخت ثبت شد'); },
onError: (e: Error) => toast.error(e.message),
});
const applyDiscount = () => {
if (!discountType || !discountValue) return;
discountMut.mutate({ discount_type: discountType, discount_value: Number(discountValue) });
};
const removeDiscount = () => {
setDiscountType(''); setDiscountValue('');
discountMut.mutate({ discount_type: null });
};
const submitPayment = (method: string) => {
if (!amount) return;
payMut.mutate({ method, amount_rials: Number(amount), paid_at: isoToUnix(paymentDate) });
};
const finalPrice = session?.final_price_rials ?? 0;
const discountRials = session?.discount_rials ?? 0;
const debt = session?.patient_debt_rials ?? 0;
const payments = session?.payments ?? [];
const serviceNames = (session?.services ?? []).map((s) => s.service_name || s.name).filter(Boolean) as string[];
if ((session?.visit_price_rials ?? 0) > 0) serviceNames.unshift('ویزیت');
const finish = () => nav(`/admin/patients/${recordUuid}?tab=services`);
if (sessionsQ.isLoading) {
@@ -116,10 +53,6 @@ export default function SessionPaymentPage() {
return <div style={{ padding: '40px 0', textAlign: 'center', color: 'var(--text-3)', fontSize: 14 }}>مراجعه یافت نشد</div>;
}
const fieldLabel: React.CSSProperties = { fontSize: 14, color: '#6B7280', marginBottom: 8, 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' };
return (
<div className="fade-in" style={{ width: '100%' }}>
{/* breadcrumb — tauri AddService header */}
@@ -157,196 +90,15 @@ export default function SessionPaymentPage() {
<SessionStepper activeStep={activeStep} steps={steps} />
{activeStep === 0 ? (
<>
{/* هزینه سرویس */}
<div style={{ display: 'flex', alignItems: 'center', gap: 12, margin: '12px 0 16px' }}>
<Step2PaymentCard />
<span style={{ fontSize: 14, color: '#F97316', fontWeight: 700 }}>هزینه سرویس:</span>
<span className="dark:text-[#D7D8ED]" style={{ fontSize: 14, fontWeight: 700, color: '#525252' }}>{formatRial(finalPrice)}</span>
</div>
{/* تخفیف — port of tauri DiscountInput (نوع + مقدار + ثبت) */}
<span style={fieldLabel}>تخفیف:</span>
<div style={{ display: 'flex', alignItems: 'stretch', gap: 8 }}>
<div className="bg-white dark:bg-[#222433] dark:border-[#35343D]" style={{ flex: 1, minWidth: 0, display: 'flex', alignItems: 'center', border: '1px solid #e0e0e0', borderRadius: 8, marginBottom: 16 }}>
<div style={{ minWidth: 150, borderLeft: '1px solid #e0e0e0', padding: '0 4px' }}>
<SearchableSelect
options={[{ value: 'percent', label: 'درصدی' }, { value: 'fixed', label: 'مبلغ ثابت' }]}
value={discountType}
onChange={(v) => setDiscountType(v ? String(v) : '')}
placeholder="مبلغ تخفیف"
/>
</div>
<input
type="number"
min={0}
dir="ltr"
placeholder="مقدار تخفیف را وارد نمایید"
value={discountValue}
onChange={(e) => setDiscountValue(e.target.value)}
style={{ flex: 1, minWidth: 0, height: 40, padding: '0 12px', fontSize: 13, border: 'none', outline: 'none', background: 'transparent', color: 'inherit' }}
/>
<button
type="button"
onClick={applyDiscount}
disabled={discountMut.isPending || !discountType || !discountValue}
style={{ height: 40, minWidth: 72, border: 'none', borderRadius: '0 4px 4px 0', background: '#E8EBFF', color: '#3B3F9F', fontSize: 13, fontWeight: 600, cursor: 'pointer' }}
>
ثبت
</button>
</div>
<div
onClick={removeDiscount}
style={{ display: 'flex', alignItems: 'center', gap: 4, flexShrink: 0, cursor: 'pointer', marginBottom: 16, minWidth: 120, justifyContent: 'center' }}
>
<TrashRed color="#EF4444" size={18} />
<span className="dark:text-[#A1A1A1]" style={{ fontSize: 13, color: '#6B7280' }}>حذف تخفیف</span>
</div>
</div>
<p style={{ fontSize: 13, marginBottom: 16 }}>
<span className="dark:text-[#D7D8ED]" style={{ color: '#2f2f2f' }}>مبلغ تخفیف:</span>{' '}
<span style={{ color: '#D32F2F' }}>{formatRial(discountRials)}</span>
</p>
{/* تاریخ پرداخت */}
<span className="dark:text-[#A1A1A1]" style={{ ...fieldLabel, color: '#3b3b3b' }}>تاریخ پرداخت</span>
<PersianDateInput value={paymentDate} onChange={setPaymentDate} />
{/* روش‌های پرداخت */}
<span className="dark:text-[#A1A1A1]" style={{ ...fieldLabel, color: '#3b3b3b', marginTop: 24 }}>انتخاب روش های پرداخت:</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div style={{ display: 'flex', gap: 4 }}>
<FilesServiceBalanceWallet />
<span style={{ fontSize: 14, color: '#F97316', marginBottom: 8, fontWeight: 600 }}>موجودی کیف پول:</span>
</div>
<span className="dark:text-[#D7D8ED]" style={{ fontSize: 14, fontWeight: 600, color: '#525252', marginBottom: 4 }}>{formatRial(walletBalance)}</span>
</div>
{/* آکاردئون چهار روش — باز شدن هر روش: مبلغ + ثبت پرداخت */}
<div style={{ marginTop: 8 }}>
{METHODS.map((m) => {
const open = expanded === m.key;
return (
<div key={m.key} className="dark:border-[#35343D]" style={{ borderBottom: '1px solid #e0e0e0' }}>
<button
type="button"
aria-expanded={open}
onClick={() => { setExpanded(open ? null : m.key); setAmount(''); }}
style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', width: '100%', padding: '14px 4px', background: 'transparent', border: 'none', cursor: 'pointer' }}
>
<span className="dark:text-[#D7D8ED]" style={{ fontSize: 14, fontWeight: 600, color: '#111827' }}>{m.label}</span>
<ChevronDownIcon style={{ width: 16, color: '#6B7280', transform: open ? 'rotate(180deg)' : undefined, transition: 'transform .15s' }} />
</button>
{open && (
<div style={{ display: 'flex', gap: 8, padding: '0 4px 14px' }}>
<input
type="number"
min={1}
dir="ltr"
className="input"
placeholder="مبلغ (تومان)"
value={amount}
onChange={(e) => setAmount(e.target.value)}
style={{ flex: 1, height: 40 }}
/>
<button
type="button"
onClick={() => submitPayment(m.key)}
disabled={payMut.isPending || !amount}
style={{ ...primaryBtn, height: 40, padding: '0 20px', borderRadius: 8 }}
>
ثبت پرداخت
</button>
</div>
)}
</div>
);
})}
</div>
{/* پرداخت شده‌ها — باکس خط‌چین tauri */}
<div className="dark:border-[#404040] dark:bg-[#222433]" style={{ border: '1px dashed #C7C9F4', borderRadius: 8, padding: 16, marginTop: 16, background: '#fff' }}>
<span className="dark:text-[#6A6AD9]" style={{ fontSize: 16, fontWeight: 500, color: '#636bd4', display: 'block', marginBottom: 16 }}>پرداخت شده ها:</span>
{payments.length === 0 ? (
<span className="dark:text-[#A1A1A1]" style={{ fontSize: 13, color: '#6B7280' }}>پرداختی ثبت نشده است</span>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{payments.map((p) => (
<div key={p.uuid} className="dark:border-[#404040]" style={{ display: 'flex', alignItems: 'center', gap: 32, padding: '4px 0', borderBottom: '1px solid #e0e0e0' }}>
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span className="dark:bg-[#6A6AD9]" style={{ width: 8, height: 8, borderRadius: '50%', background: '#636bd4' }} />
<span className="dark:text-[#D7D8ED]" style={{ fontSize: 14, color: '#111827' }}>{METHOD_LABELS[p.method] ?? p.method}</span>
</span>
<span className="dark:text-[#D7D8ED]" style={{ flex: 1, textAlign: 'left', fontSize: 14, color: '#111827' }}>مبلغ : {formatRial(p.amount_rials)}</span>
</div>
))}
</div>
)}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 8, marginTop: 16 }}>
<span className="dark:text-[#D7D8ED]" style={{ fontSize: 14, fontWeight: 500, color: '#111827' }}>مبلغ باقیمانده :</span>
<span className="dark:text-[#FF5252]" style={{ fontSize: 14, fontWeight: 600, color: '#d32f2f' }}>{formatRial(debt)}</span>
</div>
</div>
{/* ACTIONS */}
<div style={{ display: 'flex', gap: 8, marginTop: 16, width: '100%', justifyContent: 'flex-end' }}>
<div style={{ width: '50%', display: 'flex', gap: 4 }}>
<button type="button" style={{ ...ghostBtn, flex: 1 }} onClick={() => nav(-1)}>انصراف</button>
<button type="button" style={{ ...primaryBtn, flex: 1 }} onClick={() => setActiveStep(1)}>ثبت و ادامه</button>
</div>
</div>
</>
<PaymentStep
recordUuid={recordUuid}
session={session}
walletBalance={walletBalance}
onContinue={() => setActiveStep(1)}
onCancel={() => nav(-1)}
/>
) : (
<>
{/* گام جزییات — پورت tauri Step3Final با دیتای واقعی */}
<div dir="rtl" className="dark:border-[#404040] dark:bg-[#222433]" style={{ padding: 16, border: '1px dashed #A7A7E0' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 32, marginBottom: 16 }}>
<span className="dark:text-[#D7D8ED]" style={{ fontSize: 14, fontWeight: 500, color: '#2f2f2f' }}>
{serviceNames.length ? serviceNames.join(' - ') : 'ویزیت'}
</span>
<span className="dark:bg-[#404040]" style={{ width: 1, height: 38, background: '#E0E0E0' }} />
<span className="dark:text-[#D7D8ED]" style={{ fontSize: 14, fontWeight: 500, color: '#525252' }}>{session.doctor_name || '—'}</span>
</div>
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', gap: 72, marginBottom: 16 }}>
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span className="dark:text-[#D7D8ED]" style={{ fontSize: 14, fontWeight: 500, color: '#525252' }}>هزینه سرویس:</span>
<span className="dark:text-[#D7D8ED]" style={{ fontSize: 14, fontWeight: 500, color: '#525252' }}>{formatRial(finalPrice)}</span>
</span>
<span className="dark:bg-[#404040]" style={{ width: 1, height: 38, background: '#E0E0E0' }} />
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span className="dark:text-[#D7D8ED]" style={{ fontSize: 14, fontWeight: 500, color: '#525252' }}>تخفیف:</span>
<span className="dark:text-[#D7D8ED]" style={{ fontSize: 14, fontWeight: 500, color: '#525252' }}>{formatRial(discountRials)}</span>
</span>
</div>
<span className="dark:text-[#6A6AD9]" style={{ fontSize: 13, fontWeight: 600, color: '#5559CE', display: 'block', marginBottom: 12 }}>پرداخت شده ها:</span>
{payments.length === 0 ? (
<span className="dark:text-[#A1A1A1]" style={{ fontSize: 13, color: '#6B7280' }}>پرداختی ثبت نشده است</span>
) : payments.map((p) => (
<div key={p.uuid} className="dark:border-[#404040]" style={{ display: 'flex', alignItems: 'center', gap: 56, padding: '8px 0', borderBottom: '1px solid #EEE' }}>
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span className="dark:bg-[#6A6AD9]" style={{ width: 6, height: 6, borderRadius: '50%', background: '#5559CE', flexShrink: 0 }} />
<span className="dark:text-[#D7D8ED]" style={{ fontSize: 14, fontWeight: 600, color: '#525252' }}>{METHOD_LABELS[p.method] ?? p.method}</span>
</span>
<span className="dark:text-[#D7D8ED]" style={{ fontSize: 14, fontWeight: 600, color: '#111827' }}>مبلغ: {formatRial(p.amount_rials)}</span>
</div>
))}
<div style={{ display: 'flex', width: '100%', justifyContent: 'flex-end', alignItems: 'center' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 16 }}>
<span className="dark:text-[#D7D8ED]" style={{ fontSize: 14, fontWeight: 600, color: '#525252' }}>مبلغ باقی مانده:</span>
<span className="dark:text-[#FF5252]" style={{ fontSize: 14, fontWeight: 600, color: '#d32f2f' }}>{formatRial(debt)}</span>
</div>
</div>
</div>
<div style={{ display: 'flex', gap: 8, marginTop: 16, width: '100%', maxWidth: 320, margin: '16px auto 0' }}>
<button type="button" style={{ ...ghostBtn, flex: 1 }} onClick={() => setActiveStep(0)}>انصراف</button>
<button type="button" style={{ ...primaryBtn, flex: 1 }} onClick={finish}>صدور فاکتور</button>
</div>
</>
<DetailsStep session={session} onBack={() => setActiveStep(0)} onFinish={finish} />
)}
</div>
</div>
+12 -1
View File
@@ -411,12 +411,20 @@ Creates a new visit session for a patient record.
"insurance_supplementary_id": null,
"payment_method": "cash",
"notes": "...",
"session_at": 1760000000,
"inventory_package_uuid": null,
"services": [
{
"service_item_uuid": "...",
"staff_uuid": null,
"quantity": 2
}
],
"consumables": [
{
"inventory_item_uuid": "...",
"quantity": 2
}
]
}
```
@@ -424,11 +432,14 @@ Creates a new visit session for a patient record.
**Field notes:**
- `payment_method`: `cash` | `card` | `insurance` | `online` | `pending`
- `session_at` (اختیاری): زمان پذیرش (unix)؛ اگر نیاید `null` می‌ماند و زمان ثبت (`created_at`) مبنا است.
- `inventory_package_uuid` (اختیاری): مرجع پکیج مصرفی ([inventory](inventory.md))؛ فقط پکیج متعلق به همان tenant پذیرفته می‌شود، وگرنه بی‌صدا نادیده گرفته می‌شود. روی قیمت اثری ندارد (فقط مرجع).
- `consumables` (اختیاری): کالاهای مصرفی از انبار ([inventory](inventory.md)). `price_rials` snapshot از `InventoryItem.price`؛ `quantity` (پیش‌فرض ۱، حداقل ۱). کالاها **پوشش بیمه ندارند** و مبلغ کاملشان به `final_price_rials` (سهم بیمار) اضافه می‌شود. آیتم ناموجود یا متعلق به tenant دیگر بی‌صدا رد می‌شود (هم‌رفتار با `services`). پاسخ شامل `consumables[]` (با `line_total_rials`) و `consumables_total_rials` است.
- `services`: array of service items to attach; `price_rials` snapshot از ServiceItem؛ `quantity` (پیش‌فرض ۱) → `line_total_rials = price_rials × quantity`. هر `SessionService` در پاسخ `quantity` و `line_total_rials` دارد.
- `final_price_rials` (سهم بیمار) به این صورت محاسبه می‌شود:
- **ویزیت:** `round(visit_price × (1 - base%) × (1 - supp%))` با درصدهای انتخاب‌شده در فرم.
- **هر خدمت:** سهم بیمار با قاعده‌ی پوشش همان بیمه‌گر برای همان خدمت (`TenantServiceCoverage` از طریق `BillingCalculator`) محاسبه می‌شود؛ یعنی فقط خدمتی که بیمه‌ی انتخاب‌شده آن را پوشش می‌دهد تخفیف می‌گیرد (درصد/فرانشیز/سقف؛ مقدار نبودِ override از قرارداد ارث می‌برد). خدمتِ بدون پوشش، کامل بر عهده‌ی بیمار است.
- `final_price_rials = سهم بیمار ویزیت + Σ(سهم بیمار هر خدمت)` و `services_total_rials = Σ(price × quantity)` (قیمت کامل خدمات، بدون بیمه).
- `final_price_rials = سهم بیمار ویزیت + Σ(سهم بیمار هر خدمت) + Σ(کالاهای مصرفی)` و `services_total_rials = Σ(price × quantity)` (قیمت کامل خدمات، بدون بیمه). کالاهای مصرفی در `consumables_total_rials` جدا گزارش می‌شوند.
- این محاسبه دقیقاً همان منطقِ صورتحساب/مطالبات است؛ پیش‌نمایش پنل هم همین قاعده را سمت کلاینت آینه می‌کند.
**اتصال خودکار مطالبه‌ی بیمه:** اگر session دارای `insurance_base_id` یا `insurance_supplementary_id` باشد، پس از ثبت به‌صورت خودکار صورتحساب ساخته و نهایی می‌شود و مطالبه(های) بیمه در وضعیت `pending` ایجاد می‌گردد (پایه/مکمل، فقط برای سهم بیمه > ۰). این مطالبات در صفحه‌ی [مطالبات بیمه](billing.md) قابل پیگیری و ارسال‌اند. خطا در این مرحله ثبت session را خراب نمی‌کند (لاگ می‌شود). برای هر صورتحساب فقط یک‌بار مطالبه ساخته می‌شود.
+41
View File
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260716102537 extends AbstractMigration
{
public function getDescription(): string
{
return 'Session wizard parity: session_consumables table, patient_sessions.session_at and inventory_package_id';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE session_consumables (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, price_rials INT NOT NULL, quantity INT NOT NULL, created_at INT NOT NULL, session_id INT NOT NULL, inventory_item_id INT NOT NULL, UNIQUE INDEX UNIQ_6776C347D17F50A6 (uuid), INDEX IDX_6776C347613FECDF (session_id), INDEX IDX_6776C347536BF4A2 (inventory_item_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
$this->addSql('ALTER TABLE session_consumables ADD CONSTRAINT FK_6776C347613FECDF FOREIGN KEY (session_id) REFERENCES patient_sessions (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE session_consumables ADD CONSTRAINT FK_6776C347536BF4A2 FOREIGN KEY (inventory_item_id) REFERENCES inventory_items (id) ON DELETE RESTRICT');
$this->addSql('ALTER TABLE patient_sessions ADD session_at INT DEFAULT NULL, ADD inventory_package_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE patient_sessions ADD CONSTRAINT FK_ADE5C5B6B7A2FE7C FOREIGN KEY (inventory_package_id) REFERENCES inventory_packages (id) ON DELETE SET NULL');
$this->addSql('CREATE INDEX IDX_ADE5C5B6B7A2FE7C ON patient_sessions (inventory_package_id)');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE session_consumables DROP FOREIGN KEY FK_6776C347613FECDF');
$this->addSql('ALTER TABLE session_consumables DROP FOREIGN KEY FK_6776C347536BF4A2');
$this->addSql('DROP TABLE session_consumables');
$this->addSql('ALTER TABLE patient_sessions DROP FOREIGN KEY FK_ADE5C5B6B7A2FE7C');
$this->addSql('DROP INDEX IDX_ADE5C5B6B7A2FE7C ON patient_sessions');
$this->addSql('ALTER TABLE patient_sessions DROP session_at, DROP inventory_package_id');
}
}
+45
View File
@@ -3,6 +3,7 @@
namespace App\Patient\Entity;
use App\Appointment\Entity\Appointment;
use App\Inventory\Entity\InventoryPackage;
use App\Patient\Repository\PatientSessionRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
@@ -30,6 +31,15 @@ class PatientSession
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
private ?Appointment $appointment = null;
/** زمان پذیرش (unix)؛ اگر ست نشود همان زمان ثبت است */
#[ORM\Column(name: 'session_at', type: 'integer', nullable: true)]
private ?int $sessionAt = null;
/** پکیج مصرفی انتخاب‌شده برای این مراجعه (اختیاری، فقط مرجع) */
#[ORM\ManyToOne(targetEntity: InventoryPackage::class)]
#[ORM\JoinColumn(name: 'inventory_package_id', nullable: true, onDelete: 'SET NULL')]
private ?InventoryPackage $inventoryPackage = null;
#[ORM\Column(name: 'insurance_base_id', type: 'integer', nullable: true)]
private ?int $insuranceBaseId = null;
@@ -85,6 +95,9 @@ class PatientSession
#[ORM\OneToMany(targetEntity: SessionPayment::class, mappedBy: 'session', cascade: ['remove'])]
private Collection $payments;
#[ORM\OneToMany(targetEntity: SessionConsumable::class, mappedBy: 'session', cascade: ['remove'])]
private Collection $consumables;
public function __construct(PatientRecord $record, ?Appointment $appointment = null)
{
$this->uuid = Uuid::v4()->toRfc4122();
@@ -94,6 +107,7 @@ class PatientSession
$this->updatedAt = time();
$this->services = new ArrayCollection();
$this->payments = new ArrayCollection();
$this->consumables = new ArrayCollection();
}
public function getId(): ?int { return $this->id; }
@@ -146,6 +160,27 @@ class PatientSession
return max(0, $this->finalPriceRials - $this->discountRials - $this->getPaidTotalRials());
}
public function getSessionAt(): ?int { return $this->sessionAt; }
public function getInventoryPackage(): ?InventoryPackage { return $this->inventoryPackage; }
public function getConsumables(): Collection { return $this->consumables; }
public function addConsumable(SessionConsumable $consumable): self
{
if (!$this->consumables->contains($consumable)) {
$this->consumables->add($consumable);
}
return $this;
}
/** مجموع قیمت کالاهای مصرفی این مراجعه (ریال) */
public function getConsumablesTotalRials(): int
{
return array_sum(array_map(
fn(SessionConsumable $c) => $c->getLineTotalRials(),
$this->consumables->toArray(),
));
}
public function getNotes(): ?string { return $this->notes; }
public function getCreatedAt(): int { return $this->createdAt; }
public function getUpdatedAt(): int { return $this->updatedAt; }
@@ -166,6 +201,8 @@ class PatientSession
$this->updatedAt = time();
return $this;
}
public function setSessionAt(?int $v): self { $this->sessionAt = $v; $this->updatedAt = time(); return $this; }
public function setInventoryPackage(?InventoryPackage $v): self { $this->inventoryPackage = $v; $this->updatedAt = time(); return $this; }
public function setPaidAt(?int $v): self { $this->paidAt = $v; $this->updatedAt = time(); return $this; }
public function setNotes(?string $v): self { $this->notes = $v; $this->updatedAt = time(); return $this; }
@@ -199,6 +236,14 @@ class PatientSession
fn(SessionService $s) => $s->toArray(),
$this->services->toArray()
),
'session_at' => $this->sessionAt,
'inventory_package_uuid' => $this->inventoryPackage?->getUuid(),
'inventory_package_title' => $this->inventoryPackage?->getTitle(),
'consumables' => array_map(
fn(SessionConsumable $c) => $c->toArray(),
$this->consumables->toArray()
),
'consumables_total_rials' => $this->getConsumablesTotalRials(),
'notes' => $this->notes,
'created_at' => $this->createdAt,
'updated_at' => $this->updatedAt,
+75
View File
@@ -0,0 +1,75 @@
<?php
namespace App\Patient\Entity;
use App\Inventory\Entity\InventoryItem;
use App\Patient\Repository\SessionConsumableRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
/**
* A consumable (inventory item) used during a patient session. Price is a
* snapshot of the item's unit price at creation time, mirroring {@see SessionService}.
*/
#[ORM\Entity(repositoryClass: SessionConsumableRepository::class)]
#[ORM\Table(name: 'session_consumables')]
class SessionConsumable
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 36, unique: true)]
private string $uuid;
#[ORM\ManyToOne(targetEntity: PatientSession::class, inversedBy: 'consumables')]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
private PatientSession $session;
#[ORM\ManyToOne(targetEntity: InventoryItem::class)]
#[ORM\JoinColumn(name: 'inventory_item_id', nullable: false, onDelete: 'RESTRICT')]
private InventoryItem $item;
#[ORM\Column(name: 'price_rials', type: 'integer')]
private int $priceRials;
#[ORM\Column(type: 'integer')]
private int $quantity = 1;
#[ORM\Column(name: 'created_at', type: 'integer')]
private int $createdAt;
public function __construct(PatientSession $session, InventoryItem $item, int $quantity = 1)
{
$this->uuid = Uuid::v4()->toRfc4122();
$this->session = $session;
$this->item = $item;
$this->priceRials = $item->getPrice();
$this->quantity = max(1, $quantity);
$this->createdAt = time();
}
public function getId(): ?int { return $this->id; }
public function getUuid(): string { return $this->uuid; }
public function getSession(): PatientSession { return $this->session; }
public function getItem(): InventoryItem { return $this->item; }
public function getPriceRials(): int { return $this->priceRials; }
public function getQuantity(): int { return $this->quantity; }
public function getLineTotalRials(): int { return $this->priceRials * $this->quantity; }
public function getCreatedAt(): int { return $this->createdAt; }
public function toArray(): array
{
return [
'uuid' => $this->uuid,
'inventory_item_uuid' => $this->item->getUuid(),
'item_name' => $this->item->getName(),
'unit' => $this->item->getUnit(),
'price_rials' => $this->priceRials,
'quantity' => $this->quantity,
'line_total_rials' => $this->getLineTotalRials(),
'created_at' => $this->createdAt,
];
}
}
@@ -0,0 +1,21 @@
<?php
namespace App\Patient\Repository;
use App\Patient\Entity\SessionConsumable;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class SessionConsumableRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, SessionConsumable::class);
}
public function save(SessionConsumable $consumable): void
{
$this->getEntityManager()->persist($consumable);
$this->getEntityManager()->flush();
}
}
+43 -1
View File
@@ -9,14 +9,18 @@ use App\Billing\ValueObject\Money;
use App\ClinicService\Repository\ServiceItemRepository;
use App\Clinic\Repository\ClinicRepository;
use App\Insurance\Service\TenantInsuranceService;
use App\Inventory\Repository\InventoryItemRepository;
use App\Inventory\Repository\InventoryPackageRepository;
use App\Doctor\Repository\DoctorAddressRepository;
use App\Auth\Entity\User;
use App\Patient\Entity\PatientRecord;
use App\Patient\Entity\PatientSession;
use App\Patient\Entity\SessionConsumable;
use App\Patient\Entity\SessionPayment;
use App\Patient\Entity\SessionService;
use App\Patient\Repository\PatientRecordRepository;
use App\Patient\Repository\PatientSessionRepository;
use App\Patient\Repository\SessionConsumableRepository;
use App\Patient\Repository\SessionPaymentRepository;
use App\Patient\Repository\SessionServiceRepository;
use App\Settlement\Service\WalletService;
@@ -32,7 +36,10 @@ class PatientService
private readonly PatientSessionRepository $sessionRepo,
private readonly SessionServiceRepository $sessionServiceRepo,
private readonly SessionPaymentRepository $sessionPaymentRepo,
private readonly SessionConsumableRepository $sessionConsumableRepo,
private readonly ServiceItemRepository $serviceItemRepo,
private readonly InventoryItemRepository $inventoryItemRepo,
private readonly InventoryPackageRepository $inventoryPackageRepo,
private readonly ClinicStaffRepository $staffRepo,
private readonly UserRepository $userRepo,
private readonly SubscriptionService $subscriptionService,
@@ -145,6 +152,19 @@ class PatientService
$session->setPaymentMethod($data['payment_method'] ?? 'pending');
$session->setNotes($data['notes'] ?? null);
// زمان پذیرش (اختیاری — پیش‌فرض زمان ثبت)
if (!empty($data['session_at'])) {
$session->setSessionAt((int) $data['session_at']);
}
// پکیج مصرفی (اختیاری، فقط مرجع؛ باید متعلق به همین tenant باشد)
if (!empty($data['inventory_package_uuid'])) {
$package = $this->inventoryPackageRepo->findByUuid((string) $data['inventory_package_uuid']);
if ($package !== null && $package->getEntityType() === $entityType && $package->getEntityId() === $entityId) {
$session->setInventoryPackage($package);
}
}
// جمع‌آوری service items (با احتساب تعداد)
$serviceItemsData = [];
foreach (($data['services'] ?? []) as $svc) {
@@ -167,10 +187,32 @@ class PatientService
);
$session->setServicesTotalRials($priceCalc['services_total_rials']);
$session->setFinalPriceRials($priceCalc['final_price_rials']);
// کالاهای مصرفی: بدون پوشش بیمه — تمام مبلغ سهم بیمار است.
// فقط آیتم‌های متعلق به همین tenant پذیرفته می‌شوند؛ بقیه بی‌صدا رد می‌شوند (هم‌رفتار با services).
$consumableRows = [];
$consumablesTotal = 0;
foreach (($data['consumables'] ?? []) as $row) {
$item = $this->inventoryItemRepo->findByUuid((string) ($row['inventory_item_uuid'] ?? ''));
if ($item === null || $item->getEntityType() !== $entityType || $item->getEntityId() !== $entityId) {
continue;
}
$qty = max(1, (int) ($row['quantity'] ?? 1));
$consumableRows[] = ['item' => $item, 'quantity' => $qty];
$consumablesTotal += $item->getPrice() * $qty;
}
$session->setFinalPriceRials($priceCalc['final_price_rials'] + $consumablesTotal);
$this->sessionRepo->save($session);
// ثبت session consumables
foreach ($consumableRows as $row) {
$sc = new SessionConsumable($session, $row['item'], $row['quantity']);
$this->sessionConsumableRepo->save($sc);
$session->addConsumable($sc);
}
// ثبت session services
foreach (($data['services'] ?? []) as $svc) {
$item = $this->serviceItemRepo->findByUuid($svc['service_item_uuid'] ?? '');
+175
View File
@@ -0,0 +1,175 @@
<?php
namespace App\Tests\Patient;
use App\Doctor\Entity\Doctor;
use App\Inventory\Entity\InventoryItem;
use App\Inventory\Entity\InventoryPackage;
use App\Patient\Entity\PatientRecord;
use App\Tests\ApiTestCase;
/**
* ثبت مراجعه با کالای مصرفی/پکیج/زمان پذیرش (ویزارد ثبت مراجعه):
* POST /patient/{uuid}/session با consumables (سهم بیمار کامل، بدون بیمه)،
* inventory_package_uuid (مرجع) و session_at. آیتم‌های tenant دیگر بی‌صدا رد می‌شوند.
*/
class SessionConsumableTest extends ApiTestCase
{
/** @return array{0: \App\Auth\Entity\User, 1: Doctor, 2: PatientRecord} */
private function recordFor(): array
{
$owner = $this->createUser(['ROLE_DOCTOR']);
$doctor = new Doctor($owner, 'دکتر');
$this->em->persist($doctor);
$this->em->flush();
$patient = $this->createUser(['ROLE_USER']);
$record = new PatientRecord('doctor', $doctor->getId(), $patient, 'doctor', $doctor->getId());
$this->em->persist($record);
$this->em->flush();
return [$owner, $doctor, $record];
}
private function itemFor(Doctor $doctor, string $name, int $price): InventoryItem
{
$item = new InventoryItem('doctor', $doctor->getId(), $name);
$item->setPrice($price)->setStock(100);
$this->em->persist($item);
$this->em->flush();
return $item;
}
// ── موفق ────────────────────────────────────────────────────────────────
public function testCreateSessionWithConsumablesPackageAndSessionAt(): void
{
[$owner, $doctor, $record] = $this->recordFor();
$glasses = $this->itemFor($doctor, 'عینک', 1_200_000);
$pencil = $this->itemFor($doctor, 'مداد سفید', 300_000);
$package = new InventoryPackage('doctor', $doctor->getId(), 'پکیج زیبایی');
$this->em->persist($package);
$this->em->flush();
$res = $this->authJson('POST', '/api/v1/patient/' . $record->getUuid() . '/session', $owner, [
'visit_price_rials' => 500_000,
'session_at' => 1_760_000_000,
'inventory_package_uuid' => $package->getUuid(),
'consumables' => [
['inventory_item_uuid' => $glasses->getUuid(), 'quantity' => 2],
['inventory_item_uuid' => $pencil->getUuid()],
],
]);
self::assertSame(201, $this->responseCode());
self::assertSame(1_760_000_000, $res['data']['session_at']);
self::assertSame($package->getUuid(), $res['data']['inventory_package_uuid']);
self::assertSame('پکیج زیبایی', $res['data']['inventory_package_title']);
self::assertCount(2, $res['data']['consumables']);
self::assertSame(2_700_000, $res['data']['consumables_total_rials']); // 2×1٬200٬000 + 300٬000
$byName = array_column($res['data']['consumables'], null, 'item_name');
self::assertSame(2, $byName['عینک']['quantity']);
self::assertSame(2_400_000, $byName['عینک']['line_total_rials']);
self::assertSame(1, $byName['مداد سفید']['quantity']);
// کالاها بدون پوشش بیمه → کامل روی سهم بیمار
self::assertSame(3_200_000, $res['data']['final_price_rials']); // ویزیت 500٬000 + کالاها 2٬700٬000
}
public function testConsumablesAppearInSessionsList(): void
{
[$owner, $doctor, $record] = $this->recordFor();
$item = $this->itemFor($doctor, 'سرنگ', 50_000);
$this->authJson('POST', '/api/v1/patient/' . $record->getUuid() . '/session', $owner, [
'visit_price_rials' => 0,
'consumables' => [['inventory_item_uuid' => $item->getUuid(), 'quantity' => 3]],
]);
self::assertSame(201, $this->responseCode());
$sessions = $this->authJson('GET', '/api/v1/patient/' . $record->getUuid() . '/sessions', $owner);
self::assertCount(1, $sessions['data'][0]['consumables']);
self::assertSame(150_000, $sessions['data'][0]['consumables_total_rials']);
self::assertSame(150_000, $sessions['data'][0]['patient_debt_rials']);
}
// ── خطا ─────────────────────────────────────────────────────────────────
public function testForeignTenantConsumableAndPackageSilentlySkipped(): void
{
[$owner, , $record] = $this->recordFor();
// tenant دیگر
$otherOwner = $this->createUser(['ROLE_DOCTOR']);
$otherDoctor = new Doctor($otherOwner, 'دکتر دیگر');
$this->em->persist($otherDoctor);
$this->em->flush();
$foreignItem = $this->itemFor($otherDoctor, 'آمپول', 900_000);
$foreignPackage = new InventoryPackage('doctor', $otherDoctor->getId(), 'پکیج دیگری');
$this->em->persist($foreignPackage);
$this->em->flush();
$res = $this->authJson('POST', '/api/v1/patient/' . $record->getUuid() . '/session', $owner, [
'visit_price_rials' => 400_000,
'inventory_package_uuid' => $foreignPackage->getUuid(),
'consumables' => [['inventory_item_uuid' => $foreignItem->getUuid(), 'quantity' => 5]],
]);
self::assertSame(201, $this->responseCode());
self::assertCount(0, $res['data']['consumables']);
self::assertSame(0, $res['data']['consumables_total_rials']);
self::assertNull($res['data']['inventory_package_uuid']);
self::assertSame(400_000, $res['data']['final_price_rials']);
}
public function testUnknownConsumableUuidSkipped(): void
{
[$owner, , $record] = $this->recordFor();
$res = $this->authJson('POST', '/api/v1/patient/' . $record->getUuid() . '/session', $owner, [
'visit_price_rials' => 200_000,
'consumables' => [['inventory_item_uuid' => '00000000-0000-0000-0000-000000000000']],
]);
self::assertSame(201, $this->responseCode());
self::assertCount(0, $res['data']['consumables']);
self::assertSame(200_000, $res['data']['final_price_rials']);
}
// ── مرزی ────────────────────────────────────────────────────────────────
public function testEmptyConsumablesAndNoSessionAtDefaults(): void
{
[$owner, , $record] = $this->recordFor();
$res = $this->authJson('POST', '/api/v1/patient/' . $record->getUuid() . '/session', $owner, [
'visit_price_rials' => 100_000,
'consumables' => [],
]);
self::assertSame(201, $this->responseCode());
self::assertSame([], $res['data']['consumables']);
self::assertSame(0, $res['data']['consumables_total_rials']);
self::assertNull($res['data']['session_at']);
self::assertNull($res['data']['inventory_package_uuid']);
}
public function testZeroQuantityCoercedToOne(): void
{
[$owner, $doctor, $record] = $this->recordFor();
$item = $this->itemFor($doctor, 'گاز استریل', 80_000);
$res = $this->authJson('POST', '/api/v1/patient/' . $record->getUuid() . '/session', $owner, [
'visit_price_rials' => 0,
'consumables' => [['inventory_item_uuid' => $item->getUuid(), 'quantity' => 0]],
]);
self::assertSame(201, $this->responseCode());
self::assertSame(1, $res['data']['consumables'][0]['quantity']);
self::assertSame(80_000, $res['data']['consumables_total_rials']);
}
}