feat: insurance & medical billing system (6 phases)

Multi-tenant insurance contracts, service coverage, versioned tariffs,
invoice calculation, and insurance claims with debt reporting.

- TenantInsurance: per-tenant insurance contracts (coverage/franchise/ceiling,
  versioning, soft-deactivate) + active guard
- ServiceItem.insuranceCovered + TenantServiceCoverage per-service overrides
- Tariff: versioned yearly tariffs with fallback to ServiceItem price
- Billing domain: Money/ShareBreakdown VOs, BillingCalculator (unit-tested),
  Invoice/InvoiceItem aggregate, InvoiceService.createFromSession
- Claim/ClaimItem with state machine (pending->submitted->approved/rejected->paid),
  ClaimService, insurance-debt report
- ClaimSubmitterInterface + ManualClaimSubmitter (future insurance API ready)
- Admin UI: insurance-pricing page, claims page, service tariff modal,
  service insurance toggle; routes + sidebar entries
- Architecture doc + billing/insurance/clinic-services API docs

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
hamed
2026-06-23 15:05:24 +03:30
co-authored by Claude Opus 4.8
parent 5b1dfe9b40
commit 89191eee57
54 changed files with 4233 additions and 10 deletions
+44 -7
View File
@@ -1,6 +1,6 @@
import React, { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { PlusIcon, PencilIcon, TrashIcon, WrenchScrewdriverIcon } from '@heroicons/react/24/outline';
import { PlusIcon, PencilIcon, TrashIcon, WrenchScrewdriverIcon, BanknotesIcon } from '@heroicons/react/24/outline';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
@@ -13,14 +13,17 @@ import Modal from '../components/ui/Modal';
import PriceInput from '../components/ui/PriceInput';
import ConfirmDialog from '../components/ui/ConfirmDialog';
import PageHeader from '../components/ui/PageHeader';
import ServiceTariffModal from '../components/ServiceTariffModal';
import SearchableSelect from '../components/ui/SearchableSelect';
import FeatureGate from '../components/ui/FeatureGate';
const sectionSchema = z.object({ name: z.string().min(1, 'نام بخش الزامی است') });
const itemSchema = z.object({
name: z.string().min(1, 'نام سرویس الزامی است'),
price_rials: z.coerce.number().min(0, 'مبلغ نمی‌تواند منفی باشد'),
staff_uuid: z.string().optional(),
name: z.string().min(1, 'نام سرویس الزامی است'),
price_rials: z.coerce.number().min(0, 'مبلغ نمی‌تواند منفی باشد'),
staff_uuid: z.string().optional(),
insurance_covered: z.boolean().optional(),
insurance_price_rials: z.coerce.number().min(0).optional(),
});
type SectionForm = z.infer<typeof sectionSchema>;
type ItemForm = z.infer<typeof itemSchema>;
@@ -36,6 +39,7 @@ function ClinicServicesPageInner() {
const [deleteSection, setDeleteSection] = useState<ServiceSection | null>(null);
const [itemModal, setItemModal] = useState<'create' | ServiceItem | null>(null);
const [deleteItem, setDeleteItem] = useState<ServiceItem | null>(null);
const [tariffItem, setTariffItem] = useState<ServiceItem | null>(null);
const { data: sectionsData, isLoading: sectionsLoading } = useQuery<ApiResponse<ServiceSection[]>>({
queryKey: ['service-sections'],
@@ -123,6 +127,8 @@ function ClinicServicesPageInner() {
name: item.name,
price_rials: item.price_rials,
staff_uuid: item.staff?.uuid ?? '',
insurance_covered: item.insurance_covered ?? false,
insurance_price_rials: item.insurance_price_rials ?? 0,
});
setItemModal(item);
};
@@ -234,7 +240,7 @@ function ClinicServicesPageInner() {
<button
className="btn primary sm"
style={{ display: 'flex', alignItems: 'center', gap: 4 }}
onClick={() => { itemForm.reset({ price_rials: 0, staff_uuid: '' }); setItemModal('create'); }}
onClick={() => { itemForm.reset({ price_rials: 0, staff_uuid: '', insurance_covered: false, insurance_price_rials: 0 }); setItemModal('create'); }}
>
<PlusIcon style={{ width: 14 }} /> سرویس جدید
</button>
@@ -249,7 +255,7 @@ function ClinicServicesPageInner() {
<button
className="btn primary sm"
style={{ marginTop: 12 }}
onClick={() => { itemForm.reset({ price_rials: 0, staff_uuid: '' }); setItemModal('create'); }}
onClick={() => { itemForm.reset({ price_rials: 0, staff_uuid: '', insurance_covered: false, insurance_price_rials: 0 }); setItemModal('create'); }}
>
افزودن سرویس
</button>
@@ -273,7 +279,14 @@ function ClinicServicesPageInner() {
background: idx % 2 === 1 ? 'oklch(0.985 0.005 256)' : 'transparent',
}}
>
<td style={{ padding: '11px 16px', fontWeight: 500 }}>{item.name}</td>
<td style={{ padding: '11px 16px', fontWeight: 500 }}>
{item.name}
{item.insurance_covered && (
<span className="badge green" style={{ fontSize: 10, marginInlineStart: 6 }}>
<span className="bdot" />بیمه
</span>
)}
</td>
<td style={{ padding: '11px 16px', color: 'var(--primary)', fontWeight: 600 }}>
{formatRial(item.price_rials)}
</td>
@@ -299,6 +312,9 @@ function ClinicServicesPageInner() {
<button className="btn sm" onClick={() => openEditItem(item)} title="ویرایش">
<PencilIcon style={{ width: 13 }} />
</button>
<button className="btn sm" onClick={() => setTariffItem(item)} title="تعرفه‌های سالانه">
<BanknotesIcon style={{ width: 13 }} />
</button>
<button className="btn sm" onClick={() => setDeleteItem(item)} title="حذف">
<TrashIcon style={{ width: 13 }} />
</button>
@@ -378,6 +394,25 @@ function ClinicServicesPageInner() {
isClearable
/>
</div>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', fontSize: 13.5 }}>
<input
type="checkbox"
checked={itemForm.watch('insurance_covered') ?? false}
onChange={(e) => itemForm.setValue('insurance_covered', e.target.checked)}
/>
این خدمت شامل بیمه میشود
</label>
{itemForm.watch('insurance_covered') && (
<div className="field">
<label>قیمت با بیمه (ریال)</label>
<PriceInput
value={itemForm.watch('insurance_price_rials') ?? 0}
onChange={(v) => itemForm.setValue('insurance_price_rials', v)}
placeholder="سهم بیمار با بیمه"
min={0}
/>
</div>
)}
</div>
<div style={{ display: 'flex', gap: 8, marginTop: 16 }}>
<button type="submit" className="btn primary" disabled={createItem.isPending || editItem.isPending}>ذخیره</button>
@@ -386,6 +421,8 @@ function ClinicServicesPageInner() {
</form>
</Modal>
<ServiceTariffModal item={tariffItem} onClose={() => setTariffItem(null)} />
{/* Confirm حذف بخش */}
<ConfirmDialog
open={!!deleteSection}