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.
This commit is contained in:
@@ -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<string, string> = {
|
||||
submitted: 'ارسال به بیمه',
|
||||
approved: 'تأیید',
|
||||
rejected: 'رد',
|
||||
paid: 'ثبت پرداخت',
|
||||
};
|
||||
|
||||
/** نام endpoint برای هر وضعیت مقصد — وضعیتها اسم فعل ندارند. */
|
||||
const ACTION_PATH: Record<string, string> = {
|
||||
submitted: 'submit',
|
||||
approved: 'approve',
|
||||
rejected: 'reject',
|
||||
paid: 'pay',
|
||||
};
|
||||
|
||||
const KIND_LABEL: Record<string, string> = { base: 'پایه', supplementary: 'تکمیلی' };
|
||||
|
||||
function Timeline({ logs }: { logs: ClaimStatusLogEntry[] }) {
|
||||
if (logs.length === 0) return <div style={{ fontSize: 13, color: 'var(--text-3)' }}>رویدادی ثبت نشده است.</div>;
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{logs.map((log) => (
|
||||
<div key={log.uuid} style={{ display: 'flex', gap: 10, alignItems: 'flex-start' }}>
|
||||
<span style={{ marginTop: 6, width: 8, height: 8, borderRadius: '50%', background: 'var(--primary)', flexShrink: 0 }} />
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600 }}>
|
||||
<StatusBadge type="claim" value={log.to_status} />
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-3)', marginTop: 4 }}>
|
||||
{formatDateTime(log.at)}
|
||||
{log.by ? ` · ${log.by}` : ''}
|
||||
</div>
|
||||
{log.note && <div style={{ fontSize: 12.5, marginTop: 4 }}>{log.note}</div>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** داشبورد پروندههای بیمه — سطح دوم: همهی مطالبات یک بیمار با تاریخچهی کامل. */
|
||||
export default function ClaimPatientDetailPage() {
|
||||
const { patientUuid = '' } = useParams();
|
||||
const [params, setParams] = useSearchParams();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [selected, setSelected] = useState<ClaimDetailRow | null>(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<string, string>) => {
|
||||
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<ApiResponse<DetailPayload>>({
|
||||
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<string, unknown> }) =>
|
||||
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<ClaimDetailRow>[] = [
|
||||
{ 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) => <StatusBadge type="claim" value={r.status} /> },
|
||||
{ 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 (
|
||||
<FeatureGate feature="insurance">
|
||||
<PageHeader
|
||||
title={patient?.full_name ?? 'پرونده بیمه بیمار'}
|
||||
description={[patient?.mobile, patient?.national_code].filter(Boolean).join(' · ') || undefined}
|
||||
breadcrumbs={[
|
||||
{ label: 'داشبورد', to: '/admin' },
|
||||
{ label: 'پروندههای بیمه', to: '/admin/claims' },
|
||||
{ label: patient?.full_name ?? '—' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="card" style={{ padding: 18 }}>
|
||||
<div style={{ minWidth: 180, marginBottom: 16 }}>
|
||||
<label className="field-label">وضعیت</label>
|
||||
<SearchableSelect
|
||||
options={STATUS_OPTIONS}
|
||||
value={status}
|
||||
onChange={(v) => setParam({ status: v ? String(v) : '' })}
|
||||
placeholder="همه وضعیتها"
|
||||
height={38}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={claims}
|
||||
loading={detailQuery.isLoading}
|
||||
emptyMessage="مطالبهای برای این بیمار ثبت نشده است."
|
||||
actions={(row) => (
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||
<button className="btn ghost sm" onClick={() => setSelected(row)}>جزئیات</button>
|
||||
{row.allowed_transitions.map((target) => (
|
||||
<button
|
||||
key={target}
|
||||
className="btn primary sm"
|
||||
onClick={() => setPendingAction({ claim: row, target })}
|
||||
disabled={transitionMut.isPending}
|
||||
>
|
||||
{ACTION_LABEL[target] ?? target}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Modal open={!!selected} title="جزئیات مطالبه" size="lg" onClose={() => setSelected(null)}>
|
||||
{selected && (
|
||||
<div dir="rtl" style={{ display: 'flex', flexDirection: 'column', gap: 18 }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))', gap: 12, fontSize: 13 }}>
|
||||
<div><span style={{ color: 'var(--text-3)' }}>بیمه: </span>{selected.insurance_name ?? '—'}</div>
|
||||
<div><span style={{ color: 'var(--text-3)' }}>نوع: </span>{KIND_LABEL[selected.insurance_kind] ?? selected.insurance_kind}</div>
|
||||
<div><span style={{ color: 'var(--text-3)' }}>مبلغ اصلی: </span>{formatRial(selected.service_base_rials)}</div>
|
||||
<div><span style={{ color: 'var(--text-3)' }}>سهم بیمه: </span>{formatRial(selected.insurance_share_rials)}</div>
|
||||
<div><span style={{ color: 'var(--text-3)' }}>سهم بیمار: </span>{formatRial(selected.patient_share_rials)}</div>
|
||||
<div><span style={{ color: 'var(--text-3)' }}>تأییدشده: </span>{selected.total_approved_rials !== null ? formatRial(selected.total_approved_rials) : '—'}</div>
|
||||
<div><span style={{ color: 'var(--text-3)' }}>پرداختشده: </span>{selected.total_paid_rials !== null ? formatRial(selected.total_paid_rials) : '—'}</div>
|
||||
<div><span style={{ color: 'var(--text-3)' }}>شماره پیگیری: </span>{selected.tracking_number ?? '—'}</div>
|
||||
</div>
|
||||
|
||||
{selected.reject_reason && (
|
||||
<div style={{ fontSize: 13, color: 'var(--danger)' }}>دلیل رد: {selected.reject_reason}</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<div style={{ fontSize: 14, fontWeight: 600, marginBottom: 12 }}>تاریخچه تغییرات</div>
|
||||
<Timeline logs={selected.logs} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
open={!!pendingAction}
|
||||
title={pendingAction ? ACTION_LABEL[pendingAction.target] ?? 'تغییر وضعیت' : ''}
|
||||
size="sm"
|
||||
onClose={closeAction}
|
||||
footer={
|
||||
<>
|
||||
<button className="btn ghost sm" onClick={closeAction}>انصراف</button>
|
||||
<button className="btn primary sm" onClick={confirmAction} disabled={transitionMut.isPending}>تأیید</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{pendingAction?.target === 'rejected' ? (
|
||||
<div>
|
||||
<label className="field-label">دلیل رد (الزامی)</label>
|
||||
<textarea
|
||||
className="input"
|
||||
rows={3}
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
placeholder="دلیل رد مطالبه توسط بیمه"
|
||||
/>
|
||||
</div>
|
||||
) : pendingAction?.target === 'submitted' ? (
|
||||
<div>
|
||||
<label className="field-label">شماره پیگیری بیمه (اختیاری)</label>
|
||||
<input
|
||||
className="input"
|
||||
value={trackingNumber}
|
||||
onChange={(e) => setTrackingNumber(e.target.value)}
|
||||
placeholder="مثلاً TM-4419-88"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ fontSize: 13 }}>آیا از ثبت این تغییر وضعیت مطمئن هستید؟</div>
|
||||
)}
|
||||
</Modal>
|
||||
</FeatureGate>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user