Files
clinicpro/assets/admin/pages/ClaimsPage.tsx
T
hamed 20bdc49e89 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.
2026-07-18 23:38:02 +03:30

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>
);
}