feat(claims): enhance claims filtering with insurance, date range, and patient search

This commit is contained in:
hamed
2026-06-24 06:19:25 +03:30
parent 69beb7b944
commit 91fb55258a
5 changed files with 196 additions and 17 deletions
+98 -6
View File
@@ -1,14 +1,26 @@
import { useState } from 'react';
import { useState, useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { CheckIcon, XMarkIcon, PaperAirplaneIcon, BanknotesIcon } from '@heroicons/react/24/outline';
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 PersianDateInput from '../components/ui/PersianDateInput';
import FeatureGate from '../components/ui/FeatureGate';
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;
@@ -30,9 +42,13 @@ interface Claim {
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;
@@ -56,14 +72,36 @@ const STATUS_FILTERS = ['', 'pending', 'submitted', 'approved', 'rejected', 'pai
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 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());
const s = p.toString();
return s ? `?${s}` : '';
}, [statusFilter, insuranceFilter, fromDate, toDate, search]);
const claimsQuery = useQuery<{ data: { data: Claim[] } }>({
queryKey: ['claims', statusFilter],
queryFn: () => api.get(`/api/v1/billing/claims${statusFilter ? `?status=${statusFilter}` : ''}`),
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'),
@@ -113,16 +151,64 @@ export default function ClaimsPage() {
</div>
<div className="card" style={{ padding: 18 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 14 }}>
<h2 style={{ fontSize: 14, fontWeight: 700, margin: 0 }}>مطالبات</h2>
<div style={{ minWidth: 160 }}>
{(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>
<PersianDateInput value={fromDate} onChange={setFromDate} placeholder="از تاریخ" />
</div>
<div>
<label className="field-label">تا تاریخ</label>
<PersianDateInput value={toDate} onChange={setToDate} placeholder="تا تاریخ" />
</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 ? (
@@ -142,6 +228,12 @@ export default function ClaimsPage() {
<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)}`}