import { useEffect, useMemo, useRef, useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; import { UserCircleIcon, PlusIcon, TrashIcon } from '@heroicons/react/24/outline'; import { api } from '../../lib/api'; import type { ApiResponse } from '../../lib/api'; import type { BankAccount, Pos } from '../../hooks/usePaymentMethods'; import { formatRial, rialToToman, tomanToRial } from '../../lib/utils'; import { DEFAULT_SERVICE_CATEGORY } from '../../lib/insuranceShares'; import { useAppointmentInsurance } from '../../hooks/useAppointmentInsurance'; import Modal from '../ui/Modal'; import PriceInput from '../ui/PriceInput'; import SearchableSelect from '../ui/SearchableSelect'; /** همان چهار روشِ SessionPayment::METHODS در بک‌اند. */ const METHOD_OPTIONS = [ { value: 'cash', label: 'پرداخت نقدی' }, { value: 'pos', label: 'پرداخت از طریق کارت خوان' }, { value: 'card', label: 'کارت به کارت' }, { value: 'wallet', label: 'پرداخت از طریق کیف پول' }, ]; interface ServiceItem { uuid: string; name: string; price_rials?: number | null; service_category?: string | null; insurance_covered?: boolean; } interface AppointmentLike { uuid: string; version?: number; visit_price_rials?: number | null; service_items?: ServiceItem[] | null; patient_name?: string | null; insurance_service_category?: string | null; insurance_base_id?: number | null; insurance_supplementary_id?: number | null; } interface Props { open: boolean; appointmentUuid: string; /** اگر صفحه از قبل نوبت را دارد، پاس بده تا درخواست اضافه نرود. */ appointment?: AppointmentLike | null; onClose: () => void; /** کلید کوئریِ لیستی که بعد از قطعی‌شدن باید invalidate شود. */ queryKey?: unknown[]; } /** یک ردیفِ پرداخت در تسویهٔ چندروشی. */ interface PaymentRow { id: number; method: string; amountToman: number; /** uuid کارت‌خوان (pos) یا حساب بانکیِ (card) ثبت‌شده. */ methodUuid: string; /** شناسه تراکنش / شماره پیگیری. */ reference: string; } const rowStyle: React.CSSProperties = { display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12, padding: '9px 0', fontSize: 13.5, color: 'var(--text-2)', }; /** رنگ چیپِ «وضعیت پرداخت» بر اساس نسبت پرداخت به جمع کل. */ const STATE_TONE: Record = { 'بدون پرداخت': { fg: 'var(--text-2)', bg: 'var(--surface-3)' }, 'پرداخت جزئی': { fg: 'var(--warning)', bg: 'var(--warning-bg)' }, 'تسویه کامل': { fg: 'var(--success)', bg: 'var(--success-bg)' }, }; /** * «قطعی کردن نوبت» — هزینه‌های نوبت را نشان می‌دهد، پرداخت را بین چند روش * (نقدی/کارت‌خوان/کارت‌به‌کارت/کیف پول) تقسیم می‌کند و نوبت را «قطعی» می‌کند. * * سرور همین یک درخواست را اتمیک انجام می‌دهد: وضعیت + پرونده/مراجعه + پرداخت‌ها. * مبلغ پرداختی می‌تواند کمتر از جمع کل باشد (پرداخت جزئی)، ولی نباید بیشتر شود. */ export default function ConfirmAppointmentModal({ open, appointmentUuid, appointment, onClose, queryKey, }: Props) { const qc = useQueryClient(); const nextId = useRef(1); const makeRow = (over: Partial = {}): PaymentRow => ({ id: nextId.current++, method: 'cash', amountToman: 0, methodUuid: '', reference: '', ...over, }); const [rows, setRows] = useState([makeRow()]); /** تا وقتی کاربر مبلغ را دست نزده، ردیفِ اول با کل مبلغ پر می‌ماند. */ const [touched, setTouched] = useState(false); // وقتی صفحه‌ی میزبان نوبت را ندارد (مثل ردیف لیست) خودمان جزئیات را می‌گیریم: // مبلغ ویزیت و قیمت سرویس‌ها فقط در detail هستند. const detailQuery = useQuery({ queryKey: ['appointment', appointmentUuid], queryFn: () => api.get>(`/api/v1/appointment/${appointmentUuid}`), enabled: open && !appointment, }); // روش‌های پرداختِ ثبت‌شده — فقط وقتی مودال باز است. const posQuery = useQuery>({ queryKey: ['payment-methods', 'pos'], queryFn: () => api.get('/api/v1/my/payment-methods/pos'), enabled: open, }); const bankQuery = useQuery>({ queryKey: ['payment-methods', 'bank-accounts'], queryFn: () => api.get('/api/v1/my/payment-methods/bank-accounts'), enabled: open, }); const posOptions = useMemo( () => ((posQuery.data?.data ?? []) as Pos[]) .filter(p => p.is_active) .map(p => ({ value: p.uuid, label: `${p.bank_name} — ${p.terminal_number}` })), [posQuery.data], ); const bankOptions = useMemo( () => ((bankQuery.data?.data ?? []) as BankAccount[]) .filter(b => b.is_active) .map(b => ({ value: b.uuid, label: `${b.bank_name}${b.card_number ? ` — ${b.card_number}` : ''}` })), [bankQuery.data], ); const appt: AppointmentLike | null = appointment ?? ((detailQuery.data?.data as any)?.data ?? detailQuery.data?.data ?? null); // ── بیمه: نوع خدمت + بیمهٔ پایهٔ نوبت ────────────────────────────────────── const insurance = useAppointmentInsurance(open); // نوبتِ بدون هزینهٔ ویزیت، سرِ ساختِ مراجعه «قیمت ویزیت آزاد» تنظیمات را می‌گیرد؛ // مودال هم باید همان را نشان دهد، وگرنه صفر نشان می‌دهد و مبلغ ثبت‌شده فرق می‌کند. const visitPrice = insurance.visitPriceOf(appt?.visit_price_rials); const services = appt?.service_items ?? []; const servicesTotal = useMemo( () => services.reduce((sum, s) => sum + Number(s.price_rials ?? 0), 0), [services], ); const total = visitPrice + servicesTotal; const [serviceCategory, setServiceCategory] = useState(''); const [insuranceId, setInsuranceId] = useState(''); const [supplementaryId, setSupplementaryId] = useState(''); // مقدارِ نوبت مبنا است؛ در نبودش نوع پیش‌فرضِ tenant. useEffect(() => { if (!open) return; setServiceCategory(appt?.insurance_service_category ?? insurance.defaultCategory ?? ''); setInsuranceId(appt?.insurance_base_id ? String(appt.insurance_base_id) : ''); setSupplementaryId(appt?.insurance_supplementary_id ? String(appt.insurance_supplementary_id) : ''); }, [open, appt?.uuid, insurance.defaultCategory]); const effectiveCategory = serviceCategory || insurance.defaultCategory || DEFAULT_SERVICE_CATEGORY; // آینهٔ سرور: ویزیت با نوع انتخابی، هر خدمت با نوع خودش. const shares = useMemo(() => insurance.breakdown([ { total: visitPrice, category: effectiveCategory, insured: true }, ...services.map(s => ({ total: Number(s.price_rials ?? 0), category: s.service_category ?? DEFAULT_SERVICE_CATEGORY, insured: s.insurance_covered !== false, })), ], insuranceId, supplementaryId), [visitPrice, services, effectiveCategory, insuranceId, supplementaryId, insurance.breakdown]); const hasInsurance = !!insuranceId || !!supplementaryId; const payable = hasInsurance ? shares.patient : total; // ردیفِ اول تا لحظه‌ای که کاربر مبلغ را دستی تغییر ندهد پیش‌فرضِ «پرداخت کامل» است؛ // نوبت هنوز session ندارد، پس باقی‌مانده‌اش برابر مبلغِ قابل پرداخت است. useEffect(() => { if (!open || touched || payable <= 0) return; setRows(prev => prev.map((r, i) => (i === 0 ? { ...r, amountToman: rialToToman(payable) } : r))); }, [open, touched, payable]); const paidRials = useMemo( () => rows.reduce((sum, r) => sum + tomanToRial(r.amountToman), 0), [rows], ); const remaining = Math.max(0, payable - paidRials); const overpaid = paidRials > payable; const paymentState = paidRials === 0 ? 'بدون پرداخت' : remaining === 0 ? 'تسویه کامل' : 'پرداخت جزئی'; const confirmMut = useMutation({ mutationFn: () => api.post>(`/api/v1/appointment/${appointmentUuid}/confirm`, { version: appt?.version, ...(serviceCategory ? { insurance_service_category: serviceCategory } : {}), ...(insuranceId ? { insurance_base_id: Number(insuranceId) } : {}), ...(supplementaryId ? { insurance_supplementary_id: Number(supplementaryId) } : {}), payments: rows .filter(r => tomanToRial(r.amountToman) > 0) .map(r => ({ method: r.method, amount_rials: tomanToRial(r.amountToman), ...(r.methodUuid ? { payment_method_uuid: r.methodUuid } : {}), ...(r.reference.trim() ? { reference: r.reference.trim() } : {}), })), }), onSuccess: () => { toast.success('نوبت قطعی شد'); if (queryKey) qc.invalidateQueries({ queryKey }); qc.invalidateQueries({ queryKey: ['appointment', appointmentUuid] }); qc.invalidateQueries({ queryKey: ['appointment-events', appointmentUuid] }); reset(); onClose(); }, onError: (e: any) => toast.error(e?.message || 'قطعی کردن نوبت ناموفق بود'), }); function reset() { nextId.current = 1; setRows([makeRow()]); setTouched(false); } function patchRow(id: number, patch: Partial) { setTouched(true); setRows(prev => prev.map(r => (r.id === id ? { ...r, ...patch } : r))); } /** روش که عوض شد، جزئیاتِ مخصوصِ روشِ قبلی بی‌معنا می‌شود. */ function changeMethod(id: number, method: string) { patchRow(id, { method, methodUuid: '', reference: '' }); } function addRow() { setTouched(true); // ردیفِ جدید پیش‌فرض با باقی‌مانده پر می‌شود تا تسویه سریع‌تر باشد. setRows(prev => [...prev, makeRow({ amountToman: rialToToman(remaining) })]); } function removeRow(id: number) { setTouched(true); setRows(prev => (prev.length > 1 ? prev.filter(r => r.id !== id) : prev)); } function handleClose() { reset(); onClose(); } const loading = detailQuery.isLoading && !appointment; return ( } > {loading ? (

در حال دریافت اطلاعات نوبت…

) : ( <> {appt?.patient_name && (
بیمار {appt.patient_name}
)} {/* بیمه — نوع خدمت فقط وقتی چند نوع فعال است پرسیده می‌شود. */}
{insurance.needsCategoryChoice && (
setServiceCategory(v == null ? '' : String(v))} options={insurance.categoryOptions} placeholder="انتخاب نوع خدمت" height={40} />
)}
setInsuranceId(v == null ? '' : String(v))} options={insurance.insuranceOptions} placeholder="بدون بیمه" noOptionsMessage="قرارداد بیمهٔ فعالی ندارید" isClearable height={40} />
{insurance.hasSupplementary && (
setSupplementaryId(v == null ? '' : String(v))} options={insurance.supplementaryOptions} placeholder="بدون بیمه تکمیلی" isClearable height={40} />
)}
{/* هزینه‌ها */}
ویزیت {formatRial(visitPrice)}
{services.map((s) => (
{s.name} {formatRial(Number(s.price_rials ?? 0))}
))}
جمع کل {formatRial(total)}
{/* سهم‌ها زنجیره‌ای‌اند: پایه روی کل، تکمیلی روی باقیمانده — جدا نشان داده می‌شوند تا معلوم باشد هرکدام چقدر برداشته‌اند. */} {insuranceId !== '' && (
سهم بیمه پایه {insurance.insuranceNameOf(insuranceId) ? ` — ${insurance.insuranceNameOf(insuranceId)}` : ''} {insurance.categoryLabelOf(effectiveCategory) ? ` (${insurance.categoryLabelOf(effectiveCategory)})` : ''} {formatRial(shares.base)}
)} {supplementaryId !== '' && (
سهم بیمه تکمیلی {insurance.insuranceNameOf(supplementaryId) ? ` — ${insurance.insuranceNameOf(supplementaryId)}` : ''} (روی باقیمانده) {formatRial(shares.supplementary)}
)}
{hasInsurance ? 'سهم بیمار (قابل پرداخت)' : 'مبلغ قابل پرداخت'} {formatRial(payable)}
{/* پرداخت‌ها — تقسیم بین چند روش */}
{rows.map((r) => (
changeMethod(r.id, String(v ?? 'cash'))} options={METHOD_OPTIONS} placeholder="روش پرداخت" height={40} />
patchRow(r.id, { amountToman: v })} suffix="تومان" />
{rows.length > 1 && ( )}
{/* جزئیاتِ کارت‌خوان: انتخاب دستگاهِ ثبت‌شده + شناسه تراکنش */} {r.method === 'pos' && (
patchRow(r.id, { methodUuid: String(v ?? '') })} options={posOptions} placeholder={posOptions.length ? 'انتخاب کارت‌خوان' : 'کارت‌خوانی ثبت نشده'} height={40} />
patchRow(r.id, { reference: e.target.value })} placeholder="شماره پیگیری" style={{ direction: 'ltr' }} />
)} {/* جزئیاتِ کارت‌به‌کارت: انتخاب حساب بانکیِ ثبت‌شده + شناسه تراکنش */} {r.method === 'card' && (
patchRow(r.id, { methodUuid: String(v ?? '') })} options={bankOptions} placeholder={bankOptions.length ? 'انتخاب حساب' : 'حسابی ثبت نشده'} height={40} />
patchRow(r.id, { reference: e.target.value })} placeholder="شماره پیگیری" style={{ direction: 'ltr' }} />
)}
))}
{overpaid && (

مجموع پرداخت‌ها از مبلغ قابل پرداخت بیشتر است.

)} {/* خلاصه */}
پرداخت‌شده {formatRial(Math.min(paidRials, payable))}
باقی‌مانده 0 ? 'var(--danger)' : 'var(--success)' }}> {formatRial(remaining)}
وضعیت پرداخت {paymentState}
)}
); }