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>
179 lines
9.0 KiB
TypeScript
179 lines
9.0 KiB
TypeScript
import { useQuery } from '@tanstack/react-query';
|
|
import { api } from '../lib/api';
|
|
import type { ApiResponse } from '../lib/api';
|
|
import Modal from './ui/Modal';
|
|
import { formatDate, formatDateTime, formatRial } from '../lib/utils';
|
|
import { METHOD_LABELS } from './session/PaymentStep';
|
|
|
|
interface InvoiceItem { uuid: string; title: string; quantity: number; total_rials: number; patient_rials: number }
|
|
interface SessionPayment { uuid: string; method: string; amount_rials: number; paid_at: number; created_by_name: string | null }
|
|
interface SessionConsumable { uuid: string; item_name: string; quantity: number; line_total_rials: number }
|
|
interface SessionData {
|
|
session_at: number | null; paid_at: number | null;
|
|
services_total_rials: number; consumables_total_rials: number;
|
|
discount_rials: number; final_price_rials: number; paid_total_rials: number;
|
|
gross_total_rials?: number; base_insurance_rials?: number;
|
|
supplementary_insurance_rials?: number; patient_share_rials?: number;
|
|
remaining_rials?: number;
|
|
payments: SessionPayment[]; consumables: SessionConsumable[];
|
|
}
|
|
interface Invoice {
|
|
uuid: string; status: string; issued_at: number; total_rials: number;
|
|
base_insurance_rials: number; supplementary_rials: number; patient_rials: number;
|
|
/** نوع خدمتِ بیمهای که فاکتور با آن محاسبه شده + نام بیمهها. */
|
|
service_category?: string | null;
|
|
service_category_label?: string | null;
|
|
base_insurance_name?: string | null;
|
|
supplementary_insurance_name?: string | null;
|
|
items: InvoiceItem[];
|
|
session?: SessionData | null;
|
|
}
|
|
|
|
const STATUS_LABEL: Record<string, string> = { paid: 'پرداخت شده', finalized: 'بدهکار', draft: 'پیشنویس', void: 'باطل' };
|
|
|
|
/** A titled table block — mirrors tauri InvoiceSummary `SectionTable`. */
|
|
function SectionTable({ title, cols, rows }: { title: string; cols: string[]; rows: React.ReactNode[][] }) {
|
|
return (
|
|
<div style={{ marginBottom: 24 }}>
|
|
<div style={{ fontSize: 16, fontWeight: 600, color: 'var(--text)', marginBottom: 12 }}>{title}</div>
|
|
<div style={{ border: '1px solid var(--border)', borderRadius: 8, overflow: 'hidden' }}>
|
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
|
<thead>
|
|
<tr>
|
|
{cols.map((c, i) => (
|
|
<th key={i} style={{ textAlign: 'center', fontWeight: 600, padding: '12px 16px', background: 'var(--info-bg, #ebf5ff)', borderBottom: '1px solid var(--border)', color: 'var(--text-2)' }}>{c}</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{rows.map((row, i) => (
|
|
<tr key={i} style={{ background: i % 2 === 1 ? 'var(--surface-2)' : 'transparent' }}>
|
|
{row.map((cell, j) => (
|
|
<td key={j} style={{ textAlign: 'center', padding: '12px 16px', borderBottom: i === rows.length - 1 ? 'none' : '1px solid var(--border)', color: 'var(--text)' }}>{cell}</td>
|
|
))}
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** خلاصه فاکتور — invoice summary, ported pixel-for-pixel from tauri InvoiceSummary. */
|
|
export default function InvoiceSummaryModal({ invoiceUuid, onClose }: { invoiceUuid: string | null; onClose: () => void }) {
|
|
const { data, isLoading } = useQuery<ApiResponse<any>>({
|
|
queryKey: ['invoice', invoiceUuid],
|
|
queryFn: () => api.get(`/api/v1/billing/invoices/${invoiceUuid}`),
|
|
enabled: !!invoiceUuid,
|
|
});
|
|
// billing show wraps as { data: { data: invoice } }
|
|
const inv = ((data?.data as any)?.data ?? data?.data ?? null) as Invoice | null;
|
|
const session = inv?.session ?? null;
|
|
|
|
const paid = inv?.status === 'paid';
|
|
// مانده را سرور میدهد (session.remaining_rials)؛ محاسبهی محلی با صفحهی پرداخت واگرا میشد.
|
|
const remaining = session
|
|
? session.remaining_rials ?? Math.max(0, session.final_price_rials - (session.discount_rials ?? 0) - session.paid_total_rials)
|
|
: inv ? (paid ? 0 : inv.patient_rials) : 0;
|
|
const paidAmount = session ? session.paid_total_rials : inv ? inv.total_rials - remaining : 0;
|
|
|
|
// یک شکل واحد برای خلاصهی مالی؛ با session از خود مراجعه، بدون آن از فاکتور.
|
|
const summary = {
|
|
services: session ? session.services_total_rials : inv?.total_rials ?? 0,
|
|
consumables: session?.consumables_total_rials ?? 0,
|
|
discount: session?.discount_rials ?? 0,
|
|
baseInsurance: session?.base_insurance_rials ?? inv?.base_insurance_rials ?? 0,
|
|
suppInsurance: session?.supplementary_insurance_rials ?? inv?.supplementary_rials ?? 0,
|
|
patientShare: session?.patient_share_rials ?? session?.final_price_rials ?? inv?.patient_rials ?? 0,
|
|
gross: session?.gross_total_rials ?? inv?.total_rials ?? 0,
|
|
};
|
|
// وضعیت واقعی پرداخت (مستقل از وضعیت فریزشدهی فاکتور): تسویهشده اگر مانده صفر.
|
|
const statusLabel = session ? (remaining <= 0 ? 'تسویه شده' : 'بدهکار') : (inv ? (STATUS_LABEL[inv.status] ?? inv.status) : '');
|
|
|
|
return (
|
|
<Modal open={!!invoiceUuid} onClose={onClose} title="خلاصه فاکتور" size="xl">
|
|
{isLoading || !inv ? (
|
|
<div style={{ padding: 24, textAlign: 'center', color: 'var(--text-3)' }}>در حال بارگذاری…</div>
|
|
) : (
|
|
<div dir="rtl">
|
|
<SectionTable
|
|
title="اطلاعات فاکتور"
|
|
cols={['تاریخ سرویس', 'تاریخ پرداخت', 'وضعیت پرداخت']}
|
|
rows={[[
|
|
formatDate(session?.session_at ?? inv.issued_at),
|
|
session?.paid_at ? formatDate(session.paid_at) : paid ? formatDate(inv.issued_at) : '—',
|
|
<span style={{ color: remaining > 0 ? '#d32f2f' : '#388e3c', fontWeight: 600 }}>{statusLabel}</span>,
|
|
]]}
|
|
/>
|
|
{(inv.base_insurance_name || inv.service_category_label) && (
|
|
<SectionTable
|
|
title="اطلاعات بیمه"
|
|
cols={['نوع خدمت بیمه', 'بیمه', 'سهم بیمه', 'سهم بیمار']}
|
|
rows={[[
|
|
inv.service_category_label ?? '—',
|
|
inv.base_insurance_name ?? '—',
|
|
formatRial(summary.baseInsurance + summary.suppInsurance),
|
|
formatRial(summary.patientShare),
|
|
]]}
|
|
/>
|
|
)}
|
|
<SectionTable
|
|
title="اطلاعات سرویس"
|
|
cols={['سرویس', 'تعداد', 'مبلغ']}
|
|
rows={inv.items.length ? inv.items.map((it) => [it.title, it.quantity, formatRial(it.total_rials)]) : [['—', '—', '—']]}
|
|
/>
|
|
{session && (
|
|
<SectionTable
|
|
title="اطلاعات کالای مصرفی"
|
|
cols={['کالای مصرفی', 'تعداد', 'مبلغ']}
|
|
rows={session.consumables.length
|
|
? session.consumables.map((c) => [c.item_name, c.quantity, formatRial(c.line_total_rials)])
|
|
: [['-', '-', '-']]}
|
|
/>
|
|
)}
|
|
<SectionTable
|
|
title="خلاصه مالی"
|
|
cols={['جمع مبلغ سرویس', 'جمع مبلغ کالا', 'سهم بیمه پایه', 'سهم بیمه تکمیلی', 'سهم بیمار', 'تخفیف', 'مبلغ نهایی']}
|
|
rows={[[
|
|
formatRial(summary.services),
|
|
summary.consumables > 0 ? formatRial(summary.consumables) : '-',
|
|
formatRial(summary.baseInsurance),
|
|
formatRial(summary.suppInsurance),
|
|
formatRial(summary.patientShare),
|
|
summary.discount > 0 ? formatRial(summary.discount) : '-',
|
|
formatRial(Math.max(0, summary.patientShare - summary.discount)),
|
|
]]}
|
|
/>
|
|
{session && (
|
|
<SectionTable
|
|
title="پرداختی ها"
|
|
cols={['ردیف', 'شیوه پرداخت', 'مبلغ', 'تاریخ و ساعت', 'ثبتکننده']}
|
|
rows={session.payments.length
|
|
? [
|
|
...session.payments.map((p, i) => [
|
|
i + 1,
|
|
METHOD_LABELS[p.method] ?? p.method,
|
|
formatRial(p.amount_rials),
|
|
p.paid_at ? formatDateTime(p.paid_at) : '-',
|
|
p.created_by_name ?? '-',
|
|
]),
|
|
['', <span style={{ fontWeight: 700 }}>مجموع پرداختیها</span>, <span style={{ fontWeight: 700 }}>{formatRial(session.paid_total_rials)}</span>, '', ''],
|
|
]
|
|
: [['-', '-', '-', '-', '-']]}
|
|
/>
|
|
)}
|
|
<SectionTable
|
|
title="وضعیت"
|
|
cols={['مبلغ کل پرداخت شده', 'مبلغ باقی مانده']}
|
|
rows={[[
|
|
formatRial(paidAmount),
|
|
<span style={{ color: remaining > 0 ? '#d32f2f' : '#388e3c', fontWeight: 600 }}>{formatRial(remaining)}</span>,
|
|
]]}
|
|
/>
|
|
</div>
|
|
)}
|
|
</Modal>
|
|
);
|
|
}
|