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>
This commit is contained in:
hamed
2026-07-25 16:21:19 +03:30
co-authored by Claude Opus 5
parent 1a9eda3576
commit 58c6d9ac18
41 changed files with 2558 additions and 143 deletions
@@ -0,0 +1,109 @@
import { useEffect, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { api } from '../lib/api';
import type { ApiResponse } from '../lib/api';
import { digitsOnly } from '../lib/utils';
import Modal from './ui/Modal';
interface CoverageDefaultRow {
key: string;
label: string;
coverage_percent: number;
}
interface Props {
/** بیمهٔ هدف؛ `null` یعنی مودال بسته است. */
insurance: { id: number; name: string; type: 'basic' | 'supplementary' } | null;
onClose: () => void;
}
/**
* تنظیمات مرکزی درصد پوشش یک بیمه به تفکیک نوع خدمت (سرپایی/بستری/…).
* لیست نوع‌ها از سرور می‌آید؛ قراردادهای پزشک/کلینیک که override نکرده‌اند
* همین مقادیر را زنده می‌خوانند.
*/
export default function InsuranceCoverageDefaultsModal({ insurance, onClose }: Props) {
const qc = useQueryClient();
const [percents, setPercents] = useState<Record<string, string>>({});
const { data, isLoading } = useQuery<ApiResponse<{ insurance_id: number; categories: CoverageDefaultRow[] }>>({
queryKey: ['insurance-coverage-defaults', insurance?.id],
queryFn: () => api.get(`/api/v1/admin/insurance/${insurance!.id}/coverage-defaults`),
enabled: insurance !== null,
});
const rows = data?.data?.categories ?? [];
useEffect(() => {
if (rows.length === 0) return;
setPercents(Object.fromEntries(rows.map((r) => [r.key, String(r.coverage_percent)])));
}, [data]);
const save = useMutation({
mutationFn: () => api.put(`/api/v1/admin/insurance/${insurance!.id}/coverage-defaults`, {
categories: rows.map((r) => ({ key: r.key, coverage_percent: Number(percents[r.key] || 0) })),
}),
onSuccess: () => {
toast.success('درصدهای پوشش ذخیره شد');
qc.invalidateQueries({ queryKey: ['admin-insurances'] });
qc.invalidateQueries({ queryKey: ['insurance-coverage-defaults', insurance?.id] });
qc.invalidateQueries({ queryKey: ['insurance-pricing'] });
onClose();
},
onError: (e: Error) => toast.error(e.message),
});
const invalid = rows.some((r) => Number(percents[r.key] || 0) > 100);
return (
<Modal
open={insurance !== null}
title={`تنظیمات پوشش — ${insurance?.name ?? ''}`}
size="sm"
onClose={onClose}
footer={
<>
<button onClick={onClose} className="btn ghost sm">لغو</button>
<button
onClick={() => save.mutate()}
disabled={save.isPending || isLoading || invalid}
className="btn primary sm"
>
{save.isPending ? 'در حال ذخیره...' : 'ذخیره'}
</button>
</>
}
>
<p style={{ margin: '0 0 14px', fontSize: 12, lineHeight: 1.8, color: 'var(--text-2)' }}>
{insurance?.type === 'supplementary'
? 'سهم بیمهٔ تکمیلی روی «باقیماندهٔ پس از بیمهٔ پایه» اعمال می‌شود.'
: 'سهم بیمهٔ پایه از مبلغ کل خدمت محاسبه می‌شود؛ باقیمانده سهم بیمار است.'}
{' '}این مقادیر پیشفرض همهٔ پزشکان و کلینیکها است و قراردادهایی که درصد اختصاصی
تعیین نکردهاند، با تغییر همین اعداد بهروز میشوند.
</p>
{isLoading ? (
<p className="muted" style={{ fontSize: 12.5 }}>در حال بارگذاری...</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{rows.map((row) => (
<div key={row.key} className="form-row">
<label>{row.label} (درصد)</label>
<input
className="input"
inputMode="numeric"
value={percents[row.key] ?? ''}
onChange={(e) => setPercents((p) => ({ ...p, [row.key]: digitsOnly(e.target.value, 3) }))}
placeholder="مثلاً: ۳۰"
/>
{Number(percents[row.key] || 0) > 100 && (
<p className="err-text">درصد نمیتواند بیشتر از ۱۰۰ باشد</p>
)}
</div>
))}
</div>
)}
</Modal>
);
}
+73 -20
View File
@@ -4,29 +4,43 @@ import { renderWithProviders } from '../test/utils';
import InsuranceModal, {
buildInsurancePayload, contractToForm, EMPTY_FORM, type Contract, type InsuranceOption,
} from './InsuranceModal';
import type { ServiceCategoryOption } from '../hooks/useServiceCategories';
const mkContract = (over: Partial<Contract> = {}): Contract => ({
uuid: 'c-1', insurance_id: 3, insurance_name: 'بیمه ایران', insurance_kind: 'basic',
version: 1, is_active: true, coverage_percent: 70, franchise_rials: 500_000,
annual_ceiling_rials: 20_000_000, kind: 'basic', effective_from: 1_700_000_000,
effective_to: null, ...over,
effective_to: null,
category_coverages: { outpatient: 70, inpatient: 30 },
category_coverage_source: { outpatient: 'override', inpatient: 'admin_default' },
...over,
});
const options: InsuranceOption[] = [
{ insurance_id: 3, insurance_name: 'بیمه ایران', type: 'basic' },
{ insurance_id: 5, insurance_name: 'بیمه آسیا', type: 'supplementary' },
{ insurance_id: 3, insurance_name: 'بیمه ایران', type: 'basic', coverage_defaults: { outpatient: 70, inpatient: 30 } },
{ insurance_id: 5, insurance_name: 'بیمه آسیا', type: 'supplementary', coverage_defaults: { outpatient: 40, inpatient: 20 } },
];
const categories: ServiceCategoryOption[] = [
{ key: 'outpatient', label: 'خدمات سرپایی' },
{ key: 'inpatient', label: 'خدمات بستری' },
];
describe('buildInsurancePayload', () => {
it('converts toman → rials, percent, and Y-m-d → unix', () => {
it('converts toman → rials, per-category percents, and Y-m-d → unix', () => {
const payload = buildInsurancePayload({
...EMPTY_FORM, insuranceId: '3', kind: 'supplementary',
coverage: '80', franchise: '50000', ceiling: '2000000',
categoryPercents: { outpatient: '80', inpatient: '30' },
franchise: '50000', ceiling: '2000000',
effectiveFrom: '2024-01-01', effectiveTo: '2025-01-01',
});
expect(payload.insurance_id).toBe(3);
expect(payload.kind).toBe('supplementary');
expect(payload.coverage_percent).toBe(80);
expect(payload.coverage_percent).toBe(80); // ستون قدیمی = درصد سرپایی
expect(payload.category_coverages).toEqual([
{ key: 'outpatient', coverage_percent: 80 },
{ key: 'inpatient', coverage_percent: 30 },
]);
expect(payload.franchise_rials).toBe(500_000); // 50000 toman × 10
expect(payload.annual_ceiling_rials).toBe(20_000_000);
expect(typeof payload.effective_from).toBe('number');
@@ -34,56 +48,95 @@ describe('buildInsurancePayload', () => {
});
it('empty ceiling → null (بی‌نهایت), empty dates → null', () => {
const payload = buildInsurancePayload({ ...EMPTY_FORM, insuranceId: '3', coverage: '50' });
const payload = buildInsurancePayload({
...EMPTY_FORM, insuranceId: '3', categoryPercents: { outpatient: '50' },
});
expect(payload.annual_ceiling_rials).toBeNull();
expect(payload.effective_from).toBeNull();
expect(payload.effective_to).toBeNull();
});
it('forces franchise to zero on a basic contract', () => {
const payload = buildInsurancePayload({
...EMPTY_FORM, insuranceId: '3', kind: 'basic', franchise: '50000',
categoryPercents: { outpatient: '70' },
});
expect(payload.franchise_rials).toBe(0);
});
it('omits category_coverages when the user may not override them', () => {
const payload = buildInsurancePayload(
{ ...EMPTY_FORM, insuranceId: '3', categoryPercents: { outpatient: '70' } },
null,
false,
);
expect(payload).not.toHaveProperty('category_coverages');
});
});
describe('contractToForm', () => {
it('maps rials → toman and uses contract kind', () => {
it('maps rials → toman, contract kind, and effective category percents', () => {
const form = contractToForm(mkContract({ franchise_rials: 300_000, kind: 'supplementary' }));
expect(form.franchise).toBe('30000');
expect(form.kind).toBe('supplementary');
expect(form.coverage).toBe('70');
expect(form.categoryPercents).toEqual({ outpatient: '70', inpatient: '30' });
});
});
describe('InsuranceModal', () => {
it('renders the fields in add mode with no manual kind select', () => {
it('renders one percent input per category and hides franchise on basic', () => {
renderWithProviders(
<InsuranceModal open editContract={null} options={options} kind="basic" onClose={() => {}} onSubmit={() => {}} />,
<InsuranceModal open editContract={null} options={options} categories={categories} kind="basic" onClose={() => {}} onSubmit={() => {}} />,
);
expect(screen.getByText('افزودن بیمه')).toBeInTheDocument();
expect(screen.getByText('نام بیمه')).toBeInTheDocument();
// Manual "نوع بیمه" select is gone; kind is shown as a read-only chip from the tab.
expect(screen.queryByText('نوع بیمه')).not.toBeInTheDocument();
expect(screen.getByText('پایه')).toBeInTheDocument();
expect(screen.getByText('تاریخ شروع قرارداد')).toBeInTheDocument();
expect(screen.getByText('تاریخ پایان قرارداد')).toBeInTheDocument();
expect(screen.getByText('درصد پوشش')).toBeInTheDocument();
expect(screen.getByText('فرانشیز (تومان)')).toBeInTheDocument();
expect(screen.getByText('درصد پوشش — خدمات سرپایی')).toBeInTheDocument();
expect(screen.getByText('درصد پوشش — خدمات بستری')).toBeInTheDocument();
expect(screen.queryByText('فرانشیز (تومان)')).not.toBeInTheDocument();
expect(screen.getByText('سقف تعهد (تومان)')).toBeInTheDocument();
expect(screen.getByText('ثبت بیمه')).toBeInTheDocument();
});
it('shows the tab kind chip and carries it into a new payload', () => {
const onSubmit = vi.fn();
it('shows the franchise field for a supplementary contract', () => {
renderWithProviders(
<InsuranceModal open editContract={null} options={options} kind="supplementary" onClose={() => {}} onSubmit={onSubmit} />,
<InsuranceModal open editContract={null} options={options} categories={categories} kind="supplementary" onClose={() => {}} onSubmit={() => {}} />,
);
expect(screen.getByText('تکمیلی')).toBeInTheDocument();
expect(screen.getByText('فرانشیز (تومان)')).toBeInTheDocument();
});
it('prefills the percents of an edited contract and marks admin defaults', () => {
renderWithProviders(
<InsuranceModal open editContract={mkContract()} options={options} categories={categories} kind="basic" onClose={() => {}} onSubmit={() => {}} />,
);
expect(screen.getByLabelText('درصد پوشش خدمات سرپایی')).toHaveValue('70');
expect(screen.getByLabelText('درصد پوشش خدمات بستری')).toHaveValue('30');
expect(screen.getByText('پیش‌فرض ادمین')).toBeInTheDocument();
});
it('makes the percents read-only without the update permission', () => {
renderWithProviders(
<InsuranceModal open editContract={mkContract()} options={options} categories={categories} kind="basic" canUpdate={false} onClose={() => {}} onSubmit={() => {}} />,
);
expect(screen.getByLabelText('درصد پوشش خدمات سرپایی')).toHaveAttribute('readonly');
expect(screen.getAllByText('مقدار پیش‌فرض تنظیمات مرکزی').length).toBe(2);
});
it('submits the built payload for an edited contract', () => {
const onSubmit = vi.fn();
renderWithProviders(
<InsuranceModal open editContract={mkContract()} options={options} kind="basic" onClose={() => {}} onSubmit={onSubmit} />,
<InsuranceModal open editContract={mkContract()} options={options} categories={categories} kind="basic" onClose={() => {}} onSubmit={onSubmit} />,
);
fireEvent.click(screen.getByText('ثبت بیمه'));
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({
insurance_id: 3, coverage_percent: 70, franchise_rials: 500_000, kind: 'basic',
insurance_id: 3, coverage_percent: 70, franchise_rials: 0, kind: 'basic',
category_coverages: [
{ key: 'outpatient', coverage_percent: 70 },
{ key: 'inpatient', coverage_percent: 30 },
],
}));
});
});
+93 -20
View File
@@ -4,11 +4,14 @@ 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 {
@@ -24,6 +27,10 @@ export interface Contract {
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 {
@@ -31,7 +38,8 @@ export interface InsuranceFormValues {
kind: string;
effectiveFrom: string; // Y-m-d
effectiveTo: string; // Y-m-d
coverage: string;
/** درصد پوشش به ازای هر نوع خدمت — کلید = key همان category. */
categoryPercents: Record<string, string>;
franchise: string; // toman
ceiling: string; // toman
}
@@ -41,9 +49,11 @@ export const KIND_LABEL: Record<string, string> = {
supplementary: 'تکمیلی',
};
export const SOURCE_ADMIN_DEFAULT = 'admin_default';
export const EMPTY_FORM: InsuranceFormValues = {
insuranceId: '', kind: 'basic', effectiveFrom: '', effectiveTo: '',
coverage: '', franchise: '', ceiling: '',
categoryPercents: {}, franchise: '', ceiling: '',
};
/** Map a contract to editable form values (rials → toman, unix → Y-m-d). */
@@ -53,26 +63,45 @@ export function contractToForm(c: Contract): InsuranceFormValues {
kind: c.kind ?? c.insurance_kind ?? 'basic',
effectiveFrom: unixToIso(c.effective_from),
effectiveTo: unixToIso(c.effective_to),
coverage: String(c.coverage_percent ?? ''),
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) {
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,
coverage_percent: Number(v.coverage) || 0,
franchise_rials: tomanToRial(Number(v.franchise) || 0),
// ستون قدیمی قرارداد؛ آخرین سطح 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 } : {}),
};
}
@@ -82,10 +111,14 @@ interface Props {
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;
@@ -93,10 +126,13 @@ interface Props {
/**
* Add/edit insurance contract modal (افزودن/ویرایش بیمه). Presentational: owns form
* state, emits the built payload via onSubmit. Fields mirror the Figma "افزودن بیمه"
* modal plus the injected coverage/franchise/ceiling controls.
* state, emits the built payload via onSubmit. درصدهای پوشش به تفکیک نوع خدمت و
* پیش‌فرض‌گرفته از تنظیمات مرکزی ادمین.
*/
export default function InsuranceModal({ open, editContract, options, kind, doctorUuid, onClose, onSubmit, isPending }: Props) {
export default function InsuranceModal({
open, editContract, options, categories, kind, doctorUuid, canUpdate = true,
onClose, onSubmit, isPending,
}: Props) {
const [form, setForm] = useState<InsuranceFormValues>(EMPTY_FORM);
useEffect(() => {
@@ -107,13 +143,27 @@ export default function InsuranceModal({ open, editContract, options, kind, doct
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));
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
@@ -149,7 +199,7 @@ export default function InsuranceModal({ open, editContract, options, kind, doct
<SearchableSelect
options={options.map((i) => ({ value: String(i.insurance_id), label: i.insurance_name }))}
value={form.insuranceId}
onChange={(v) => set({ insuranceId: v ? String(v) : '' })}
onChange={(v) => pickInsurance(v ? String(v) : '')}
isDisabled={isEdit}
placeholder="انتخاب کنید..."
/>
@@ -166,15 +216,38 @@ export default function InsuranceModal({ open, editContract, options, kind, doct
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 12 }}>
<div style={field}>
<label style={label}>درصد پوشش</label>
<input type="text" inputMode="numeric" dir="ltr" className="input" value={form.coverage} onChange={(e) => set({ coverage: digitsOnly(e.target.value, 3) })} />
</div>
<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={{ 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) })} />
@@ -11,6 +11,7 @@ import type { ServiceItem, ClinicStaff } from '../types';
import type { InventoryPackage, InventoryItem } from '../hooks/useInventory';
import { rialToToman, tomanToRial } from '../lib/utils';
import { numericField } from '../lib/forms';
import { useServiceCategories } from '../hooks/useServiceCategories';
import Modal from './ui/Modal';
import PriceInput from './ui/PriceInput';
import SearchableSelect from './ui/SearchableSelect';
@@ -21,6 +22,8 @@ const itemSchema = z.object({
staff_uuids: z.array(z.string()).optional(),
duration_minutes: z.coerce.number().min(0).optional(),
bookable: z.boolean().optional(),
/** نوع خدمت (سرپایی/بستری) — مبنای انتخاب درصد پوشش بیمه. */
service_category: z.string().min(1, 'نوع خدمت الزامی است'),
/** پکیج کالای مصرفی؛ رشته‌ی خالی یعنی بدون پکیج. */
inventory_package_uuid: z.string().optional(),
/** اقلام کالای تکی — مستقل از پکیج. */
@@ -28,9 +31,12 @@ const itemSchema = z.object({
});
type ItemForm = z.infer<typeof itemSchema>;
const DEFAULT_SERVICE_CATEGORY = 'outpatient';
const EMPTY_FORM: ItemForm = {
name: '', price_rials: 0, staff_uuids: [], duration_minutes: undefined,
bookable: false, inventory_package_uuid: '', consumables: [],
bookable: false, service_category: DEFAULT_SERVICE_CATEGORY,
inventory_package_uuid: '', consumables: [],
};
interface Props {
@@ -75,6 +81,8 @@ export default function ServiceItemFormModal({ item, sectionUuid, onClose, onMan
});
const inventoryItems = inventoryData?.data?.items ?? [];
const { categories } = useServiceCategories(item !== null);
const form = useForm<ItemForm>({ resolver: zodResolver(itemSchema), defaultValues: EMPTY_FORM });
useEffect(() => {
@@ -86,6 +94,7 @@ export default function ServiceItemFormModal({ item, sectionUuid, onClose, onMan
staff_uuids: (editing.staff_members ?? (editing.staff ? [editing.staff] : [])).map((s) => s.uuid),
duration_minutes: editing.duration_minutes ?? undefined,
bookable: editing.bookable ?? false,
service_category: editing.service_category ?? DEFAULT_SERVICE_CATEGORY,
inventory_package_uuid: editing.inventory_package_uuid ?? '',
consumables: (editing.consumables ?? []).map((c) => ({ item_uuid: c.item_uuid, amount: c.amount })),
}
@@ -242,6 +251,21 @@ export default function ServiceItemFormModal({ item, sectionUuid, onClose, onMan
</label>
</div>
<div>
<label className="field-label">نوع خدمت *</label>
<SearchableSelect
options={categories.map((c) => ({ value: c.key, label: c.label }))}
value={form.watch('service_category') || null}
onChange={(v) => { if (v != null) form.setValue('service_category', String(v)); }}
placeholder="انتخاب نوع خدمت"
noOptionsMessage="نوعی تعریف نشده است"
height={42}
/>
<span style={{ display: 'block', marginTop: 6, fontSize: 11.5, color: 'var(--text-3)' }}>
درصد پوشش بیمه بر اساس همین نوع محاسبه میشود.
</span>
</div>
<div>
<label className="field-label">پکیج کالای مصرفی</label>
<SearchableSelect
@@ -38,18 +38,39 @@ describe('filterInsurances', () => {
});
});
const categories = [
{ key: 'outpatient', label: 'سرپایی' },
{ key: 'inpatient', label: 'بستری' },
];
describe('contractSummary', () => {
it('shows coverage, franchise and ceiling inline', () => {
const s = contractSummary(mk({ coverage_percent: 90, franchise_rials: 500_000, annual_ceiling_rials: 20_000_000 }));
expect(s).toContain('پوشش');
expect(s).toContain('فرانشیز');
it('breaks the coverage down per service category', () => {
const s = contractSummary(
mk({ category_coverages: { outpatient: 70, inpatient: 30 }, annual_ceiling_rials: 20_000_000 }),
categories,
);
expect(s).toContain('سرپایی ۷۰٪');
expect(s).toContain('بستری ۳۰٪');
expect(s).toContain('سقف پوشش');
});
it('omits franchise when zero and marks unlimited ceiling', () => {
const s = contractSummary(mk({ franchise_rials: 0, annual_ceiling_rials: null }));
expect(s).not.toContain('فرانشیز');
it('shows the franchise only on a supplementary contract', () => {
const basic = contractSummary(mk({ franchise_rials: 500_000, insurance_kind: 'basic' }), categories);
expect(basic).not.toContain('فرانشیز');
const supp = contractSummary(mk({ franchise_rials: 500_000, insurance_kind: 'supplementary' }), categories);
expect(supp).toContain('فرانشیز');
});
it('marks an unlimited ceiling', () => {
const s = contractSummary(mk({ franchise_rials: 0, annual_ceiling_rials: null }), categories);
expect(s).toContain('سقف پوشش نامحدود');
});
it('falls back to the legacy contract percent with no category rows', () => {
const s = contractSummary(mk({ coverage_percent: 90, category_coverages: undefined }), categories);
expect(s).toContain('پوشش ۹۰٪');
});
});
describe('TenantInsuranceContracts', () => {
@@ -7,9 +7,10 @@ import type { ApiResponse } from '../lib/api';
import { formatRial, formatNumber, formatDate } from '../lib/utils';
import { useAuthStore } from '../stores/authStore';
import { usePermissions } from '../hooks/usePermissions';
import { useServiceCategories, type ServiceCategoryOption } from '../hooks/useServiceCategories';
import SearchableSelect from './ui/SearchableSelect';
import type { ClinicDoctorItem } from './ClinicDoctorsManager';
import InsuranceModal, { Contract, InsuranceOption, KIND_LABEL, buildInsurancePayload } from './InsuranceModal';
import InsuranceModal, { Contract, InsuranceOption, KIND_LABEL, SOURCE_ADMIN_DEFAULT, buildInsurancePayload } from './InsuranceModal';
type Kind = 'basic' | 'supplementary';
@@ -32,11 +33,22 @@ export function filterInsurances(list: Contract[], query: string): Contract[] {
);
}
/** One-line readable summary shown on the collapsed row: پوشش ۹۰٪ · فرانشیز … · سقف پوشش … */
export function contractSummary(c: Contract): string {
const parts = [`پوشش ${formatNumber(c.coverage_percent)}٪`];
if (c.franchise_rials > 0) parts.push(`فرانشیز ${formatRial(c.franchise_rials)}`);
/**
* One-line readable summary shown on the collapsed row:
* سرپایی ۷۰٪ · بستری ۳۰٪ · سقف پوشش … — فرانشیز فقط در قراردادهای تکمیلی.
*/
export function contractSummary(c: Contract, categories: ServiceCategoryOption[] = []): string {
const percents = c.category_coverages ?? {};
const labelled = categories.length > 0
? categories.filter((cat) => cat.key in percents).map((cat) => `${cat.label} ${formatNumber(percents[cat.key])}٪`)
: Object.entries(percents).map(([key, percent]) => `${key} ${formatNumber(percent)}٪`);
const parts = labelled.length > 0 ? labelled : [`پوشش ${formatNumber(c.coverage_percent)}٪`];
if (c.insurance_kind === 'supplementary' && c.franchise_rials > 0) {
parts.push(`فرانشیز ${formatRial(c.franchise_rials)}`);
}
parts.push(c.annual_ceiling_rials != null ? `سقف پوشش ${formatRial(c.annual_ceiling_rials)}` : 'سقف پوشش نامحدود');
return parts.join(' · ');
}
@@ -89,6 +101,8 @@ export default function TenantInsuranceContracts() {
queryFn: () => api.get(`/api/v1/billing/tenant-insurances${dq}`),
});
const { categories } = useServiceCategories();
const pricingQuery = useQuery({
queryKey: ['insurance-pricing', doctorUuid],
queryFn: () => api.get(`/api/v1/insurance-pricing${dq}`),
@@ -227,6 +241,7 @@ export default function TenantInsuranceContracts() {
<ContractCard
key={c.uuid}
contract={c}
categories={categories}
open={expanded === c.uuid}
onToggleRow={() => toggleRow(c.uuid)}
onEdit={() => openEdit(c)}
@@ -242,8 +257,10 @@ export default function TenantInsuranceContracts() {
open={modalOpen}
editContract={editContract}
options={editContract ? allInsurances : available}
categories={categories}
kind={editContract ? kindOf(editContract) : tab}
doctorUuid={doctorUuid}
canUpdate={canUpdate}
onClose={closeModal}
onSubmit={(payload) => saveMut.mutate(payload)}
isPending={saveMut.isPending}
@@ -254,6 +271,7 @@ export default function TenantInsuranceContracts() {
interface RowProps {
contract: Contract;
categories: ServiceCategoryOption[];
open: boolean;
onToggleRow: () => void;
onEdit: () => void;
@@ -262,7 +280,7 @@ interface RowProps {
canUpdate?: boolean;
}
function ContractCard({ contract: c, open, onToggleRow, onEdit, onToggleStatus, statusPending, canUpdate }: RowProps) {
function ContractCard({ contract: c, categories, open, onToggleRow, onEdit, onToggleStatus, statusPending, canUpdate }: RowProps) {
const stop = (fn: () => void) => (e: React.MouseEvent) => { e.stopPropagation(); fn(); };
return (
<div style={{ border: '1px solid var(--border)', borderRadius: 12, padding: 14, cursor: 'pointer' }} onClick={onToggleRow}>
@@ -277,27 +295,45 @@ function ContractCard({ contract: c, open, onToggleRow, onEdit, onToggleStatus,
</button>
)}
</div>
<div style={{ fontSize: 12, color: 'var(--text-2)', marginBottom: 8 }}>{contractSummary(c)}</div>
<div style={{ fontSize: 12, color: 'var(--text-2)', marginBottom: 8 }}>{contractSummary(c, categories)}</div>
{canUpdate && (
<div style={{ display: 'flex', justifyContent: 'flex-end' }} onClick={stop(() => {})}>
<StatusToggle contract={c} onToggle={stop(onToggleStatus)} disabled={statusPending} />
</div>
)}
{open && <div style={{ marginTop: 10 }}><ContractDetails contract={c} /></div>}
{open && <div style={{ marginTop: 10 }}><ContractDetails contract={c} categories={categories} /></div>}
</div>
);
}
/** Expanded full detail of a contract (all fields), shown when its card is open. */
function ContractDetails({ contract: c }: { contract: Contract }) {
function ContractDetails({ contract: c, categories }: { contract: Contract; categories: ServiceCategoryOption[] }) {
const percents = c.category_coverages ?? {};
const isSupplementary = c.insurance_kind === 'supplementary';
return (
<div style={{
background: 'var(--surface-2)', border: '1px solid var(--border-2)', borderRadius: 'var(--r-sm)',
padding: 14,
display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(150px, 1fr))', gap: 14,
}}>
<DetailCell label="درصد پوشش" value={`${formatNumber(c.coverage_percent)}٪`} />
<DetailCell label="فرانشیز" value={c.franchise_rials > 0 ? formatRial(c.franchise_rials) : '—'} />
{categories.filter((cat) => cat.key in percents).map((cat) => (
<DetailCell
key={cat.key}
label={`درصد پوشش — ${cat.label}`}
value={
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
{formatNumber(percents[cat.key])}٪
{c.category_coverage_source?.[cat.key] === SOURCE_ADMIN_DEFAULT && (
<span style={{ fontSize: 10.5, fontWeight: 600, color: 'var(--text-3)' }}>پیشفرض ادمین</span>
)}
</span>
}
/>
))}
{isSupplementary && (
<DetailCell label="فرانشیز" value={c.franchise_rials > 0 ? formatRial(c.franchise_rials) : '—'} />
)}
<DetailCell label="سقف تعهد سالانه" value={c.annual_ceiling_rials != null ? formatRial(c.annual_ceiling_rials) : 'نامحدود'} />
<DetailCell label="تاریخ شروع قرارداد" value={formatDate(c.effective_from)} />
<DetailCell label="تاریخ پایان قرارداد" value={c.effective_to != null ? formatDate(c.effective_to) : 'بدون تاریخ پایان'} />
@@ -8,7 +8,7 @@ vi.mock('../../lib/api', () => ({
}));
import { api } from '../../lib/api';
import CreateStep from './CreateStep';
import CreateStep, { patientShareOf } from './CreateStep';
const get = api.get as ReturnType<typeof vi.fn>;
const post = api.post as ReturnType<typeof vi.fn>;
@@ -81,3 +81,34 @@ describe('CreateStep — الزامی بودن قیمت ویزیت با فلگ r
await waitFor(() => expect(screen.getByLabelText('قیمت ویزیت')).toHaveValue('30000'));
});
});
describe('patientShareOf — آینه‌ی BillingCalculator', () => {
it('سناریوی مرجع: ۳۰٪ پوشش پایه روی ۵,۹۵۲,۰۰۰ ریال', () => {
const share = patientShareOf(5_952_000, { covered: true, percent: 30, franchise: 0, ceiling: null }, null);
expect(share).toBe(4_166_400);
});
it('فرانشیز بیمهٔ پایه سهم بیمار را زیاد نمی‌کند', () => {
const share = patientShareOf(600_000, { covered: true, percent: 100, franchise: 50_000, ceiling: null }, null);
expect(share).toBe(0);
});
it('فرانشیز بیمهٔ تکمیلی به سهم بیمار اضافه می‌شود', () => {
const share = patientShareOf(
600_000,
{ covered: true, percent: 70, franchise: 90_000, ceiling: null },
{ covered: true, percent: 100, franchise: 50_000, ceiling: null },
);
expect(share).toBe(50_000);
});
it('سقف تعهد سهم بیمه را محدود می‌کند', () => {
const share = patientShareOf(600_000, { covered: true, percent: 70, franchise: 0, ceiling: 300_000 }, null);
expect(share).toBe(300_000);
});
it('notCovered → کل مبلغ سهم بیمار', () => {
const share = patientShareOf(600_000, { covered: false, percent: 0, franchise: 0, ceiling: null }, null);
expect(share).toBe(600_000);
});
});
+42 -15
View File
@@ -12,15 +12,21 @@ import { UserTick, FilesServiceAddCard, ClockP, TrashRed } from '../icons/FilesS
import { useAuthStore } from '../../stores/authStore';
import { digitsOnly } from '../../lib/utils';
interface Contract { uuid: string; insurance_id: number; insurance_name: string | null; insurance_kind: string | null; coverage_percent: number; franchise_rials: number; annual_ceiling_rials: number | null }
interface Contract { uuid: string; insurance_id: number; insurance_name: string | null; insurance_kind: string | null; coverage_percent: number; franchise_rials: number; annual_ceiling_rials: number | null; category_coverages?: Record<string, number> }
interface CoverageRow { service_item_uuid: string | null; covered: boolean; coverage_percent: number | null; franchise_rials: number | null; ceiling_rials: number | null }
interface Rule { covered: boolean; percent: number; franchise: number; ceiling: number | null }
interface InventoryItemRow { uuid: string; name: string; unit: string; price: number; stock: number; status: string }
interface PackageRow { uuid: string; title: string; total: number; available: boolean }
interface StaffRow { uuid: string; full_name: string; active?: boolean }
// آینه‌ی BillingCalculator سمت سرور: سهم بیمار یک خدمت با پوشش پایه/مکمل.
function patientShareOf(total: number, base: Rule | null, supp: Rule | null): number {
/** ویزیت خدمتِ سرپایی است. */
const VISIT_SERVICE_CATEGORY = 'outpatient';
/**
* آینه‌ی BillingCalculator سمت سرور: سهم بیمار یک خدمت با پوشش پایه/مکمل.
* فرانشیز فقط در بیمهٔ تکمیلی اثر دارد؛ بیمهٔ پایه صرفاً درصدی است.
*/
export function patientShareOf(total: number, base: Rule | null, supp: Rule | null): number {
let baseShare = 0;
let remaining = total;
if (base && base.covered) {
@@ -34,8 +40,7 @@ function patientShareOf(total: number, base: Rule | null, supp: Rule | null): nu
if (supp.ceiling !== null) suppShare = Math.min(suppShare, supp.ceiling);
remaining = remaining - suppShare;
}
const franchise = (base?.franchise ?? 0) + (supp?.franchise ?? 0);
return Math.min(remaining + franchise, total);
return Math.min(remaining + (supp?.franchise ?? 0), total);
}
const todayISO = () => new Date().toISOString().slice(0, 10);
@@ -75,7 +80,7 @@ export default function CreateStep({ recordUuid, profile, onCreated, onCancel, e
const [sectionUuid, setSectionUuid] = useState('');
const [itemUuid, setItemUuid] = useState('');
const [staffUuid, setStaffUuid] = useState('');
const [selectedServices, setSelectedServices] = useState<{ uuid: string; name: string; price: number; qty: number; insured: boolean }[]>([]);
const [selectedServices, setSelectedServices] = useState<{ uuid: string; name: string; price: number; qty: number; insured: boolean; category: string }[]>([]);
const [consumableUuid, setConsumableUuid] = useState('');
const [selectedConsumables, setSelectedConsumables] = useState<{ uuid: string; name: string; price: number; qty: number }[]>([]);
const [packageUuid, setPackageUuid] = useState('');
@@ -138,6 +143,7 @@ export default function CreateStep({ recordUuid, profile, onCreated, onCancel, e
if (editSession.insurance_supplementary_id) { setSuppId(String(editSession.insurance_supplementary_id)); setSuppPercent(String(editSession.supplementary_discount_percent ?? 0)); }
setSelectedServices((editSession.services ?? []).map((s) => ({
uuid: s.service_item_uuid ?? '', name: s.service_name || s.name || '', price: s.price_rials ?? 0, qty: s.quantity ?? 1, insured: false,
category: VISIT_SERVICE_CATEGORY,
})).filter((s) => s.uuid));
setSelectedConsumables((editSession.consumables ?? []).map((c) => ({
uuid: c.inventory_item_uuid ?? '', name: c.item_name ?? '', price: c.price_rials ?? 0, qty: c.quantity ?? 1,
@@ -179,20 +185,31 @@ export default function CreateStep({ recordUuid, profile, onCreated, onCancel, e
const baseCoverage = (baseCoverageQ.data as any)?.data?.data as CoverageRow[] | undefined ?? [];
const suppCoverage = (suppCoverageQ.data as any)?.data?.data as CoverageRow[] | undefined ?? [];
// قاعده‌ی پوشش یک خدمت تحت یک قرارداد: override خدمت اگر باشد، وگرنه پیش‌فرض قرارداد.
const ruleFor = (contract: Contract | null, coverage: CoverageRow[], serviceUuid: string): Rule | null => {
/** درصد مؤثر قرارداد برای یک نوع خدمت؛ نبودِ ردیف → ستون قدیمی قرارداد. */
const contractPercent = (contract: Contract, category: string): number =>
Number(contract.category_coverages?.[category] ?? contract.coverage_percent ?? 0);
/**
* قاعده‌ی پوشش یک خدمت تحت یک قرارداد: override خدمت اگر باشد، وگرنه درصد
* همان نوع خدمت (سرپایی/بستری). فرانشیز فقط در قرارداد تکمیلی خوانده می‌شود.
*/
const ruleFor = (contract: Contract | null, coverage: CoverageRow[], serviceUuid: string, category: string): Rule | null => {
if (!contract) return null;
const ov = coverage.find(r => r.service_item_uuid === serviceUuid);
if (ov && !ov.covered) return { covered: false, percent: 0, franchise: 0, ceiling: null };
const isSupplementary = contract.insurance_kind === 'supplementary';
return {
covered: true,
percent: ov?.coverage_percent ?? contract.coverage_percent,
franchise: ov?.franchise_rials ?? contract.franchise_rials,
ceiling: ov?.ceiling_rials ?? contract.annual_ceiling_rials,
percent: ov?.coverage_percent ?? contractPercent(contract, category),
franchise: isSupplementary ? (ov?.franchise_rials ?? contract.franchise_rials) : 0,
ceiling: ov?.ceiling_rials ?? contract.annual_ceiling_rials,
};
};
const coverageOf = (id: string): number => contracts.find(c => String(c.insurance_id) === id)?.coverage_percent ?? 0;
const coverageOf = (id: string): number => {
const contract = contracts.find(c => String(c.insurance_id) === id);
return contract ? contractPercent(contract, VISIT_SERVICE_CATEGORY) : 0;
};
const applyBase = (id: string) => { setBaseId(id); setBasePercent(id ? String(coverageOf(id)) : '0'); };
const applySupp = (id: string) => { setSuppId(id); setSuppPercent(id ? String(coverageOf(id)) : '0'); };
@@ -204,7 +221,11 @@ export default function CreateStep({ recordUuid, profile, onCreated, onCancel, e
// ── خدمات ────────────────────────────────────────────────────────────────
const addService = () => {
if (!currentItem || selectedServices.some(s => s.uuid === currentItem.uuid)) return;
setSelectedServices(p => [...p, { uuid: currentItem.uuid, name: currentItem.name, price: currentItem.price_rials, qty: 1, insured: !!currentItem.insurance_covered }]);
setSelectedServices(p => [...p, {
uuid: currentItem.uuid, name: currentItem.name, price: currentItem.price_rials, qty: 1,
insured: !!currentItem.insurance_covered,
category: currentItem.service_category ?? VISIT_SERVICE_CATEGORY,
}]);
setItemUuid('');
};
const setServiceQty = (uuid: string, qty: number) => {
@@ -237,9 +258,15 @@ export default function CreateStep({ recordUuid, profile, onCreated, onCancel, e
() => selectedServices.reduce((sum, x) => {
const total = x.price * x.qty;
if (!x.insured) return sum + total;
return sum + patientShareOf(total, ruleFor(baseContract, baseCoverage, x.uuid), ruleFor(suppContract, suppCoverage, x.uuid));
// نوع خدمت از کاتالوگ خوانده می‌شود تا ردیف‌های پیش‌پرشدهٔ ویرایش هم درست باشند.
const category = serviceItems.find(i => i.uuid === x.uuid)?.service_category ?? x.category;
return sum + patientShareOf(
total,
ruleFor(baseContract, baseCoverage, x.uuid, category),
ruleFor(suppContract, suppCoverage, x.uuid, category),
);
}, 0),
[selectedServices, baseContract, suppContract, baseCoverage, suppCoverage], // eslint-disable-line react-hooks/exhaustive-deps
[selectedServices, baseContract, suppContract, baseCoverage, suppCoverage, serviceItems], // eslint-disable-line react-hooks/exhaustive-deps
);
const consumablesTotal = useMemo(() => selectedConsumables.reduce((s, c) => s + c.price * c.qty, 0), [selectedConsumables]);
const afterBase = Math.round(visit * (1 - base / 100));