feat(claims): enhance claims filtering with insurance, date range, and patient search
This commit is contained in:
@@ -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)}`}
|
||||
|
||||
Reference in New Issue
Block a user