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>
260 lines
11 KiB
TypeScript
260 lines
11 KiB
TypeScript
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>
|
||
);
|
||
}
|