Files
clinicpro/assets/admin/pages/ClaimsPage.tsx
T
hamedandClaude Opus 4.8 2f3f25f48e fix(admin): paginate claims & settlement lists for backend-audit 50-cap
GET /billing/claims and GET /settlement now return only the first 50 rows by
default (data.data unchanged, data.meta added). ClaimsPage and
RepresentationSettlementPage read the full array with no pager, so rows beyond 50
were unreachable. Add page state + ?page/limit + the existing <Pagination>
(reading data.meta.totalRecords).

No change needed for: 422 on claim approve/pay (api.ts already surfaces the
backend message via toast; the admin UI sends no amount so it's unreachable),
the owner-only appointment-settings endpoints (admin user bypasses), and refresh
rotation (authStore.refresh already persists the rotated refresh_token).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 21:49:44 +03:30

367 lines
18 KiB
TypeScript

import { useState, useMemo, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { CheckIcon, XMarkIcon, PaperAirplaneIcon, BanknotesIcon, MagnifyingGlassIcon, ArrowPathIcon } from '@heroicons/react/24/outline';
import { toast } from 'sonner';
import { api } from '../lib/api';
import { formatRial, formatNumber, formatDate } from '../lib/utils';
import PageHeader from '../components/ui/PageHeader';
import Modal from '../components/ui/Modal';
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 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 ClaimItem {
invoice_item_id: number;
claimed_rials: number;
approved_rials: number | null;
title: string | null;
is_visit: boolean;
quantity: number;
total_rials: number | null;
visit_date: number | null;
}
interface Claim {
uuid: string;
insurance_id: number;
insurance_name: string | null;
insurance_kind: string;
total_claimed_rials: number;
total_approved_rials: number | null;
total_paid_rials: number | null;
status: string;
reject_reason: string | null;
patient_name: string | null;
patient_mobile: string | null;
items: ClaimItem[];
}
interface InsuranceOption { insurance_id: number; insurance_name: string | null }
interface DebtRow {
insurance_id: number;
insurance_name: string | null;
claimed: number;
approved: number;
paid: number;
debt: number;
}
const STATUS_META: Record<string, { label: string; cls: string }> = {
pending: { label: 'در انتظار', cls: 'gray' },
submitted: { label: 'ارسال‌شده', cls: 'blue' },
approved: { label: 'تأییدشده', cls: 'amber' },
rejected: { label: 'ردشده', cls: 'red' },
paid: { label: 'پرداخت‌شده', cls: 'green' },
};
const KIND_LABEL: Record<string, string> = { base: 'پایه', supplementary: 'تکمیلی' };
const STATUS_FILTERS = ['', 'pending', 'submitted', 'approved', 'rejected', 'paid'];
export default function ClaimsPage() {
const qc = useQueryClient();
const [statusFilter, setStatusFilter] = useState('');
const [insuranceFilter, setInsuranceFilter] = useState('');
const [fromDate, setFromDate] = useState('');
const [toDate, setToDate] = useState('');
const [search, setSearch] = useState('');
const [searchInput, setSearchInput] = useState('');
const [rejectTarget, setRejectTarget] = useState<Claim | null>(null);
const [rejectReason, setRejectReason] = useState('');
const [page, setPage] = useState(1);
const limit = 20;
// back to page 1 whenever a filter changes
useEffect(() => { setPage(1); }, [statusFilter, insuranceFilter, fromDate, toDate, search]);
const queryString = useMemo(() => {
const p = new URLSearchParams();
if (statusFilter) p.set('status', statusFilter);
if (insuranceFilter) p.set('insurance_id', insuranceFilter);
const from = toUnix(fromDate); if (from) p.set('from', String(from));
const to = endOfDayUnix(toDate); if (to) p.set('to', String(to));
if (search.trim()) p.set('q', search.trim());
p.set('page', String(page));
p.set('limit', String(limit));
const s = p.toString();
return s ? `?${s}` : '';
}, [statusFilter, insuranceFilter, fromDate, toDate, search, page]);
const claimsQuery = useQuery<{ data: { data: Claim[] } }>({
queryKey: ['claims', queryString],
queryFn: () => api.get(`/api/v1/billing/claims${queryString}`),
});
const insuranceOptionsQuery = useQuery<{ data: { data: InsuranceOption[] } }>({
queryKey: ['claim-insurance-options'],
queryFn: () => api.get('/api/v1/billing/reports/insurance-debt'),
});
const insuranceOptions = ((insuranceOptionsQuery.data as any)?.data?.data ?? []) as InsuranceOption[];
const debtQuery = useQuery<{ data: { data: DebtRow[] } }>({
queryKey: ['insurance-debt'],
queryFn: () => api.get('/api/v1/billing/reports/insurance-debt'),
});
const claims = (claimsQuery.data as any)?.data?.data ?? [];
const claimsTotal = (claimsQuery.data as any)?.data?.meta?.totalRecords ?? 0;
const debt = (debtQuery.data as any)?.data?.data ?? [];
const transitionMut = useMutation({
mutationFn: ({ uuid, action, body }: { uuid: string; action: string; body?: object }) =>
api.post(`/api/v1/billing/claims/${uuid}/${action}`, body ?? {}),
onSuccess: () => {
toast.success('وضعیت مطالبه به‌روزرسانی شد');
setRejectTarget(null);
setRejectReason('');
qc.invalidateQueries({ queryKey: ['claims'] });
qc.invalidateQueries({ queryKey: ['insurance-debt'] });
},
onError: (e: Error) => toast.error(e.message),
});
return (
<FeatureGate feature="insurance">
<div className="fade-in">
<PageHeader title="مطالبات بیمه" description="پیگیری مطالبات و بدهی بیمه‌ها" />
<div className="card" style={{ padding: 18, marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
<BanknotesIcon style={{ width: 18, color: 'var(--text-3)' }} />
<h2 style={{ fontSize: 14, fontWeight: 700, margin: 0 }}>بدهی بیمه‌ها</h2>
</div>
{debtQuery.isLoading ? (
<div className="muted" style={{ fontSize: 13 }}>در حال بارگذاری...</div>
) : debt.length === 0 ? (
<div style={{ fontSize: 12.5, color: 'var(--text-3)' }}>بدهی‌ای ثبت نشده است.</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{debt.map((d: DebtRow) => (
<div key={d.insurance_id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '8px 12px', borderRadius: 8, border: '1px solid var(--border)', fontSize: 13 }}>
<span style={{ fontWeight: 600 }}>{d.insurance_name ?? `بیمه #${formatNumber(d.insurance_id)}`}</span>
<span style={{ color: 'var(--text-3)' }}>ادعا {formatRial(d.claimed)} · پرداخت {formatRial(d.paid)}</span>
<span style={{ fontWeight: 700, color: d.debt > 0 ? 'var(--danger)' : 'var(--success, #16a34a)' }}>بدهی {formatRial(d.debt)}</span>
</div>
))}
</div>
)}
</div>
<div className="card" style={{ padding: 18 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 14 }}>
<h2 style={{ fontSize: 14, fontWeight: 700, margin: 0 }}>مطالبات</h2>
{(statusFilter || insuranceFilter || fromDate || toDate || search) && (
<button
className="btn ghost sm"
onClick={() => { setStatusFilter(''); setInsuranceFilter(''); setFromDate(''); setToDate(''); setSearch(''); setSearchInput(''); }}
style={{ display: 'flex', alignItems: 'center', gap: 4 }}
>
<ArrowPathIcon style={{ width: 13 }} /> پاک‌کردن فیلترها
</button>
)}
</div>
{/* نوار فیلتر */}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 10, alignItems: 'flex-end', marginBottom: 16 }}>
<div style={{ minWidth: 150 }}>
<label className="field-label">بیمه</label>
<SearchableSelect
options={[{ value: '', label: 'همه بیمه‌ها' }, ...insuranceOptions.map((o) => ({ value: String(o.insurance_id), label: o.insurance_name ?? `بیمه #${o.insurance_id}` }))]}
value={insuranceFilter}
onChange={(v) => setInsuranceFilter(v ? String(v) : '')}
placeholder="همه بیمه‌ها"
height={38}
/>
</div>
<div style={{ minWidth: 150 }}>
<label className="field-label">وضعیت</label>
<SearchableSelect
options={STATUS_FILTERS.map((s) => ({ value: s, label: s === '' ? 'همه وضعیت‌ها' : STATUS_META[s]?.label ?? s }))}
value={statusFilter}
onChange={(v) => setStatusFilter(v ? String(v) : '')}
placeholder="همه وضعیت‌ها"
height={38}
/>
</div>
<div>
<label className="field-label">از تاریخ</label>
<PersianDatePicker value={fromDate} onChange={setFromDate} placeholder="از تاریخ" />
</div>
<div>
<label className="field-label">تا تاریخ</label>
<PersianDatePicker value={toDate} onChange={setToDate} placeholder="تا تاریخ" />
</div>
<div style={{ display: 'flex', gap: 6, alignItems: 'flex-end', paddingBottom: 1 }}>
<button
type="button"
className="btn sm"
onClick={() => { setFromDate(isoNDaysAgo(365)); setToDate(todayIso()); }}
title="از یک سال پیش تا امروز"
>
یک سال اخیر
</button>
<button
type="button"
className="btn sm"
onClick={() => { setFromDate(isoNDaysAgo(30)); setToDate(todayIso()); }}
title="از یک ماه پیش تا امروز"
>
یک ماه اخیر
</button>
</div>
<div style={{ flex: 1, minWidth: 200 }}>
<label className="field-label">جستجوی بیمار (نام / موبایل / کدملی)</label>
<div style={{ position: 'relative' }}>
<MagnifyingGlassIcon style={{ width: 15, position: 'absolute', insetInlineStart: 10, top: '50%', transform: 'translateY(-50%)', color: 'var(--text-3)', pointerEvents: 'none' }} />
<input
className="input"
style={{ height: 38, paddingInlineStart: 32 }}
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') setSearch(searchInput); }}
placeholder="نام، موبایل یا کدملی بیمار"
/>
</div>
</div>
<button className="btn primary" style={{ height: 38 }} onClick={() => setSearch(searchInput)}>جستجو</button>
</div>
{claimsQuery.isLoading ? (
<div className="muted" style={{ fontSize: 13 }}>در حال بارگذاری...</div>
) : claims.length === 0 ? (
<div style={{ fontSize: 12.5, color: 'var(--text-3)', padding: '12px 0' }}>مطالبه‌ای یافت نشد.</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{claims.map((c: Claim) => {
const meta = STATUS_META[c.status] ?? { label: c.status, cls: 'gray' };
return (
<div key={c.uuid} style={{ padding: '12px 14px', borderRadius: 10, border: '1px solid var(--border)' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 10 }}>
<div>
<div style={{ fontWeight: 600, fontSize: 13.5 }}>
{c.insurance_name ?? `بیمه #${formatNumber(c.insurance_id)}`}
<span className="badge gray" style={{ fontSize: 10, marginInlineStart: 6 }}>{KIND_LABEL[c.insurance_kind] ?? c.insurance_kind}</span>
<span className={`badge ${meta.cls}`} style={{ fontSize: 10, marginInlineStart: 4 }}><span className="bdot" />{meta.label}</span>
</div>
{c.patient_name && (
<div style={{ fontSize: 12, color: 'var(--text-2)', marginTop: 4, fontWeight: 500 }}>
بیمار: {c.patient_name}
{c.patient_mobile && <span style={{ color: 'var(--text-3)', fontWeight: 400 }} dir="ltr">{' '}{c.patient_mobile}</span>}
</div>
)}
<div style={{ fontSize: 11.5, color: 'var(--text-3)', marginTop: 3 }}>
ادعا {formatRial(c.total_claimed_rials)}
{c.total_approved_rials != null && ` · تأیید ${formatRial(c.total_approved_rials)}`}
{c.total_paid_rials != null && ` · پرداخت ${formatRial(c.total_paid_rials)}`}
{c.reject_reason && ` · دلیل رد: ${c.reject_reason}`}
</div>
</div>
<div style={{ display: 'flex', gap: 6, flexShrink: 0 }}>
{c.status === 'pending' && (
<button className="btn primary sm" disabled={transitionMut.isPending} onClick={() => transitionMut.mutate({ uuid: c.uuid, action: 'submit' })}>
<PaperAirplaneIcon style={{ width: 13 }} /> ارسال
</button>
)}
{c.status === 'submitted' && (
<>
<button className="btn primary sm" disabled={transitionMut.isPending} onClick={() => transitionMut.mutate({ uuid: c.uuid, action: 'approve' })}>
<CheckIcon style={{ width: 13 }} /> تأیید
</button>
<button className="btn danger sm" onClick={() => { setRejectTarget(c); setRejectReason(''); }}>
<XMarkIcon style={{ width: 13 }} /> رد
</button>
</>
)}
{c.status === 'approved' && (
<button className="btn primary sm" disabled={transitionMut.isPending} onClick={() => transitionMut.mutate({ uuid: c.uuid, action: 'pay' })}>
<BanknotesIcon style={{ width: 13 }} /> پرداخت
</button>
)}
</div>
</div>
{c.items.length > 0 && (
<div style={{ marginTop: 12, borderTop: '1px solid var(--border)', paddingTop: 10, overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12.5 }}>
<thead>
<tr style={{ color: 'var(--text-3)', textAlign: 'right' }}>
<th style={{ fontWeight: 600, padding: '4px 8px' }}>شرح</th>
<th style={{ fontWeight: 600, padding: '4px 8px' }}>تاریخ مراجعه</th>
<th style={{ fontWeight: 600, padding: '4px 8px', textAlign: 'center' }}>تعداد</th>
<th style={{ fontWeight: 600, padding: '4px 8px', textAlign: 'left' }}>مبلغ کل</th>
<th style={{ fontWeight: 600, padding: '4px 8px', textAlign: 'left' }}>سهم بیمه (ادعا)</th>
<th style={{ fontWeight: 600, padding: '4px 8px', textAlign: 'left' }}>تأییدشده</th>
</tr>
</thead>
<tbody>
{c.items.map((it, idx) => (
<tr key={idx} style={{ borderTop: '1px solid var(--border)' }}>
<td style={{ padding: '7px 8px' }}>
<span className={`badge ${it.is_visit ? 'blue' : 'gray'}`} style={{ fontSize: 9.5, marginInlineEnd: 6 }}>
{it.is_visit ? 'ویزیت' : 'خدمت'}
</span>
<span style={{ fontWeight: 500 }}>{it.title ?? '—'}</span>
</td>
<td style={{ padding: '7px 8px', color: 'var(--text-3)' }} dir="ltr">
{it.visit_date ? formatDate(it.visit_date) : '—'}
</td>
<td style={{ padding: '7px 8px', textAlign: 'center' }}>{formatNumber(it.quantity)}</td>
<td style={{ padding: '7px 8px', textAlign: 'left' }} dir="ltr">{it.total_rials != null ? formatRial(it.total_rials) : '—'}</td>
<td style={{ padding: '7px 8px', textAlign: 'left', fontWeight: 600 }} dir="ltr">{formatRial(it.claimed_rials)}</td>
<td style={{ padding: '7px 8px', textAlign: 'left', color: 'var(--text-3)' }} dir="ltr">
{it.approved_rials != null ? formatRial(it.approved_rials) : '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
})}
</div>
)}
<Pagination page={page} total={claimsTotal} limit={limit} onPageChange={setPage} />
</div>
<Modal open={!!rejectTarget} onClose={() => setRejectTarget(null)} title="رد مطالبه"
footer={
<>
<button className="btn ghost sm" onClick={() => setRejectTarget(null)}>انصراف</button>
<button className="btn danger sm" disabled={!rejectReason || transitionMut.isPending}
onClick={() => rejectTarget && transitionMut.mutate({ uuid: rejectTarget.uuid, action: 'reject', body: { reason: rejectReason } })}>
رد کردن
</button>
</>
}
>
<div className="field">
<label>دلیل رد</label>
<textarea className="input" rows={3} dir="rtl" value={rejectReason} onChange={(e) => setRejectReason(e.target.value)} placeholder="دلیل رد را بنویسید..." />
</div>
</Modal>
</div>
</FeatureGate>
);
}