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:
@@ -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) : 'بدون تاریخ پایان'} />
|
||||
|
||||
Reference in New Issue
Block a user