- Changed franchise_rials to franchise_percent in tenant_insurances and tenant_service_coverage tables. - Reset old rial values to 0/NULL as they are not convertible to percentage. feat(command): add SeedInsuranceScenarioCommand for seeding insurance data - Implemented a command to seed supplementary insurance contracts, patients, and claims for a specified doctor. - Includes functionality for purging existing scenario data and generating new entries with predefined contracts and patient scenarios.
287 lines
12 KiB
TypeScript
287 lines
12 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_percent: 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; // percent
|
||
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_percent != null ? String(c.franchise_percent) : '',
|
||
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)]));
|
||
}
|
||
|
||
/** درصد پوششِ قابل ثبت: عددی بین ۱ تا ۱۰۰ — صفر یعنی قرارداد آن نوع خدمت را پوشش نمیدهد. */
|
||
export function isValidPercent(raw?: string): boolean {
|
||
const n = Number(raw);
|
||
return raw !== undefined && raw !== '' && Number.isFinite(n) && n > 0 && n <= 100;
|
||
}
|
||
|
||
/**
|
||
* 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_percent: isBasic ? 0 : 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[];
|
||
/** نوع خدمتهای *فعالِ* همین tenant — یک ورودی درصد به ازای هر نوع رندر میشود. */
|
||
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) } }));
|
||
|
||
// بدون مجوز update درصدها اصلاً ارسال نمیشوند، پس اجبارشان هم بیمعناست.
|
||
const missingPercents = canUpdate
|
||
? categories.filter((c) => !isValidPercent(form.categoryPercents[c.key])).map((c) => c.key)
|
||
: [];
|
||
const franchiseInvalid = form.kind === 'supplementary' && form.franchise !== '' && Number(form.franchise) > 100;
|
||
const canSubmit = !!form.insuranceId && missingPercents.length === 0 && !franchiseInvalid;
|
||
|
||
const submit = () => {
|
||
if (!canSubmit) 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={!canSubmit || 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: missingPercents.includes(c.key) ? 'var(--danger)' : 'var(--text-3)' }}>
|
||
{!canUpdate
|
||
? 'مقدار پیشفرض تنظیمات مرکزی'
|
||
: missingPercents.includes(c.key)
|
||
? 'درصد پوشش الزامی است (۱ تا ۱۰۰)'
|
||
: 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"
|
||
aria-label="فرانشیز درصد"
|
||
value={form.franchise}
|
||
onChange={(e) => set({ franchise: digitsOnly(e.target.value, 3) })}
|
||
/>
|
||
<span style={{ fontSize: 11, color: franchiseInvalid ? 'var(--danger)' : 'var(--text-3)' }}>
|
||
{franchiseInvalid ? 'فرانشیز نمیتواند بیش از ۱۰۰ باشد' : 'سهم بیمار از مبلغ تحت پوشش'}
|
||
</span>
|
||
</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>
|
||
);
|
||
}
|