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>
110 lines
4.4 KiB
TypeScript
110 lines
4.4 KiB
TypeScript
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>
|
|
);
|
|
}
|