Files
clinicpro/assets/admin/pages/ClaimsPage.tsx
T
hamed 6876135a53 feat: add BlogBodySanitizer for HTML sanitization on article save
- Implemented BlogBodySanitizer to clean HTML content before saving articles, ensuring security against XSS attacks.
- Added tests for BlogBodySanitizer to verify that unsafe tags and attributes are stripped from the content.
- Introduced ApiLeastPrivilegeTest to ensure that unauthorized users cannot access sensitive API routes, maintaining strict access control.
2026-08-07 21:13:38 +03:30

287 lines
12 KiB
TypeScript

import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useNavigate, useSearchParams } from 'react-router';
import { ArrowPathIcon } from '@heroicons/react/24/outline';
import { api } from '../lib/api';
import { formatRial, formatNumber, toGregorianDate, todayIso } 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: 'وصول‌نشده' },
];
/** واژگان مطالبه `base` است، نه `basic` که در قرارداد بیمه به کار می‌رود. */
const KIND_OPTIONS = [
{ value: '', label: 'همه انواع' },
{ value: 'base', label: 'پایه' },
{ value: 'supplementary', label: 'تکمیلی' },
];
const KIND_LABEL: Record<string, string> = { base: 'پایه', supplementary: 'تکمیلی' };
const isoNDaysAgo = (days: number): string => {
const d = new Date();
d.setDate(d.getDate() - days);
return toGregorianDate(d);
};
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 kind = params.get('kind') ?? '';
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 || kind || 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 (kind) qs.set('kind', kind);
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, kind, 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: 'insurances',
header: 'بیمه',
render: (r) => (
(r.insurances ?? []).length === 0 ? '—' : (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
{r.insurances.map((i) => (
<span
key={`${i.insurance_id}-${i.kind}`}
className={`badge ${i.kind === 'supplementary' ? 'violet' : 'blue'}`}
title={KIND_LABEL[i.kind ?? ''] ?? i.kind ?? ''}
>
{i.insurance_name ?? `بیمه #${i.insurance_id}`}
</span>
))}
</div>
)
),
},
{ 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: 130 }}>
<label className="field-label">نوع بیمه</label>
<SearchableSelect
options={KIND_OPTIONS}
value={kind}
onChange={(v) => setParam({ kind: 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>
);
}