- 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.
248 lines
10 KiB
TypeScript
248 lines
10 KiB
TypeScript
import { useMemo } from 'react';
|
|
import { useQuery } from '@tanstack/react-query';
|
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
|
import { ArrowPathIcon } from '@heroicons/react/24/outline';
|
|
import { api } from '../lib/api';
|
|
import { formatRial, formatNumber } from '../lib/utils';
|
|
import type { ClaimPatientRow } from '../types';
|
|
import type { ApiResponse, PaginatedResponse } from '../lib/api';
|
|
import PageHeader from '../components/ui/PageHeader';
|
|
import DataTable, { type Column } from '../components/ui/DataTable';
|
|
import StatCard from '../components/ui/StatCard';
|
|
import StatusBadge from '../components/ui/StatusBadge';
|
|
import SearchableSelect from '../components/ui/SearchableSelect';
|
|
import PersianDatePicker from '../components/ui/PersianDatePicker';
|
|
import FeatureGate from '../components/ui/FeatureGate';
|
|
import Pagination from '../components/ui/Pagination';
|
|
|
|
const LIMIT = 20;
|
|
|
|
const STATUS_OPTIONS = [
|
|
{ value: '', label: 'همه وضعیتها' },
|
|
{ value: 'pending', label: 'در انتظار ارسال' },
|
|
{ value: 'submitted', label: 'ارسال شده' },
|
|
{ value: 'approved', label: 'تأیید شده' },
|
|
{ value: 'rejected', label: 'رد شده' },
|
|
{ value: 'paid', label: 'پرداخت شده' },
|
|
];
|
|
|
|
const PAYMENT_OPTIONS = [
|
|
{ value: '', label: 'همه' },
|
|
{ value: 'paid', label: 'وصولشده' },
|
|
{ value: 'unpaid', label: 'وصولنشده' },
|
|
];
|
|
|
|
const isoNDaysAgo = (days: number): string => {
|
|
const d = new Date();
|
|
d.setDate(d.getDate() - days);
|
|
return d.toISOString().slice(0, 10);
|
|
};
|
|
const todayIso = (): string => new Date().toISOString().slice(0, 10);
|
|
|
|
const toUnix = (iso: string): number | null => {
|
|
if (!iso) return null;
|
|
const t = new Date(iso + 'T00:00:00').getTime();
|
|
return Number.isNaN(t) ? null : Math.floor(t / 1000);
|
|
};
|
|
const endOfDayUnix = (iso: string): number | null => {
|
|
if (!iso) return null;
|
|
const t = new Date(iso + 'T23:59:59').getTime();
|
|
return Number.isNaN(t) ? null : Math.floor(t / 1000);
|
|
};
|
|
|
|
interface DebtRow {
|
|
insurance_id: number;
|
|
insurance_name: string | null;
|
|
claimed: number;
|
|
approved: number;
|
|
paid: number;
|
|
debt: number;
|
|
}
|
|
|
|
/**
|
|
* داشبورد پروندههای بیمه — سطح اول: یک ردیف بهازای هر بیمار.
|
|
* فیلترها در query string زندگی میکنند تا رفرش و اشتراک لینک، نما را حفظ کند.
|
|
*/
|
|
export default function ClaimsPage() {
|
|
const navigate = useNavigate();
|
|
const [params, setParams] = useSearchParams();
|
|
|
|
const search = params.get('search') ?? '';
|
|
const status = params.get('status') ?? '';
|
|
const insuranceId = params.get('insurance_id') ?? '';
|
|
const paymentStatus = params.get('payment_status') ?? '';
|
|
const from = params.get('from') ?? '';
|
|
const to = params.get('to') ?? '';
|
|
const page = Math.max(1, Number(params.get('page') ?? 1));
|
|
const sort = params.get('sort') ?? 'last_activity_at';
|
|
const dir = (params.get('dir') ?? 'desc') as 'asc' | 'desc';
|
|
|
|
const hasFilters = !!(search || status || insuranceId || paymentStatus || from || to);
|
|
|
|
/** تغییر فیلتر همیشه به صفحهی اول برمیگردد؛ ماندن روی صفحه ۵ با نتیجهی جدید بیمعناست. */
|
|
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)));
|
|
if (!('page' in patch)) next.delete('page');
|
|
setParams(next, { replace: true });
|
|
};
|
|
|
|
const queryString = useMemo(() => {
|
|
const qs = new URLSearchParams();
|
|
qs.set('page', String(page));
|
|
qs.set('limit', String(LIMIT));
|
|
qs.set('sort', sort);
|
|
qs.set('dir', dir);
|
|
if (search) qs.set('search', search);
|
|
if (status) qs.set('status', status);
|
|
if (insuranceId) qs.set('insurance_id', insuranceId);
|
|
if (paymentStatus) qs.set('payment_status', paymentStatus);
|
|
const fromUnix = toUnix(from);
|
|
const toUnixVal = endOfDayUnix(to);
|
|
if (fromUnix) qs.set('from', String(fromUnix));
|
|
if (toUnixVal) qs.set('to', String(toUnixVal));
|
|
return qs.toString();
|
|
}, [page, sort, dir, search, status, insuranceId, paymentStatus, from, to]);
|
|
|
|
const listQuery = useQuery<PaginatedResponse<ClaimPatientRow>>({
|
|
queryKey: ['claims-by-patient', queryString],
|
|
queryFn: () => api.get(`/api/v1/billing/claims/by-patient?${queryString}`),
|
|
});
|
|
|
|
// یک fetch برای هر دو مصرف: کارتهای آمار و گزینههای فیلتر بیمه.
|
|
const debtQuery = useQuery<ApiResponse<{ data: DebtRow[] }>>({
|
|
queryKey: ['insurance-debt'],
|
|
queryFn: () => api.get('/api/v1/billing/reports/insurance-debt'),
|
|
});
|
|
|
|
const rows = listQuery.data?.data ?? [];
|
|
const total = listQuery.data?.meta?.totalRecords ?? 0;
|
|
const debt: DebtRow[] = debtQuery.data?.data?.data ?? [];
|
|
|
|
const totals = useMemo(
|
|
() => debt.reduce(
|
|
(acc, d) => ({
|
|
claimed: acc.claimed + d.claimed,
|
|
paid: acc.paid + d.paid,
|
|
debt: acc.debt + d.debt,
|
|
}),
|
|
{ claimed: 0, paid: 0, debt: 0 },
|
|
),
|
|
[debt],
|
|
);
|
|
|
|
const insuranceOptions = useMemo(
|
|
() => [
|
|
{ value: '', label: 'همه بیمهها' },
|
|
...debt.map((d) => ({ value: String(d.insurance_id), label: d.insurance_name ?? `بیمه #${d.insurance_id}` })),
|
|
],
|
|
[debt],
|
|
);
|
|
|
|
const columns: Column<ClaimPatientRow>[] = [
|
|
{ key: 'full_name', header: 'بیمار', sortable: true, render: (r) => r.full_name ?? '—' },
|
|
{ key: 'mobile', header: 'موبایل', render: (r) => r.mobile ?? '—' },
|
|
{ key: 'national_code', header: 'کد ملی', render: (r) => r.national_code ?? '—' },
|
|
{ key: 'claims_count', header: 'تعداد درخواست', sortable: true, render: (r) => formatNumber(r.claims_count) },
|
|
{ key: 'total_services_rials', header: 'مجموع خدمات', sortable: true, render: (r) => formatRial(r.total_services_rials) },
|
|
{ key: 'total_insurance_rials', header: 'سهم بیمه', sortable: true, render: (r) => formatRial(r.total_insurance_rials) },
|
|
{ key: 'total_patient_rials', header: 'سهم بیمار', render: (r) => formatRial(r.total_patient_rials) },
|
|
{ key: 'overall_status', header: 'وضعیت کلی', render: (r) => <StatusBadge type="claim" value={r.overall_status} /> },
|
|
];
|
|
|
|
return (
|
|
<FeatureGate feature="insurance">
|
|
<PageHeader
|
|
title="پروندههای بیمه"
|
|
description="پیگیری مطالبات بیمه به تفکیک بیمار"
|
|
breadcrumbs={[{ label: 'داشبورد', to: '/admin' }, { label: 'پروندههای بیمه' }]}
|
|
/>
|
|
|
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 'var(--gap)', marginBottom: 'var(--gap)' }}>
|
|
<StatCard tone="violet" label="مجموع ادعای بیمه" value={formatRial(totals.claimed)} />
|
|
<StatCard tone="green" label="وصولشده" value={formatRial(totals.paid)} />
|
|
<StatCard tone="pink" label="مانده وصولنشده" value={formatRial(totals.debt)} />
|
|
<StatCard tone="amber" label="تعداد بیماران" value={formatNumber(total)} />
|
|
</div>
|
|
|
|
<div className="card" style={{ padding: 18 }}>
|
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 10, alignItems: 'flex-end', marginBottom: 16 }}>
|
|
<div style={{ minWidth: 150 }}>
|
|
<label className="field-label">بیمه</label>
|
|
<SearchableSelect
|
|
options={insuranceOptions}
|
|
value={insuranceId}
|
|
onChange={(v) => setParam({ insurance_id: v ? String(v) : '' })}
|
|
placeholder="همه بیمهها"
|
|
height={38}
|
|
/>
|
|
</div>
|
|
<div style={{ minWidth: 150 }}>
|
|
<label className="field-label">وضعیت</label>
|
|
<SearchableSelect
|
|
options={STATUS_OPTIONS}
|
|
value={status}
|
|
onChange={(v) => setParam({ status: v ? String(v) : '' })}
|
|
placeholder="همه وضعیتها"
|
|
height={38}
|
|
/>
|
|
</div>
|
|
<div style={{ minWidth: 130 }}>
|
|
<label className="field-label">وضعیت پرداخت</label>
|
|
<SearchableSelect
|
|
options={PAYMENT_OPTIONS}
|
|
value={paymentStatus}
|
|
onChange={(v) => setParam({ payment_status: v ? String(v) : '' })}
|
|
placeholder="همه"
|
|
height={38}
|
|
/>
|
|
</div>
|
|
<div style={{ minWidth: 140 }}>
|
|
<label className="field-label">از تاریخ</label>
|
|
<PersianDatePicker value={from} onChange={(v) => setParam({ from: v })} height={38} />
|
|
</div>
|
|
<div style={{ minWidth: 140 }}>
|
|
<label className="field-label">تا تاریخ</label>
|
|
<PersianDatePicker value={to} onChange={(v) => setParam({ to: v })} height={38} />
|
|
</div>
|
|
<div style={{ display: 'flex', gap: 6 }}>
|
|
<button className="btn ghost sm" onClick={() => setParam({ from: isoNDaysAgo(30), to: todayIso() })}>یک ماه اخیر</button>
|
|
<button className="btn ghost sm" onClick={() => setParam({ from: isoNDaysAgo(365), to: todayIso() })}>یک سال اخیر</button>
|
|
{hasFilters && (
|
|
<button
|
|
className="btn ghost sm"
|
|
onClick={() => setParams(new URLSearchParams(), { replace: true })}
|
|
style={{ display: 'flex', alignItems: 'center', gap: 4 }}
|
|
>
|
|
<ArrowPathIcon style={{ width: 13 }} /> پاککردن فیلترها
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<DataTable
|
|
columns={columns}
|
|
data={rows}
|
|
loading={listQuery.isLoading}
|
|
searchValue={search}
|
|
onSearchChange={(v) => setParam({ search: v })}
|
|
searchPlaceholder="نام، موبایل یا کد ملی بیمار"
|
|
emptyMessage="پرونده بیمهای ثبت نشده است."
|
|
sortKey={sort}
|
|
sortDir={dir}
|
|
onSort={(key) => setParam({ sort: key, dir: sort === key && dir === 'desc' ? 'asc' : 'desc' })}
|
|
actions={(row) => (
|
|
<button className="btn primary sm" onClick={() => navigate(`/admin/claims/${row.record_uuid}`)}>
|
|
جزئیات
|
|
</button>
|
|
)}
|
|
/>
|
|
|
|
{total > LIMIT && (
|
|
<Pagination page={page} total={total} limit={LIMIT} onPageChange={(p) => setParam({ page: String(p) })} />
|
|
)}
|
|
</div>
|
|
</FeatureGate>
|
|
);
|
|
}
|