- Consolidated the calculation of patient and insurance shares into a single method using BillingCalculator. - Introduced new fields in PatientSession to store breakdown of insurance shares and patient share. - Updated the API responses to include the new fields for consistency across payment, invoice, and claims dashboard. - Added migration to backfill existing sessions with appropriate values for the new fields. - Implemented tests to ensure the correctness of the new logic and verify that the breakdown sums to the gross total. - Redesigned the claims dashboard to provide a more user-friendly overview of patient claims and their statuses.
162 lines
8.2 KiB
TypeScript
162 lines
8.2 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;
|
|
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>,
|
|
]]}
|
|
/>
|
|
<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>
|
|
);
|
|
}
|