Files
clinicpro/assets/admin/components/appointments/ConfirmAppointmentModal.tsx
T
hamedandClaude Opus 5 1f58b1b9b3 feat(insurance): bill an appointment with a chosen service kind and insurance
An appointment can now carry the insurance it is billed with: the service kind
(outpatient/inpatient) and the basic insurance. Confirming it no longer hands the
whole amount to the patient — the visit is split through BillingCalculator with the
coverage percent of that service kind, and the choice travels to the encounter and
the invoice built from it.

The enabled service kinds are a tenant-wide setting (all of that tenant's
insurances share it), so a tenant covering only one kind is never asked which one:
the panel resolves it the same way the server does.

- add tenant_service_category_settings + TenantServiceCategoryService, exposed on
  the existing insurance-pricing endpoint (service_categories,
  default_service_category); at least one kind must stay enabled
- add appointments.insurance_service_category / insurance_base_id with
  AppointmentInsuranceService validating them against the tenant's own settings
  and active contracts (basic only), accepted by PATCH and by confirm
- snapshot the kind on patient_sessions and invoices; the visit's coverage rule is
  resolved per kind (services keep using their own ServiceItem.service_category)
- lib/insuranceShares becomes the single client-side mirror of BillingCalculator,
  shared by the confirm modal, the appointment edit page and the session form
- surface the selection: confirm modal (with live shares), turns timeline chip,
  appointment edit page, patient record service card and invoice summary
- the session form shows the insurance block whenever the tenant has an active
  contract and prefills the patient's own insurance, so it can be changed
- fix: the confirm modal showed a zero visit price when the appointment had none —
  it now falls back to the tenant's free-visit price like the server
- fix: useServiceCategories read one level too shallow, so Persian labels never
  arrived and raw enum keys leaked into the contract summary
- fix: BlogsPage test asserted the public blogs endpoint after the page moved to
  the admin one

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 17:50:14 +03:30

