A clinic owner configures insurance on the doctor (`doctor_uuid`), but an
appointment booked at the clinic belongs to the clinic — so at confirm time
the engine looked for contracts under the clinic, found none, and the operator
had no insurance to pick and no way to save one ("this insurance has no active
contract"). The two sides were writing and reading different tenants.
Contracts, service kinds and the visit price now resolve doctor-first with the
appointment's clinic as fallback, each judged separately: a doctor who holds
their own contracts but leaves the visit price to the clinic gets each from the
right place. The confirm modal asks the same question the engine answers, via
`inherit=1` on the two read endpoints; the settings pages deliberately do not
send it, since editing must target the doctor's own row.
Two further things came out of the same sweep. The service-kind settings
repository had the tenant-filter blindness already fixed for contracts and
pricing — reads pinned to the caller's environment while the target is another
tenant — so it is now exempted the same way. And a coverage percentage of zero
is accepted as a real choice meaning "this contract does not cover that service
kind"; what is still rejected is leaving an enabled kind with no percentage at
all, inheriting a central default of zero included.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
292 lines
12 KiB
TypeScript
292 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>
|
||
);
|
||
}
|