Files
clinicpro/assets/admin/components/InsuranceModal.tsx
T
hamed 4f4bce9fe2 feat(migrations): update franchise to percentage in tenant_insurances and tenant_service_coverage
- 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.
2026-07-29 13:28:59 +03:30

287 lines
12 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_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>
);
}