Files
clinicpro/assets/admin/components/InsuranceModal.tsx
T
hamedandClaude Opus 5 58c6d9ac18 feat(insurance): resolve coverage percent per service category
Base insurance is a percentage-only rule: patient share is now total minus the
base share, and the contract franchise no longer inflates it (franchise stays
meaningful for supplementary contracts only).

Coverage percentages are managed centrally by admin per service category
(outpatient/inpatient, extensible via the ServiceCategory enum). A tenant
contract may override a category, otherwise it follows the admin default live —
changing the central value immediately applies to every contract that did not
override it.

- add ServiceCategory enum + GET /api/v1/service-categories as the single source
  of the category list for every client
- add insurance_coverage_defaults (+ GET/PUT admin coverage-defaults endpoints)
  and expose coverage_defaults on the insurance list and insurance-pricing
- add tenant_insurance_category_coverage; tenant-insurances accepts optional
  category_coverages (needs insurances.update) and returns the effective
  percentages with their source
- add service_items.service_category; visits always resolve as outpatient
- drop the reverse-engineered percent from patient_share_rials in MyPatientsPage
  and align the client-side BillingCalculator mirror in CreateStep

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

260 lines
11 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState } from 'react';
import Modal from './ui/Modal';
import SearchableSelect from './ui/SearchableSelect';
import PersianDateInput from './ui/PersianDateInput';
import { isoToUnix, rialToToman, tomanToRial, unixToIso } from '../lib/utils';
import { digitsOnly } from '../lib/utils';
import type { ServiceCategoryOption } from '../hooks/useServiceCategories';
export interface InsuranceOption {
insurance_id: number;
insurance_name: string;
type: string;
/** درصدهای پیش‌فرض تنظیمات مرکزی ادمین به تفکیک نوع خدمت. */
coverage_defaults?: Record<string, number>;
}
export interface Contract {
uuid: string;
insurance_id: number;
insurance_name: string | null;
insurance_kind: string | null;
version: number;
is_active: boolean;
coverage_percent: number;
franchise_rials: number;
annual_ceiling_rials: number | null;
kind: string | null;
effective_from: number;
effective_to: number | null;
/** درصد مؤثر هر نوع خدمت (override قرارداد یا پیش‌فرض مرکزی). */
category_coverages?: Record<string, number>;
/** منبع هر درصد: `override` | `admin_default` | `contract`. */
category_coverage_source?: Record<string, string>;
}
export interface InsuranceFormValues {
insuranceId: string;
kind: string;
effectiveFrom: string; // Y-m-d
effectiveTo: string; // Y-m-d
/** درصد پوشش به ازای هر نوع خدمت — کلید = key همان category. */
categoryPercents: Record<string, string>;
franchise: string; // toman
ceiling: string; // toman
}
export const KIND_LABEL: Record<string, string> = {
basic: 'پایه',
supplementary: 'تکمیلی',
};
export const SOURCE_ADMIN_DEFAULT = 'admin_default';
export const EMPTY_FORM: InsuranceFormValues = {
insuranceId: '', kind: 'basic', effectiveFrom: '', effectiveTo: '',
categoryPercents: {}, franchise: '', ceiling: '',
};
/** Map a contract to editable form values (rials → toman, unix → Y-m-d). */
export function contractToForm(c: Contract): InsuranceFormValues {
return {
insuranceId: String(c.insurance_id),
kind: c.kind ?? c.insurance_kind ?? 'basic',
effectiveFrom: unixToIso(c.effective_from),
effectiveTo: unixToIso(c.effective_to),
categoryPercents: percentsToStrings(c.category_coverages),
franchise: c.franchise_rials != null ? String(rialToToman(c.franchise_rials)) : '',
ceiling: c.annual_ceiling_rials != null ? String(rialToToman(c.annual_ceiling_rials)) : '',
};
}
function percentsToStrings(map?: Record<string, number>): Record<string, string> {
return Object.fromEntries(Object.entries(map ?? {}).map(([k, v]) => [k, String(v)]));
}
/**
* Build the API payload from form values (toman → rials, Y-m-d → unix).
* When `doctorUuid` is set, the contract is targeted at that doctor (multi-doctor
* clinic); otherwise it falls back to the caller's own tenant on the backend.
*
* `category_coverages` is only sent when the user may override percentages — the
* backend rejects it otherwise, and omitting it keeps the contract on the central
* admin defaults. فرانشیز فقط در قرارداد تکمیلی معنا دارد.
*/
export function buildInsurancePayload(
v: InsuranceFormValues,
doctorUuid?: string | null,
includeCategoryCoverages = true,
) {
const isBasic = v.kind !== 'supplementary';
const percents = Object.entries(v.categoryPercents);
return {
insurance_id: Number(v.insuranceId),
kind: v.kind || null,
// ستون قدیمی قرارداد؛ آخرین سطح fallback است و با درصد سرپایی همگام می‌ماند.
coverage_percent: Number(v.categoryPercents.outpatient ?? percents[0]?.[1] ?? 0) || 0,
franchise_rials: isBasic ? 0 : tomanToRial(Number(v.franchise) || 0),
annual_ceiling_rials: v.ceiling === '' ? null : tomanToRial(Number(v.ceiling)),
effective_from: isoToUnix(v.effectiveFrom),
effective_to: isoToUnix(v.effectiveTo),
...(includeCategoryCoverages
? { category_coverages: percents.map(([key, percent]) => ({ key, coverage_percent: Number(percent) || 0 })) }
: {}),
...(doctorUuid ? { doctor_uuid: doctorUuid } : {}),
};
}
interface Props {
open: boolean;
editContract: Contract | null;
/** Insurance catalog options; in edit mode all are shown, in add mode only the available ones. */
options: InsuranceOption[];
/** انواع خدمت از سرور — یک ورودی درصد به ازای هر نوع رندر می‌شود. */
categories: ServiceCategoryOption[];
/** Insurance kind of the active tab ('basic'|'supplementary'); assigned to new contracts, not user-editable. */
kind: string;
/** Target doctor in a multi-doctor clinic; threaded into the payload as `doctor_uuid`. */
doctorUuid?: string | null;
/** بدون این مجوز، درصدها فقط خواندنی‌اند و اصلاً ارسال نمی‌شوند. */
canUpdate?: boolean;
onClose: () => void;
onSubmit: (payload: ReturnType<typeof buildInsurancePayload>) => void;
isPending?: boolean;
}
/**
* Add/edit insurance contract modal (افزودن/ویرایش بیمه). Presentational: owns form
* state, emits the built payload via onSubmit. درصدهای پوشش به تفکیک نوع خدمت و
* پیش‌فرض‌گرفته از تنظیمات مرکزی ادمین.
*/
export default function InsuranceModal({
open, editContract, options, categories, kind, doctorUuid, canUpdate = true,
onClose, onSubmit, isPending,
}: Props) {
const [form, setForm] = useState<InsuranceFormValues>(EMPTY_FORM);
useEffect(() => {
if (!open) return;
setForm(editContract ? contractToForm(editContract) : { ...EMPTY_FORM, kind });
}, [open, editContract, kind]);
const set = (patch: Partial<InsuranceFormValues>) => setForm((f) => ({ ...f, ...patch }));
const isEdit = editContract !== null;
// در حالت افزودن، انتخاب بیمه درصدها را از تنظیمات مرکزی همان بیمه پر می‌کند.
const pickInsurance = (value: string) => {
const picked = options.find((i) => String(i.insurance_id) === value);
set({
insuranceId: value,
categoryPercents: value === '' ? {} : percentsToStrings(picked?.coverage_defaults),
});
};
const setPercent = (key: string, raw: string) =>
setForm((f) => ({ ...f, categoryPercents: { ...f.categoryPercents, [key]: digitsOnly(raw, 3) } }));
const submit = () => {
if (!form.insuranceId) return;
onSubmit(buildInsurancePayload(form, doctorUuid, canUpdate));
};
const field = { display: 'flex', flexDirection: 'column' as const, gap: 6 };
const label = { fontSize: 12, fontWeight: 600, color: 'var(--text-2)' };
const isBasic = form.kind !== 'supplementary';
const sourceOf = (key: string) => editContract?.category_coverage_source?.[key];
return (
<Modal
open={open}
title={isEdit ? 'ویرایش بیمه' : 'افزودن بیمه'}
size="md"
onClose={onClose}
footer={
<>
<button type="button" className="btn ghost" onClick={onClose}>لغو</button>
<button
type="button"
className="btn primary"
disabled={!form.insuranceId || isPending}
onClick={submit}
>
{isPending ? '...' : 'ثبت بیمه'}
</button>
</>
}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div style={field}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
<label style={label}>نام بیمه</label>
<span style={{
fontSize: 11, fontWeight: 600, padding: '2px 10px', borderRadius: 'var(--r-pill)',
background: 'var(--primary-soft)', color: 'var(--primary)',
}}>
{KIND_LABEL[form.kind] ?? form.kind}
</span>
</div>
<SearchableSelect
options={options.map((i) => ({ value: String(i.insurance_id), label: i.insurance_name }))}
value={form.insuranceId}
onChange={(v) => pickInsurance(v ? String(v) : '')}
isDisabled={isEdit}
placeholder="انتخاب کنید..."
/>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div style={field}>
<label style={label}>تاریخ شروع قرارداد</label>
<PersianDateInput value={form.effectiveFrom} onChange={(v) => set({ effectiveFrom: v })} placeholder="انتخاب" />
</div>
<div style={field}>
<label style={label}>تاریخ پایان قرارداد</label>
<PersianDateInput value={form.effectiveTo} onChange={(v) => set({ effectiveTo: v })} placeholder="انتخاب" />
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: `repeat(${Math.max(1, categories.length)}, 1fr)`, gap: 12 }}>
{categories.map((c) => (
<div key={c.key} style={field}>
<label style={label}>درصد پوشش {c.label}</label>
<input
type="text"
inputMode="numeric"
dir="ltr"
className="input"
readOnly={!canUpdate}
aria-label={`درصد پوشش ${c.label}`}
value={form.categoryPercents[c.key] ?? ''}
onChange={(e) => setPercent(c.key, e.target.value)}
/>
<span style={{ fontSize: 11, color: 'var(--text-3)' }}>
{!canUpdate
? 'مقدار پیش‌فرض تنظیمات مرکزی'
: sourceOf(c.key) === SOURCE_ADMIN_DEFAULT
? 'پیش‌فرض ادمین'
: ' '}
</span>
</div>
))}
</div>
<div style={{ display: 'grid', gridTemplateColumns: isBasic ? '1fr' : '1fr 1fr', gap: 12 }}>
{!isBasic && (
<div style={field}>
<label style={label}>فرانشیز (تومان)</label>
<input type="text" inputMode="numeric" dir="ltr" className="input" value={form.franchise} onChange={(e) => set({ franchise: digitsOnly(e.target.value) })} />
</div>
)}
<div style={field}>
<label style={label}>سقف تعهد (تومان)</label>
<input type="text" inputMode="numeric" dir="ltr" className="input" placeholder="بی‌نهایت" value={form.ceiling} onChange={(e) => set({ ceiling: digitsOnly(e.target.value) })} />
</div>
</div>
</div>
</Modal>
);
}