From 89191eee571b474d44d274c85874f8720f4a8e1e Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Tue, 23 Jun 2026 15:03:34 +0330 Subject: [PATCH] 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 --- .claude/prompt/insurance-visit-pricing.md | 110 +++++ .claude/prompt/service-insurance-coverage.md | 98 +++++ assets/admin/App.tsx | 4 + .../components/InsurancePricingSection.tsx | 169 ++++++++ .../admin/components/ServiceTariffModal.tsx | 94 +++++ .../components/TenantInsuranceContracts.tsx | 164 +++++++ assets/admin/components/layout/Sidebar.tsx | 31 ++ assets/admin/pages/ClaimsPage.tsx | 187 ++++++++ assets/admin/pages/ClinicServicesPage.tsx | 51 ++- assets/admin/pages/InsurancePricingPage.tsx | 16 + assets/admin/types/index.ts | 2 + docs/api/billing.md | 144 +++++++ docs/api/clinic-services.md | 55 ++- docs/api/insurance.md | 187 ++++++++ docs/architecture/insurance-billing-system.md | 399 ++++++++++++++++++ migrations/Version20260622163152.php | 32 ++ migrations/Version20260622171538.php | 31 ++ migrations/Version20260622173707.php | 33 ++ migrations/Version20260622181251.php | 31 ++ migrations/Version20260622184409.php | 35 ++ migrations/Version20260622185948.php | 35 ++ .../Contract/ClaimSubmissionResult.php | 22 + .../Contract/ClaimSubmitterInterface.php | 14 + src/Billing/Controller/BillingController.php | 186 ++++++++ src/Billing/Entity/Claim.php | 171 ++++++++ src/Billing/Entity/ClaimItem.php | 51 +++ src/Billing/Entity/Invoice.php | 153 +++++++ src/Billing/Entity/InvoiceItem.php | 97 +++++ .../Repository/ClaimItemRepository.php | 28 ++ src/Billing/Repository/ClaimRepository.php | 77 ++++ .../Repository/InvoiceItemRepository.php | 23 + src/Billing/Repository/InvoiceRepository.php | 33 ++ src/Billing/Service/BillingCalculator.php | 53 +++ src/Billing/Service/ClaimService.php | 109 +++++ src/Billing/Service/InvoiceService.php | 76 ++++ src/Billing/Service/ManualClaimSubmitter.php | 20 + src/Billing/ValueObject/Money.php | 38 ++ src/Billing/ValueObject/ShareBreakdown.php | 23 + .../Controller/ClinicServiceController.php | 62 +++ src/ClinicService/Entity/ServiceItem.php | 12 + src/ClinicService/Entity/Tariff.php | 71 ++++ .../Repository/TariffRepository.php | 48 +++ src/ClinicService/Service/TariffService.php | 54 +++ .../Controller/InsuranceController.php | 251 +++++++++++ .../Entity/EntityInsurancePricing.php | 66 +++ src/Insurance/Entity/TenantInsurance.php | 109 +++++ .../Entity/TenantServiceCoverage.php | 78 ++++ .../EntityInsurancePricingRepository.php | 46 ++ .../Repository/TenantInsuranceRepository.php | 74 ++++ .../TenantServiceCoverageRepository.php | 45 ++ .../Service/TenantInsuranceService.php | 147 +++++++ src/Insurance/ValueObject/CoverageRule.php | 18 + src/Patient/Entity/PatientSession.php | 3 + tests/Billing/BillingCalculatorTest.php | 77 ++++ 54 files changed, 4233 insertions(+), 10 deletions(-) create mode 100644 .claude/prompt/insurance-visit-pricing.md create mode 100644 .claude/prompt/service-insurance-coverage.md create mode 100644 assets/admin/components/InsurancePricingSection.tsx create mode 100644 assets/admin/components/ServiceTariffModal.tsx create mode 100644 assets/admin/components/TenantInsuranceContracts.tsx create mode 100644 assets/admin/pages/ClaimsPage.tsx create mode 100644 assets/admin/pages/InsurancePricingPage.tsx create mode 100644 docs/api/billing.md create mode 100644 docs/architecture/insurance-billing-system.md create mode 100644 migrations/Version20260622163152.php create mode 100644 migrations/Version20260622171538.php create mode 100644 migrations/Version20260622173707.php create mode 100644 migrations/Version20260622181251.php create mode 100644 migrations/Version20260622184409.php create mode 100644 migrations/Version20260622185948.php create mode 100644 src/Billing/Contract/ClaimSubmissionResult.php create mode 100644 src/Billing/Contract/ClaimSubmitterInterface.php create mode 100644 src/Billing/Controller/BillingController.php create mode 100644 src/Billing/Entity/Claim.php create mode 100644 src/Billing/Entity/ClaimItem.php create mode 100644 src/Billing/Entity/Invoice.php create mode 100644 src/Billing/Entity/InvoiceItem.php create mode 100644 src/Billing/Repository/ClaimItemRepository.php create mode 100644 src/Billing/Repository/ClaimRepository.php create mode 100644 src/Billing/Repository/InvoiceItemRepository.php create mode 100644 src/Billing/Repository/InvoiceRepository.php create mode 100644 src/Billing/Service/BillingCalculator.php create mode 100644 src/Billing/Service/ClaimService.php create mode 100644 src/Billing/Service/InvoiceService.php create mode 100644 src/Billing/Service/ManualClaimSubmitter.php create mode 100644 src/Billing/ValueObject/Money.php create mode 100644 src/Billing/ValueObject/ShareBreakdown.php create mode 100644 src/ClinicService/Entity/Tariff.php create mode 100644 src/ClinicService/Repository/TariffRepository.php create mode 100644 src/ClinicService/Service/TariffService.php create mode 100644 src/Insurance/Entity/EntityInsurancePricing.php create mode 100644 src/Insurance/Entity/TenantInsurance.php create mode 100644 src/Insurance/Entity/TenantServiceCoverage.php create mode 100644 src/Insurance/Repository/EntityInsurancePricingRepository.php create mode 100644 src/Insurance/Repository/TenantInsuranceRepository.php create mode 100644 src/Insurance/Repository/TenantServiceCoverageRepository.php create mode 100644 src/Insurance/Service/TenantInsuranceService.php create mode 100644 src/Insurance/ValueObject/CoverageRule.php create mode 100644 tests/Billing/BillingCalculatorTest.php diff --git a/.claude/prompt/insurance-visit-pricing.md b/.claude/prompt/insurance-visit-pricing.md new file mode 100644 index 00000000..82f8d5a4 --- /dev/null +++ b/.claude/prompt/insurance-visit-pricing.md @@ -0,0 +1,110 @@ +# قیمت‌گذاری ویزیت بر اساس بیمه در پروفایل کلینیک/مطب + +## پروژه + +`clinicpro` (backend + admin frontend) + +## زمینه + +در حال حاضر قیمت ویزیت پزشک نسبت به بیمه فقط با موجودیت `DoctorInsurance` ذخیره می‌شود که **یک فیلد قیمت تکی** (`price`) دارد: + +```php +// src/Insurance/Entity/DoctorInsurance.php +#[ORM\Column(type: 'integer', nullable: true)] +private ?int $price = null; +``` + +این کافی نیست. کاربر (کلینیک یا مطب شخصی) باید بتواند در پروفایل خود مشخص کند: + +- مبلغ **ویزیت آزاد** (بدون بیمه) چقدر است +- اگر **بیمه پایه** اعمال شود، مبلغ نهایی/سهم بیمار چقدر می‌شود +- اگر علاوه بر آن **بیمه مکمل** هم اضافه شود، مبلغ نهایی چقدر می‌شود + +## مشکل / هدف + +افزودن یک ساختار قیمت‌گذاری ویزیت بر اساس بیمه در پروفایل entity (هم `doctor` و هم `clinic`)، با سه لایه: + +1. **قیمت پایه آزاد** (free / بدون بیمه) +2. **سهم/مبلغ با بیمه پایه** — به ازای هر بیمه پایه‌ای که entity می‌پذیرد +3. **سهم/مبلغ با بیمه مکمل** — به ازای هر بیمه مکمل + +این مقادیر بعداً در «ثبت مراجعه جدید» (پرامپت `new-visit-modal-ux.md`) برای پر کردن خودکار قیمت ویزیت و درصد/مبلغ تخفیف استفاده می‌شوند. + +## فایل‌های مرتبط + +| فایل | نقش | +|------|-----| +| `src/Insurance/Entity/DoctorInsurance.php` | رابطه فعلی پزشک↔بیمه با یک قیمت | +| `src/Insurance/Entity/Insurance.php` | موجودیت بیمه؛ دارای `type` (enum `InsuranceType`) | +| `src/Insurance/Enum/InsuranceType.php` | نوع بیمه (پایه/مکمل) — بخوان و مقادیر را تأیید کن | +| `src/Insurance/Controller/InsuranceController.php` | CRUD بیمه + `DoctorInsurance` (از خط ۱۸۶) | +| `src/Insurance/Repository/DoctorInsuranceRepository.php` | کوئری‌ها | +| `assets/admin/pages/MyClinicPage.tsx` و `DoctorProfilePage.tsx` | پروفایل کلینیک/مطب در پنل | +| `docs/api/insurance.md` | مستندات API بیمه | + +## وضعیت فعلی + +`DoctorInsurance.toArray()`: + +```php +return [ + 'id' => $this->id, + 'doctor_id' => $this->doctor->getId(), + 'insurance_id' => $this->insurance->getId(), + 'insurance_name' => $this->insurance->getName(), + 'type' => $this->insurance->getType()->value, + 'price' => $this->price, +]; +``` + +- فقط برای `doctor` است؛ معادل `clinic` وجود ندارد. +- فقط یک `price` تکی دارد؛ تفکیک آزاد/پایه/مکمل ندارد. + +## وظایف + +### ۱. تحلیل و طراحی مدل قیمت‌گذاری + +ابتدا `InsuranceType` و `DoctorInsurance` و `InsuranceController` (بخش DoctorInsurance) را کامل بخوان. سپس تصمیم بگیر: + +- آیا «ویزیت آزاد» یک فیلد روی پروفایل entity است (یک مقدار) و هر `DoctorInsurance` فقط مبلغ سهم بیمار با آن بیمه را نگه می‌دارد؟ (پیشنهاد: بله) +- آیا باید برای `clinic` هم معادل `DoctorInsurance` ساخت (مثلاً عمومی‌سازی به entity-type/entity-id مثل `PatientRecord`)، یا یک موجودیت جدید `EntityInsurancePricing(entityType, entityId, insuranceId, patientShareRials)` ساخت؟ + +> توصیه: یک موجودیت polymorphic جدید `EntityInsurancePricing` با کلیدهای `entity_type` (`doctor|clinic`)، `entity_id`، `insurance_id`، `patient_share_rials` بساز و `DoctorInsurance` را به‌مرور کنار بگذار (اما داده‌ی فعلی را migrate کن). قیمت ویزیت آزاد را روی پروفایل entity (یک کلید config یا ستون) نگه‌دار. + +طرح نهایی را قبل از پیاده‌سازی به‌صورت یک پاراگراف توضیح بده. + +### ۲. Entity + Migration + +- موجودیت/فیلدهای جدید را بساز (طبق طرح مرحله ۱). +- مقدار «ویزیت آزاد» را برای entity ذخیره کن. +- `doctrine:migrations:diff` سپس `migrate`. +- داده‌ی موجود `doctor_insurances.price` را به مدل جدید migrate کن (در همان migration یا یک migration داده‌ای). + +### ۳. API + +Endpointها برای خواندن/ذخیره قیمت‌گذاری بیمه‌ی entity جاری (از `#[CurrentUser]` → resolve به `doctor` یا `clinic` مثل الگوی `PatientController::resolveEntity`): + +- `GET /api/v1/insurance-pricing` — لیست قیمت‌گذاری entity جاری + قیمت ویزیت آزاد +- `PUT|PATCH /api/v1/insurance-pricing` — ذخیره‌ی قیمت آزاد + آرایه‌ی سهم هر بیمه + +از `BaseController` و helperهای `$this->success()` / `$this->error()` استفاده کن. + +### ۴. Admin Frontend + +در `MyClinicPage.tsx` و `DoctorProfilePage.tsx` یک بخش «قیمت‌گذاری ویزیت بر اساس بیمه» اضافه کن: + +- ورودی «مبلغ ویزیت آزاد (ریال)» +- جدول/لیست بیمه‌های پایه و مکمل (از `GET /api/v1/insurances`) با یک ورودی مبلغ سهم بیمار به ازای هر کدام +- پیش‌نمایش: «آزاد: X — با بیمه پایه Y: Z — با بیمه مکمل W: …» +- ذخیره با TanStack Query `useMutation` + +### ۵. مستندات + +`docs/api/insurance.md` را با endpointهای جدید (method/path/permission، body کامل، نمونه پاسخ JSON واقعی، error codeها) به‌روز کن. + +## نکات مهم + +- entity جاری از `#[CurrentUser]` resolve می‌شود؛ الگو را از `PatientController::resolveEntity` بگیر (نقش `ROLE_DOCTOR` → doctor، `ROLE_CLINIC` → clinic). +- تاریخ‌ها/قیمت‌ها همه integer ریال هستند. +- این پرامپت پیش‌نیاز `new-visit-modal-ux.md` است؛ خروجی قیمت‌ها آنجا مصرف می‌شود. +- مهاجرت داده‌ی `DoctorInsurance` نباید قیمت‌های موجود را از بین ببرد. diff --git a/.claude/prompt/service-insurance-coverage.md b/.claude/prompt/service-insurance-coverage.md new file mode 100644 index 00000000..c98e1388 --- /dev/null +++ b/.claude/prompt/service-insurance-coverage.md @@ -0,0 +1,98 @@ +# مشخص‌کردن شمول بیمه برای هر خدمت + +## پروژه + +`clinicpro` (backend + admin frontend) + +## زمینه + +هر خدمت کلینیک با موجودیت `ServiceItem` نگه‌داری می‌شود. در حال حاضر هیچ فیلدی ندارد که بگوید این خدمت **شامل بیمه می‌شود یا نه**. + +## مشکل / هدف + +افزودن قابلیت تعیین «شمول بیمه» به هر `ServiceItem`: + +- یک پرچم: آیا این خدمت شامل بیمه می‌شود؟ +- اگر شامل می‌شود، اطلاعات لازم برای محاسبه‌ی درست (مثلاً سهم بیمار / درصد پوشش / لیست بیمه‌های پذیرفته‌شده) ذخیره و نمایش داده شود. + +## فایل‌های مرتبط + +| فایل | نقش | +|------|-----| +| `src/ClinicService/Entity/ServiceItem.php` | موجودیت خدمت | +| `src/ClinicService/Controller/ClinicServiceController.php` | CRUD خدمات/بخش‌ها | +| `src/ClinicService/Repository/ServiceItemRepository.php` | کوئری‌ها | +| `assets/admin/pages/ClinicServicesPage.tsx` | صفحه‌ی مدیریت خدمات در پنل | +| `src/Patient/Service/PatientService.php` | محاسبه‌ی قیمت مراجعه (مصرف‌کننده‌ی خدمت) | +| `docs/api/clinic-services.md` | مستندات API خدمات | + +## وضعیت فعلی + +`ServiceItem` فیلدهای موجود: + +```php +private string $name; +#[ORM\Column(name: 'price_rials', type: 'integer')] +private int $priceRials = 0; +#[ORM\Column(type: 'boolean')] +private bool $active = true; +``` + +`toArray()`: + +```php +return [ + 'uuid' => $this->uuid, + 'section_uuid' => $this->section->getUuid(), + 'staff_uuid' => $this->staff?->getUuid(), + 'staff_name' => $this->staff?->getFullName(), + 'name' => $this->name, + 'price_rials' => $this->priceRials, + 'active' => $this->active, + 'created_at' => $this->createdAt, + 'updated_at' => $this->updatedAt, +]; +``` + +هیچ مفهوم بیمه‌ای ندارد. + +## وظایف + +### ۱. طراحی فیلد شمول بیمه + +تصمیم بگیر مدل داده چه باشد: + +- ساده: یک `bool $insuranceCovered = false`. +- کامل‌تر: `bool $insuranceCovered` + `?int $patientShareRials` (سهم بیمار وقتی بیمه اعمال شود) یا `?float $coveragePercent`. + +> توصیه: `insuranceCovered` (bool) + `?int $insurancePriceRials` (قیمت/سهم بیمار با بیمه). اگر `insuranceCovered=false`، فیلد دوم نادیده گرفته شود. + +طرح را قبل از پیاده‌سازی توضیح بده. + +### ۲. Entity + Migration + +- فیلد(ها) را به `ServiceItem` اضافه کن + getter/setter. +- در `toArray()` اضافه کن. +- `doctrine:migrations:diff` سپس `migrate`. + +### ۳. API + +`ClinicServiceController` (create/update خدمت) باید فیلدهای جدید را از body بپذیرد و ذخیره کند. validation مناسب (اگر `insurance_covered=true` و قیمت بیمه خالی، خطا یا صفر منطقی). + +### ۴. Admin Frontend + +در `ClinicServicesPage.tsx` فرم خدمت: + +- یک toggle/checkbox «شامل بیمه می‌شود» +- وقتی روشن شد، ورودی «قیمت با بیمه (ریال)» نمایش داده شود +- در لیست خدمات، یک badge نشان دهد خدمت شامل بیمه است یا خیر + +### ۵. مستندات + +`docs/api/clinic-services.md` را با فیلدهای جدید در request/response به‌روز کن. + +## نکات مهم + +- قیمت‌ها integer ریال. +- این فیلد بعداً در محاسبه‌ی قیمت مراجعه (`PatientService::createSession`) قابل استفاده است؛ در این پرامپت فقط ذخیره/نمایش کافی است مگر اینکه ساده باشد همان‌جا هم اعمال شود. +- الگوی frontend خدمات موجود را رعایت کن (همان فرم/modal فعلی). diff --git a/assets/admin/App.tsx b/assets/admin/App.tsx index ed7cc47c..064b2af5 100644 --- a/assets/admin/App.tsx +++ b/assets/admin/App.tsx @@ -30,6 +30,8 @@ import MyClinicPage from './pages/MyClinicPage'; import SettingsPage from './pages/SettingsPage'; import DoctorProfilePage from './pages/DoctorProfilePage'; import MyPatientsPage from './pages/MyPatientsPage'; +import InsurancePricingPage from './pages/InsurancePricingPage'; +import ClaimsPage from './pages/ClaimsPage'; import MyFinancialPage from './pages/MyFinancialPage'; import ClinicFormPage from './pages/ClinicFormPage'; import PreRegistrationsPage from './pages/PreRegistrationsPage'; @@ -168,6 +170,8 @@ export default function App() { {/* دکتر / منشی / کلینیک */} } /> + } /> + } /> } /> {/* فاز ۲ — دکتر / کلینیک */} diff --git a/assets/admin/components/InsurancePricingSection.tsx b/assets/admin/components/InsurancePricingSection.tsx new file mode 100644 index 00000000..53bf9014 --- /dev/null +++ b/assets/admin/components/InsurancePricingSection.tsx @@ -0,0 +1,169 @@ +import { useEffect, useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { api } from '../lib/api'; +import { formatRial } from '../lib/utils'; + +interface InsuranceRow { + insurance_id: number; + insurance_name: string; + type: string; + patient_share_rials: number | null; +} + +interface PricingResponse { + free_visit_price_rials: number; + insurances: InsuranceRow[]; +} + +const TYPE_LABEL: Record = { + basic: 'بیمه پایه', + supplementary: 'بیمه تکمیلی', +}; + +export default function InsurancePricingSection() { + const qc = useQueryClient(); + const [freeVisit, setFreeVisit] = useState(''); + const [shares, setShares] = useState>({}); + + const { data, isLoading } = useQuery<{ data: PricingResponse }>({ + queryKey: ['insurance-pricing'], + queryFn: () => api.get('/api/v1/insurance-pricing'), + }); + + const pricing = (data as any)?.data as PricingResponse | undefined; + + useEffect(() => { + if (!pricing) return; + setFreeVisit(String(pricing.free_visit_price_rials ?? 0)); + const next: Record = {}; + pricing.insurances.forEach((i) => { + next[i.insurance_id] = i.patient_share_rials != null ? String(i.patient_share_rials) : ''; + }); + setShares(next); + }, [pricing]); + + const saveMut = useMutation({ + mutationFn: () => + api.put('/api/v1/insurance-pricing', { + free_visit_price_rials: Number(freeVisit) || 0, + insurances: (pricing?.insurances ?? []).map((i) => ({ + insurance_id: i.insurance_id, + patient_share_rials: + shares[i.insurance_id] === '' || shares[i.insurance_id] == null + ? null + : Number(shares[i.insurance_id]) || 0, + })), + }), + onSuccess: () => { + toast.success('قیمت‌گذاری بیمه ذخیره شد'); + qc.invalidateQueries({ queryKey: ['insurance-pricing'] }); + }, + onError: (e: Error) => toast.error(e.message), + }); + + const basics = (pricing?.insurances ?? []).filter((i) => i.type === 'basic'); + const supps = (pricing?.insurances ?? []).filter((i) => i.type === 'supplementary'); + + const renderRow = (i: InsuranceRow) => { + const share = shares[i.insurance_id] ?? ''; + const preview = + share !== '' && freeVisit !== '' + ? `سهم بیمار: ${formatRial(Number(share) || 0)}` + : 'تعیین نشده'; + return ( +
+
+
{i.insurance_name}
+
{preview}
+
+
+ + setShares((p) => ({ ...p, [i.insurance_id]: e.target.value })) + } + /> +
+
+ ); + }; + + return ( +
+
+

قیمت‌گذاری ویزیت بر اساس بیمه

+

+ مبلغ ویزیت آزاد و سهم بیمار به ازای هر بیمه را تعیین کنید. خالی‌گذاشتن یک بیمه یعنی پذیرفته نمی‌شود. +

+
+ + {isLoading ? ( +
در حال بارگذاری...
+ ) : ( +
+
+ + setFreeVisit(e.target.value)} + /> + + {freeVisit !== '' ? formatRial(Number(freeVisit) || 0) : '—'} + +
+ + {basics.length > 0 && ( +
+
{TYPE_LABEL.basic}
+
{basics.map(renderRow)}
+
+ )} + + {supps.length > 0 && ( +
+
{TYPE_LABEL.supplementary}
+
{supps.map(renderRow)}
+
+ )} + + {basics.length === 0 && supps.length === 0 && ( +
بیمه فعالی در سیستم تعریف نشده است.
+ )} + +
+ +
+
+ )} +
+ ); +} diff --git a/assets/admin/components/ServiceTariffModal.tsx b/assets/admin/components/ServiceTariffModal.tsx new file mode 100644 index 00000000..61235a73 --- /dev/null +++ b/assets/admin/components/ServiceTariffModal.tsx @@ -0,0 +1,94 @@ +import { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { api } from '../lib/api'; +import { formatRial, formatNumber } from '../lib/utils'; +import Modal from './ui/Modal'; +import type { ServiceItem } from '../types'; + +interface TariffRow { + uuid: string; + year: number; + price_rials: number; + is_active: boolean; +} + +interface TariffResponse { + current_year: number; + default_price_rials: number; + data: TariffRow[]; +} + +export default function ServiceTariffModal({ item, onClose }: { item: ServiceItem | null; onClose: () => void }) { + const qc = useQueryClient(); + const [year, setYear] = useState(''); + const [price, setPrice] = useState(''); + + const { data, isLoading } = useQuery<{ data: TariffResponse }>({ + queryKey: ['service-tariffs', item?.uuid], + queryFn: () => api.get(`/api/v1/service-items/${item!.uuid}/tariffs`), + enabled: !!item, + }); + + const resp = (data as any)?.data as TariffResponse | undefined; + const tariffs = resp?.data ?? []; + const currentYear = resp?.current_year; + + const saveMut = useMutation({ + mutationFn: () => + api.put(`/api/v1/service-items/${item!.uuid}/tariffs/${Number(year)}`, { + price_rials: Number(price) || 0, + }), + onSuccess: () => { + toast.success('تعرفه ذخیره شد'); + setYear(''); + setPrice(''); + qc.invalidateQueries({ queryKey: ['service-tariffs', item?.uuid] }); + }, + onError: (e: Error) => toast.error(e.message), + }); + + return ( + +
+ {resp && ( +
+ قیمت پیش‌فرض خدمت: {formatRial(resp.default_price_rials)}. اگر تعرفه‌ی سالی ثبت نشود، همین قیمت اعمال می‌شود. +
+ )} + +
+
+ + setYear(e.target.value)} /> +
+
+ + setPrice(e.target.value)} /> +
+ +
+ + {isLoading ? ( +
در حال بارگذاری...
+ ) : tariffs.length === 0 ? ( +
هنوز تعرفه‌ی سالانه‌ای ثبت نشده است.
+ ) : ( +
+ {tariffs.map((t) => ( +
+ + سال {formatNumber(t.year)} + {t.year === currentYear && جاری} + + {formatRial(t.price_rials)} +
+ ))} +
+ )} +
+
+ ); +} diff --git a/assets/admin/components/TenantInsuranceContracts.tsx b/assets/admin/components/TenantInsuranceContracts.tsx new file mode 100644 index 00000000..1410c8fa --- /dev/null +++ b/assets/admin/components/TenantInsuranceContracts.tsx @@ -0,0 +1,164 @@ +import { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { TrashIcon, PlusIcon } from '@heroicons/react/24/outline'; +import { toast } from 'sonner'; +import { api } from '../lib/api'; +import { formatRial } from '../lib/utils'; + +interface Contract { + uuid: string; + insurance_id: number; + insurance_name: string | null; + insurance_kind: string | null; + version: number; + coverage_percent: number; + franchise_rials: number; + annual_ceiling_rials: number | null; +} + +interface InsuranceOption { + insurance_id: number; + insurance_name: string; + type: string; +} + +const KIND_LABEL: Record = { + basic: 'پایه', + supplementary: 'تکمیلی', +}; + +export default function TenantInsuranceContracts() { + const qc = useQueryClient(); + const [addOpen, setAddOpen] = useState(false); + const [insuranceId, setInsuranceId] = useState(''); + const [coverage, setCoverage] = useState(''); + const [franchise, setFranchise] = useState(''); + const [ceiling, setCeiling] = useState(''); + + const contractsQuery = useQuery<{ data: Contract[] }>({ + queryKey: ['tenant-insurances'], + queryFn: () => api.get('/api/v1/billing/tenant-insurances'), + }); + + const pricingQuery = useQuery<{ data: { insurances: InsuranceOption[] } }>({ + queryKey: ['insurance-pricing'], + queryFn: () => api.get('/api/v1/insurance-pricing'), + }); + + const contracts = (contractsQuery.data as any)?.data?.data ?? []; + const allInsurances: InsuranceOption[] = (pricingQuery.data as any)?.data?.insurances ?? []; + const activeIds = new Set(contracts.map((c: Contract) => c.insurance_id)); + const available = allInsurances.filter((i) => !activeIds.has(i.insurance_id)); + + const resetForm = () => { + setInsuranceId(''); + setCoverage(''); + setFranchise(''); + setCeiling(''); + setAddOpen(false); + }; + + const addMut = useMutation({ + mutationFn: () => + api.post('/api/v1/billing/tenant-insurances', { + insurance_id: Number(insuranceId), + coverage_percent: Number(coverage) || 0, + franchise_rials: Number(franchise) || 0, + annual_ceiling_rials: ceiling === '' ? null : Number(ceiling), + }), + onSuccess: () => { + toast.success('قرارداد بیمه فعال شد'); + resetForm(); + qc.invalidateQueries({ queryKey: ['tenant-insurances'] }); + }, + onError: (e: Error) => toast.error(e.message), + }); + + const delMut = useMutation({ + mutationFn: (uuid: string) => api.delete(`/api/v1/billing/tenant-insurances/${uuid}`), + onSuccess: () => { + toast.success('قرارداد غیرفعال شد'); + qc.invalidateQueries({ queryKey: ['tenant-insurances'] }); + }, + onError: (e: Error) => toast.error(e.message), + }); + + return ( +
+
+
+

قراردادهای بیمه

+

+ بیمه‌هایی که با آن‌ها قرارداد دارید. درصد پوشش، فرانشیز و سقف تعهد هر بیمه را تعیین کنید. +

+
+ {!addOpen && available.length > 0 && ( + + )} +
+ + {addOpen && ( +
+
+ + +
+
+ + setCoverage(e.target.value)} /> +
+
+ + setFranchise(e.target.value)} /> +
+
+ + setCeiling(e.target.value)} /> +
+
+ + +
+
+ )} + + {contractsQuery.isLoading ? ( +
در حال بارگذاری...
+ ) : contracts.length === 0 ? ( +
+ هنوز با هیچ بیمه‌ای قرارداد فعال ندارید. +
+ ) : ( +
+ {contracts.map((c: Contract) => ( +
+
+
+ {c.insurance_name ?? `#${c.insurance_id}`} + {c.insurance_kind && {KIND_LABEL[c.insurance_kind] ?? c.insurance_kind}} +
+
+ پوشش {c.coverage_percent}٪ + {c.franchise_rials > 0 && ` · فرانشیز ${formatRial(c.franchise_rials)}`} + {c.annual_ceiling_rials != null && ` · سقف ${formatRial(c.annual_ceiling_rials)}`} +
+
+ +
+ ))} +
+ )} +
+ ); +} diff --git a/assets/admin/components/layout/Sidebar.tsx b/assets/admin/components/layout/Sidebar.tsx index 52aadb71..e0da4231 100644 --- a/assets/admin/components/layout/Sidebar.tsx +++ b/assets/admin/components/layout/Sidebar.tsx @@ -16,6 +16,7 @@ import { IdentificationIcon, KeyIcon, LockClosedIcon, + ShieldCheckIcon, StarIcon, TagIcon, UserCircleIcon, @@ -192,6 +193,16 @@ function buildSections( label: "پرونده بیماران", feature: "patient_records", }, + { + to: "/admin/insurance-pricing", + icon: ShieldCheckIcon, + label: "قیمت‌گذاری بیمه", + }, + { + to: "/admin/claims", + icon: DocumentTextIcon, + label: "مطالبات بیمه", + }, { to: "/admin/staff", icon: UserPlusIcon, label: "پرسنل" }, { to: "/admin/my-secretaries", @@ -261,6 +272,16 @@ function buildSections( label: "پرونده بیماران", feature: "patient_records", }, + { + to: "/admin/insurance-pricing", + icon: ShieldCheckIcon, + label: "قیمت‌گذاری بیمه", + }, + { + to: "/admin/claims", + icon: DocumentTextIcon, + label: "مطالبات بیمه", + }, { to: "/admin/staff", icon: UserPlusIcon, label: "پرسنل" }, { to: "/admin/my-secretaries", @@ -320,6 +341,16 @@ function buildSections( label: "پرونده بیماران", feature: "patient_records", }, + { + to: "/admin/insurance-pricing", + icon: ShieldCheckIcon, + label: "قیمت‌گذاری بیمه", + }, + { + to: "/admin/claims", + icon: DocumentTextIcon, + label: "مطالبات بیمه", + }, ], }, ]; diff --git a/assets/admin/pages/ClaimsPage.tsx b/assets/admin/pages/ClaimsPage.tsx new file mode 100644 index 00000000..11ec2cc9 --- /dev/null +++ b/assets/admin/pages/ClaimsPage.tsx @@ -0,0 +1,187 @@ +import { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { CheckIcon, XMarkIcon, PaperAirplaneIcon, BanknotesIcon } from '@heroicons/react/24/outline'; +import { toast } from 'sonner'; +import { api } from '../lib/api'; +import { formatRial, formatNumber } from '../lib/utils'; +import PageHeader from '../components/ui/PageHeader'; +import Modal from '../components/ui/Modal'; + +interface ClaimItem { + invoice_item_id: number; + claimed_rials: number; + approved_rials: number | null; +} + +interface Claim { + uuid: string; + insurance_id: number; + insurance_kind: string; + total_claimed_rials: number; + total_approved_rials: number | null; + total_paid_rials: number | null; + status: string; + reject_reason: string | null; + items: ClaimItem[]; +} + +interface DebtRow { + insurance_id: number; + claimed: number; + approved: number; + paid: number; + debt: number; +} + +const STATUS_META: Record = { + pending: { label: 'در انتظار', cls: 'gray' }, + submitted: { label: 'ارسال‌شده', cls: 'blue' }, + approved: { label: 'تأییدشده', cls: 'amber' }, + rejected: { label: 'ردشده', cls: 'red' }, + paid: { label: 'پرداخت‌شده', cls: 'green' }, +}; + +const KIND_LABEL: Record = { base: 'پایه', supplementary: 'تکمیلی' }; +const STATUS_FILTERS = ['', 'pending', 'submitted', 'approved', 'rejected', 'paid']; + +export default function ClaimsPage() { + const qc = useQueryClient(); + const [statusFilter, setStatusFilter] = useState(''); + const [rejectTarget, setRejectTarget] = useState(null); + const [rejectReason, setRejectReason] = useState(''); + + const claimsQuery = useQuery<{ data: { data: Claim[] } }>({ + queryKey: ['claims', statusFilter], + queryFn: () => api.get(`/api/v1/billing/claims${statusFilter ? `?status=${statusFilter}` : ''}`), + }); + + const debtQuery = useQuery<{ data: { data: DebtRow[] } }>({ + queryKey: ['insurance-debt'], + queryFn: () => api.get('/api/v1/billing/reports/insurance-debt'), + }); + + const claims = (claimsQuery.data as any)?.data?.data ?? []; + const debt = (debtQuery.data as any)?.data?.data ?? []; + + const transitionMut = useMutation({ + mutationFn: ({ uuid, action, body }: { uuid: string; action: string; body?: object }) => + api.post(`/api/v1/billing/claims/${uuid}/${action}`, body ?? {}), + onSuccess: () => { + toast.success('وضعیت مطالبه به‌روزرسانی شد'); + setRejectTarget(null); + setRejectReason(''); + qc.invalidateQueries({ queryKey: ['claims'] }); + qc.invalidateQueries({ queryKey: ['insurance-debt'] }); + }, + onError: (e: Error) => toast.error(e.message), + }); + + return ( +
+ + +
+
+ +

بدهی بیمه‌ها

+
+ {debtQuery.isLoading ? ( +
در حال بارگذاری...
+ ) : debt.length === 0 ? ( +
بدهی‌ای ثبت نشده است.
+ ) : ( +
+ {debt.map((d: DebtRow) => ( +
+ بیمه #{formatNumber(d.insurance_id)} + ادعا {formatRial(d.claimed)} · پرداخت {formatRial(d.paid)} + 0 ? 'var(--danger)' : 'var(--success, #16a34a)' }}>بدهی {formatRial(d.debt)} +
+ ))} +
+ )} +
+ +
+
+

مطالبات

+ +
+ + {claimsQuery.isLoading ? ( +
در حال بارگذاری...
+ ) : claims.length === 0 ? ( +
مطالبه‌ای یافت نشد.
+ ) : ( +
+ {claims.map((c: Claim) => { + const meta = STATUS_META[c.status] ?? { label: c.status, cls: 'gray' }; + return ( +
+
+
+
+ بیمه #{formatNumber(c.insurance_id)} + {KIND_LABEL[c.insurance_kind] ?? c.insurance_kind} + {meta.label} +
+
+ ادعا {formatRial(c.total_claimed_rials)} + {c.total_approved_rials != null && ` · تأیید ${formatRial(c.total_approved_rials)}`} + {c.total_paid_rials != null && ` · پرداخت ${formatRial(c.total_paid_rials)}`} + {c.reject_reason && ` · دلیل رد: ${c.reject_reason}`} +
+
+
+ {c.status === 'pending' && ( + + )} + {c.status === 'submitted' && ( + <> + + + + )} + {c.status === 'approved' && ( + + )} +
+
+
+ ); + })} +
+ )} +
+ + setRejectTarget(null)} title="رد مطالبه" + footer={ + <> + + + + } + > +
+ +