514 lines
22 KiB
TypeScript

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;
}
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<string, { fg: string; bg: string }> = {
'بدون پرداخت': { 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> = {}): PaymentRow => ({
id: nextId.current++, method: 'cash', amountToman: 0, methodUuid: '', reference: '', ...over,
});
const [rows, setRows] = useState<PaymentRow[]>([makeRow()]);
/** تا وقتی کاربر مبلغ را دست نزده، ردیفِ اول با کل مبلغ پر می‌ماند. */
const [touched, setTouched] = useState(false);
// وقتی صفحه‌ی میزبان نوبت را ندارد (مثل ردیف لیست) خودمان جزئیات را می‌گیریم:
// مبلغ ویزیت و قیمت سرویس‌ها فقط در detail هستند.
const detailQuery = useQuery({
queryKey: ['appointment', appointmentUuid],
queryFn: () => api.get<ApiResponse<AppointmentLike>>(`/api/v1/appointment/${appointmentUuid}`),
enabled: open && !appointment,
});
// روش‌های پرداختِ ثبت‌شده — فقط وقتی مودال باز است.
const posQuery = useQuery<ApiResponse<Pos[]>>({
queryKey: ['payment-methods', 'pos'],
queryFn: () => api.get('/api/v1/my/payment-methods/pos'),
enabled: open,
});
const bankQuery = useQuery<ApiResponse<BankAccount[]>>({
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<string>('');
const [insuranceId, setInsuranceId] = useState<string>('');
// مقدارِ نوبت مبنا است؛ در نبودش نوع پیش‌فرضِ tenant.
useEffect(() => {
if (!open) return;
setServiceCategory(appt?.insurance_service_category ?? insurance.defaultCategory ?? '');
setInsuranceId(appt?.insurance_base_id ? String(appt.insurance_base_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), [visitPrice, services, effectiveCategory, insuranceId, insurance.breakdown]);
const payable = insuranceId ? 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<ApiResponse<unknown>>(`/api/v1/appointment/${appointmentUuid}/confirm`, {
version: appt?.version,
...(serviceCategory ? { insurance_service_category: serviceCategory } : {}),
...(insuranceId ? { insurance_base_id: Number(insuranceId) } : {}),
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<PaymentRow>) {
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 (
<Modal
open={open}
title="قطعی کردن نوبت"
size="md"
onClose={handleClose}
footer={
<>
<button type="button" className="btn ghost" onClick={handleClose}>
انصراف
</button>
<button
type="button"
className="btn primary"
disabled={loading || overpaid || confirmMut.isPending}
onClick={() => confirmMut.mutate()}
>
{confirmMut.isPending ? 'در حال ثبت…' : 'تأیید و قطعی کردن'}
</button>
</>
}
>
{loading ? (
<p style={{ color: 'var(--text-2)' }}>در حال دریافت اطلاعات نوبت…</p>
) : (
<>
{appt?.patient_name && (
<div
style={{
display: 'flex', alignItems: 'center', gap: 10, marginBottom: 16,
padding: '10px 14px', borderRadius: 'var(--r-sm)',
background: 'var(--primary-soft)',
}}
>
<UserCircleIcon style={{ width: 20, height: 20, color: 'var(--primary-700)', flexShrink: 0 }} />
<span style={{ fontSize: 13.5, color: 'var(--text-2)' }}>بیمار</span>
<strong style={{ fontSize: 14, color: 'var(--primary-700)' }}>{appt.patient_name}</strong>
</div>
)}
{/* بیمه — نوع خدمت فقط وقتی چند نوع فعال است پرسیده می‌شود. */}
<div style={{ display: 'flex', gap: 10, marginBottom: 16, flexWrap: 'wrap' }}>
{insurance.needsCategoryChoice && (
<div className="field-block" style={{ flex: 1, minWidth: 180 }}>
<label>نوع خدمت</label>
<SearchableSelect
value={serviceCategory}
onChange={(v) => setServiceCategory(v == null ? '' : String(v))}
options={insurance.categoryOptions}
placeholder="انتخاب نوع خدمت"
height={40}
/>
</div>
)}
<div className="field-block" style={{ flex: 1, minWidth: 180 }}>
<label>بیمه</label>
<SearchableSelect
value={insuranceId}
onChange={(v) => setInsuranceId(v == null ? '' : String(v))}
options={insurance.insuranceOptions}
placeholder="بدون بیمه"
noOptionsMessage="قرارداد بیمهٔ فعالی ندارید"
isClearable
height={40}
/>
</div>
</div>
{/* هزینه‌ها */}
<div
style={{
border: '1px solid var(--border)', borderRadius: 'var(--r)',
padding: '4px 14px 10px', marginBottom: 20,
}}
>
<div style={rowStyle}>
<span>ویزیت</span>
<strong style={{ color: 'var(--text)' }}>{formatRial(visitPrice)}</strong>
</div>
{services.map((s) => (
<div key={s.uuid} style={{ ...rowStyle, borderTop: '1px solid var(--border)' }}>
<span>{s.name}</span>
<strong style={{ color: 'var(--text)' }}>{formatRial(Number(s.price_rials ?? 0))}</strong>
</div>
))}
<div style={{ ...rowStyle, borderTop: '1px solid var(--border)' }}>
<span>جمع کل</span>
<strong style={{ color: 'var(--text)' }}>{formatRial(total)}</strong>
</div>
{insuranceId !== '' && (
<div style={{ ...rowStyle, borderTop: '1px solid var(--border)' }}>
<span>سهم بیمه{insurance.categoryLabelOf(effectiveCategory) ? ` (${insurance.categoryLabelOf(effectiveCategory)})` : ''}</span>
<strong style={{ color: 'var(--success)' }}>{formatRial(shares.insurance)}</strong>
</div>
)}
<div
style={{
...rowStyle, borderTop: '1px solid var(--border)', marginTop: 2,
paddingTop: 12, fontSize: 14, fontWeight: 700, color: 'var(--text)',
}}
>
<span>{insuranceId !== '' ? 'سهم بیمار (قابل پرداخت)' : 'مبلغ قابل پرداخت'}</span>
<strong style={{ fontSize: 16, color: 'var(--primary)' }}>{formatRial(payable)}</strong>
</div>
</div>
{/* پرداخت‌ها — تقسیم بین چند روش */}
<div style={{ marginBottom: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<label style={{ fontSize: 13.5, fontWeight: 700, color: 'var(--text)' }}>روش‌های پرداخت</label>
<button type="button" className="btn ghost sm" onClick={addRow}>
<PlusIcon style={{ width: 15, height: 15 }} />
افزودن روش
</button>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{rows.map((r) => (
<div
key={r.id}
style={{
border: '1px solid var(--border)', borderRadius: 'var(--r-sm)',
padding: 12, display: 'flex', flexDirection: 'column', gap: 10,
}}
>
<div style={{ display: 'flex', gap: 10, alignItems: 'flex-end' }}>
<div className="field-block" style={{ flex: 1 }}>
<label>روش پرداخت</label>
<SearchableSelect
value={r.method}
onChange={(v) => changeMethod(r.id, String(v ?? 'cash'))}
options={METHOD_OPTIONS}
placeholder="روش پرداخت"
height={40}
/>
</div>
<div className="field-block" style={{ flex: 1 }}>
<label>مبلغ (تومان)</label>
<div className="field">
<PriceInput
value={r.amountToman}
onChange={(v) => patchRow(r.id, { amountToman: v })}
suffix="تومان"
/>
</div>
</div>
{rows.length > 1 && (
<button
type="button"
className="btn ghost sm"
aria-label="حذف روش پرداخت"
onClick={() => removeRow(r.id)}
style={{ marginBottom: 2, color: 'var(--danger)' }}
>
<TrashIcon style={{ width: 16, height: 16 }} />
</button>
)}
</div>
{/* جزئیاتِ کارت‌خوان: انتخاب دستگاهِ ثبت‌شده + شناسه تراکنش */}
{r.method === 'pos' && (
<div style={{ display: 'flex', gap: 10 }}>
<div className="field-block" style={{ flex: 1 }}>
<label>کارت‌خوان</label>
<SearchableSelect
value={r.methodUuid}
onChange={(v) => patchRow(r.id, { methodUuid: String(v ?? '') })}
options={posOptions}
placeholder={posOptions.length ? 'انتخاب کارت‌خوان' : 'کارت‌خوانی ثبت نشده'}
height={40}
/>
</div>
<div className="field-block" style={{ flex: 1 }}>
<label>شناسه تراکنش (اختیاری)</label>
<div className="field">
<input
type="text"
value={r.reference}
onChange={(e) => patchRow(r.id, { reference: e.target.value })}
placeholder="شماره پیگیری"
style={{ direction: 'ltr' }}
/>
</div>
</div>
</div>
)}
{/* جزئیاتِ کارت‌به‌کارت: انتخاب حساب بانکیِ ثبت‌شده + شناسه تراکنش */}
{r.method === 'card' && (
<div style={{ display: 'flex', gap: 10 }}>
<div className="field-block" style={{ flex: 1 }}>
<label>حساب بانکی (اختیاری)</label>
<SearchableSelect
value={r.methodUuid}
onChange={(v) => patchRow(r.id, { methodUuid: String(v ?? '') })}
options={bankOptions}
placeholder={bankOptions.length ? 'انتخاب حساب' : 'حسابی ثبت نشده'}
height={40}
/>
</div>
<div className="field-block" style={{ flex: 1 }}>
<label>شناسه تراکنش (اختیاری)</label>
<div className="field">
<input
type="text"
value={r.reference}
onChange={(e) => patchRow(r.id, { reference: e.target.value })}
placeholder="شماره پیگیری"
style={{ direction: 'ltr' }}
/>
</div>
</div>
</div>
)}
</div>
))}
</div>
</div>
{overpaid && (
<p className="field-err" style={{ marginBottom: 14 }}>
مجموع پرداخت‌ها از مبلغ قابل پرداخت بیشتر است.
</p>
)}
{/* خلاصه */}
<div
style={{
background: 'var(--surface-2)', border: '1px solid var(--border)',
borderRadius: 'var(--r)', padding: '4px 14px 10px',
}}
>
<div style={rowStyle}>
<span>پرداخت‌شده</span>
<strong style={{ color: 'var(--text)' }}>{formatRial(Math.min(paidRials, payable))}</strong>
</div>
<div style={{ ...rowStyle, borderTop: '1px solid var(--border)' }}>
<span>باقی‌مانده</span>
<strong style={{ color: remaining > 0 ? 'var(--danger)' : 'var(--success)' }}>
{formatRial(remaining)}
</strong>
</div>
<div style={{ ...rowStyle, borderTop: '1px solid var(--border)' }}>
<span>وضعیت پرداخت</span>
<span
style={{
padding: '4px 12px', borderRadius: 'var(--r-pill)',
fontSize: 12.5, fontWeight: 700,
color: STATE_TONE[paymentState].fg,
background: STATE_TONE[paymentState].bg,
}}
>
{paymentState}
</span>
</div>
</div>
</>
)}
</Modal>
);
}