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>
367 lines
18 KiB
TypeScript
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>
|
|
);
|
|
}
|