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>
This commit is contained in:
@@ -6,6 +6,8 @@ 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';
|
||||
@@ -22,6 +24,8 @@ interface ServiceItem {
|
||||
uuid: string;
|
||||
name: string;
|
||||
price_rials?: number | null;
|
||||
service_category?: string | null;
|
||||
insurance_covered?: boolean;
|
||||
}
|
||||
|
||||
interface AppointmentLike {
|
||||
@@ -30,6 +34,8 @@ interface AppointmentLike {
|
||||
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 {
|
||||
@@ -128,27 +134,55 @@ export default function ConfirmAppointmentModal({
|
||||
const appt: AppointmentLike | null = appointment
|
||||
?? ((detailQuery.data?.data as any)?.data ?? detailQuery.data?.data ?? null);
|
||||
|
||||
const visitPrice = Number(appt?.visit_price_rials ?? 0);
|
||||
// ── بیمه: نوع خدمت + بیمهٔ پایهٔ نوبت ──────────────────────────────────────
|
||||
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 ندارد، پس باقیماندهاش برابر کل هزینه است.
|
||||
// نوبت هنوز session ندارد، پس باقیماندهاش برابر مبلغِ قابل پرداخت است.
|
||||
useEffect(() => {
|
||||
if (!open || touched || total <= 0) return;
|
||||
setRows(prev => prev.map((r, i) => (i === 0 ? { ...r, amountToman: rialToToman(total) } : r)));
|
||||
}, [open, touched, total]);
|
||||
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, total - paidRials);
|
||||
const overpaid = paidRials > total;
|
||||
const remaining = Math.max(0, payable - paidRials);
|
||||
const overpaid = paidRials > payable;
|
||||
|
||||
const paymentState = paidRials === 0
|
||||
? 'بدون پرداخت'
|
||||
@@ -160,6 +194,8 @@ export default function ConfirmAppointmentModal({
|
||||
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 => ({
|
||||
@@ -254,6 +290,34 @@ export default function ConfirmAppointmentModal({
|
||||
</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={{
|
||||
@@ -271,14 +335,24 @@ export default function ConfirmAppointmentModal({
|
||||
<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>جمع کل</span>
|
||||
<strong style={{ fontSize: 16, color: 'var(--primary)' }}>{formatRial(total)}</strong>
|
||||
<span>{insuranceId !== '' ? 'سهم بیمار (قابل پرداخت)' : 'مبلغ قابل پرداخت'}</span>
|
||||
<strong style={{ fontSize: 16, color: 'var(--primary)' }}>{formatRial(payable)}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -397,7 +471,7 @@ export default function ConfirmAppointmentModal({
|
||||
|
||||
{overpaid && (
|
||||
<p className="field-err" style={{ marginBottom: 14 }}>
|
||||
مجموع پرداختها از جمع کل بیشتر است.
|
||||
مجموع پرداختها از مبلغ قابل پرداخت بیشتر است.
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -410,7 +484,7 @@ export default function ConfirmAppointmentModal({
|
||||
>
|
||||
<div style={rowStyle}>
|
||||
<span>پرداختشده</span>
|
||||
<strong style={{ color: 'var(--text)' }}>{formatRial(Math.min(paidRials, total))}</strong>
|
||||
<strong style={{ color: 'var(--text)' }}>{formatRial(Math.min(paidRials, payable))}</strong>
|
||||
</div>
|
||||
<div style={{ ...rowStyle, borderTop: '1px solid var(--border)' }}>
|
||||
<span>باقیمانده</span>
|
||||
|
||||
Reference in New Issue
Block a user