From 20bdc49e89d5f10772d2d330c3ba0e49e2a4fc28 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Sat, 18 Jul 2026 23:38:02 +0330 Subject: [PATCH] feat(claims): add tracking number and status history for claims - Introduced `tracking_number` field in the `claims` table to store the insurance tracking number. - Created `claim_status_logs` table to maintain a history of status changes for claims, including who made the change and when. - Implemented `ClaimStatusLog` entity and repository for managing status log entries. - Updated `ClaimService` to log transitions and handle tracking numbers during claim submissions. - Added new API endpoint for fetching claims by patient, including detailed claim history and status logs. - Enhanced frontend with a new `ClaimPatientDetailPage` to display claims and their status history. - Added tests to ensure correct aggregation of claims and proper handling of status transitions. --- assets/admin/App.tsx | 2 + assets/admin/components/ui/StatusBadge.tsx | 16 +- assets/admin/pages/ClaimPatientDetailPage.tsx | 269 +++++++++++ assets/admin/pages/ClaimsPage.tsx | 451 +++++++----------- assets/admin/types/index.ts | 52 ++ docs/api/billing.md | 119 ++++- docs/api/insurance.md | 4 +- migrations/Version20260718193940.php | 58 +++ src/Billing/Controller/BillingController.php | 130 ++++- src/Billing/Entity/Claim.php | 41 ++ src/Billing/Entity/ClaimStatusLog.php | 94 ++++ src/Billing/Repository/ClaimRepository.php | 210 ++++++++ .../Repository/ClaimStatusLogRepository.php | 51 ++ src/Billing/Service/ClaimService.php | 38 +- tests/Billing/ClaimsByPatientTest.php | 186 ++++++++ 15 files changed, 1412 insertions(+), 309 deletions(-) create mode 100644 assets/admin/pages/ClaimPatientDetailPage.tsx create mode 100644 migrations/Version20260718193940.php create mode 100644 src/Billing/Entity/ClaimStatusLog.php create mode 100644 src/Billing/Repository/ClaimStatusLogRepository.php create mode 100644 tests/Billing/ClaimsByPatientTest.php diff --git a/assets/admin/App.tsx b/assets/admin/App.tsx index 3e1f16b8..7f702ff2 100644 --- a/assets/admin/App.tsx +++ b/assets/admin/App.tsx @@ -47,6 +47,7 @@ import EditSessionPage from './pages/EditSessionPage'; import SessionPaymentPage from './pages/SessionPaymentPage'; import InsurancePricingPage from './pages/InsurancePricingPage'; import ClaimsPage from './pages/ClaimsPage'; +import ClaimPatientDetailPage from './pages/ClaimPatientDetailPage'; import DoctorClaimsPage from './pages/DoctorClaimsPage'; import MyFinancialPage from './pages/MyFinancialPage'; import ClinicFormPage from './pages/ClinicFormPage'; @@ -235,6 +236,7 @@ export default function App() { } /> } /> } /> + } /> } /> {/* فاز ۲ — دکتر / کلینیک */} diff --git a/assets/admin/components/ui/StatusBadge.tsx b/assets/admin/components/ui/StatusBadge.tsx index 4a7854fc..1f3349f8 100644 --- a/assets/admin/components/ui/StatusBadge.tsx +++ b/assets/admin/components/ui/StatusBadge.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import type { AppointmentStatus, PaymentStatus, SmsTemplateStatus, SettlementStatus } from '../../types'; +import type { AppointmentStatus, PaymentStatus, SmsTemplateStatus, SettlementStatus, ClaimStatus } from '../../types'; type BadgeColor = 'green' | 'amber' | 'red' | 'blue' | 'violet' | 'gray'; @@ -36,8 +36,17 @@ const settlementMap: Record = { + pending: { color: 'gray', label: 'در انتظار ارسال' }, + submitted: { color: 'blue', label: 'ارسال شده' }, + approved: { color: 'amber', label: 'تأیید شده' }, + rejected: { color: 'red', label: 'رد شده' }, + paid: { color: 'green', label: 'پرداخت شده' }, + mixed: { color: 'violet', label: 'وضعیت‌های مختلف' }, +}; + interface Props { - type: 'appointment' | 'payment' | 'sms' | 'settlement' | 'active'; + type: 'appointment' | 'payment' | 'sms' | 'settlement' | 'active' | 'claim'; value: string; } @@ -57,6 +66,9 @@ export default function StatusBadge({ type, value }: Props) { } else if (type === 'settlement') { const m = settlementMap[value as SettlementStatus]; if (m) { color = m.color; label = m.label; } + } else if (type === 'claim') { + const m = claimMap[value as ClaimStatus]; + if (m) { color = m.color; label = m.label; } } else if (type === 'active') { color = value === 'true' || value === 'active' ? 'green' : 'gray'; label = value === 'true' || value === 'active' ? 'فعال' : 'غیرفعال'; diff --git a/assets/admin/pages/ClaimPatientDetailPage.tsx b/assets/admin/pages/ClaimPatientDetailPage.tsx new file mode 100644 index 00000000..4a35ae8b --- /dev/null +++ b/assets/admin/pages/ClaimPatientDetailPage.tsx @@ -0,0 +1,269 @@ +import { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useParams, useSearchParams } from 'react-router-dom'; +import { toast } from 'sonner'; +import { api } from '../lib/api'; +import { formatRial, formatNumber, formatDate, formatDateTime } from '../lib/utils'; +import type { ClaimDetailRow, ClaimStatusLogEntry } from '../types'; +import type { ApiResponse } from '../lib/api'; +import PageHeader from '../components/ui/PageHeader'; +import DataTable, { type Column } from '../components/ui/DataTable'; +import StatusBadge from '../components/ui/StatusBadge'; +import SearchableSelect from '../components/ui/SearchableSelect'; +import Modal from '../components/ui/Modal'; +import FeatureGate from '../components/ui/FeatureGate'; + +interface PatientHeader { + uuid: string; + record_uuid: string; + full_name: string | null; + mobile: string | null; + national_code: string | null; +} + +interface DetailPayload { + patient: PatientHeader; + claims: ClaimDetailRow[]; +} + +const STATUS_OPTIONS = [ + { value: '', label: 'همه وضعیت‌ها' }, + { value: 'pending', label: 'در انتظار ارسال' }, + { value: 'submitted', label: 'ارسال شده' }, + { value: 'approved', label: 'تأیید شده' }, + { value: 'rejected', label: 'رد شده' }, + { value: 'paid', label: 'پرداخت شده' }, +]; + +const ACTION_LABEL: Record = { + submitted: 'ارسال به بیمه', + approved: 'تأیید', + rejected: 'رد', + paid: 'ثبت پرداخت', +}; + +/** نام endpoint برای هر وضعیت مقصد — وضعیت‌ها اسم فعل ندارند. */ +const ACTION_PATH: Record = { + submitted: 'submit', + approved: 'approve', + rejected: 'reject', + paid: 'pay', +}; + +const KIND_LABEL: Record = { base: 'پایه', supplementary: 'تکمیلی' }; + +function Timeline({ logs }: { logs: ClaimStatusLogEntry[] }) { + if (logs.length === 0) return
رویدادی ثبت نشده است.
; + + return ( +
+ {logs.map((log) => ( +
+ +
+
+ +
+
+ {formatDateTime(log.at)} + {log.by ? ` · ${log.by}` : ''} +
+ {log.note &&
{log.note}
} +
+
+ ))} +
+ ); +} + +/** داشبورد پرونده‌های بیمه — سطح دوم: همه‌ی مطالبات یک بیمار با تاریخچه‌ی کامل. */ +export default function ClaimPatientDetailPage() { + const { patientUuid = '' } = useParams(); + const [params, setParams] = useSearchParams(); + const queryClient = useQueryClient(); + + const [selected, setSelected] = useState(null); + const [pendingAction, setPendingAction] = useState<{ claim: ClaimDetailRow; target: string } | null>(null); + const [reason, setReason] = useState(''); + const [trackingNumber, setTrackingNumber] = useState(''); + + const status = params.get('status') ?? ''; + + const setParam = (patch: Record) => { + const next = new URLSearchParams(params); + Object.entries(patch).forEach(([k, v]) => (v ? next.set(k, v) : next.delete(k))); + setParams(next, { replace: true }); + }; + + const queryString = status ? `?status=${status}` : ''; + + const detailQuery = useQuery>({ + queryKey: ['claims-patient-detail', patientUuid, status], + queryFn: () => api.get(`/api/v1/billing/claims/by-patient/${patientUuid}${queryString}`), + enabled: !!patientUuid, + }); + + const payload = detailQuery.data?.data ?? null; + const patient = payload?.patient ?? null; + const claims = payload?.claims ?? []; + + const transitionMut = useMutation({ + mutationFn: ({ uuid, target, body }: { uuid: string; target: string; body: Record }) => + api.post(`/api/v1/billing/claims/${uuid}/${ACTION_PATH[target]}`, body), + onSuccess: () => { + toast.success('وضعیت مطالبه به‌روز شد'); + queryClient.invalidateQueries({ queryKey: ['claims-patient-detail'] }); + queryClient.invalidateQueries({ queryKey: ['claims-by-patient'] }); + queryClient.invalidateQueries({ queryKey: ['insurance-debt'] }); + closeAction(); + }, + onError: (e: unknown) => toast.error(e instanceof Error ? e.message : 'ثبت تغییر وضعیت ناموفق بود'), + }); + + const closeAction = () => { + setPendingAction(null); + setReason(''); + setTrackingNumber(''); + }; + + const confirmAction = () => { + if (!pendingAction) return; + const { claim, target } = pendingAction; + if (target === 'rejected' && reason.trim() === '') { + toast.error('دلیل رد الزامی است'); + return; + } + transitionMut.mutate({ + uuid: claim.uuid, + target, + body: target === 'rejected' ? { reason: reason.trim() } : { tracking_number: trackingNumber.trim() || undefined }, + }); + }; + + const columns: Column[] = [ + { key: 'visit_date', header: 'تاریخ مراجعه', render: (r) => (r.visit_date ? formatDate(r.visit_date) : '—') }, + { key: 'doctor_name', header: 'پزشک', render: (r) => r.doctor_name ?? '—' }, + { key: 'insurance_name', header: 'بیمه', render: (r) => `${r.insurance_name ?? '—'} (${KIND_LABEL[r.insurance_kind] ?? r.insurance_kind})` }, + { key: 'service_base_rials', header: 'مبلغ اصلی', render: (r) => formatRial(r.service_base_rials) }, + { key: 'coverage_percent', header: 'پوشش بیمه', render: (r) => `${formatNumber(r.coverage_percent)}٪` }, + { key: 'insurance_share_rials', header: 'سهم بیمه', render: (r) => formatRial(r.insurance_share_rials) }, + { key: 'patient_share_rials', header: 'سهم بیمار', render: (r) => formatRial(r.patient_share_rials) }, + { key: 'status', header: 'وضعیت', render: (r) => }, + { key: 'submitted_at', header: 'تاریخ ارسال', render: (r) => (r.submitted_at ? formatDate(r.submitted_at) : '—') }, + { key: 'settled_at', header: 'تاریخ تسویه', render: (r) => (r.settled_at ? formatDate(r.settled_at) : '—') }, + { key: 'tracking_number', header: 'شماره پیگیری', render: (r) => r.tracking_number ?? '—' }, + ]; + + return ( + + + +
+
+ + setParam({ status: v ? String(v) : '' })} + placeholder="همه وضعیت‌ها" + height={38} + /> +
+ + ( +
+ + {row.allowed_transitions.map((target) => ( + + ))} +
+ )} + /> +
+ + setSelected(null)}> + {selected && ( +
+
+
بیمه: {selected.insurance_name ?? '—'}
+
نوع: {KIND_LABEL[selected.insurance_kind] ?? selected.insurance_kind}
+
مبلغ اصلی: {formatRial(selected.service_base_rials)}
+
سهم بیمه: {formatRial(selected.insurance_share_rials)}
+
سهم بیمار: {formatRial(selected.patient_share_rials)}
+
تأییدشده: {selected.total_approved_rials !== null ? formatRial(selected.total_approved_rials) : '—'}
+
پرداخت‌شده: {selected.total_paid_rials !== null ? formatRial(selected.total_paid_rials) : '—'}
+
شماره پیگیری: {selected.tracking_number ?? '—'}
+
+ + {selected.reject_reason && ( +
دلیل رد: {selected.reject_reason}
+ )} + +
+
تاریخچه تغییرات
+ +
+
+ )} +
+ + + + + + } + > + {pendingAction?.target === 'rejected' ? ( +
+ +