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)}`}
+14 -2
View File
@@ -110,8 +110,20 @@
هر مطالبه در پاسخ علاوه بر `insurance_id`، فیلد `insurance_name` (نام بیمه، یا `null` اگر بیمه حذف شده باشد) را نیز دارد. در `POST` و `transition` و `GET` یکسان است.
## GET /api/v1/billing/claims?status=
لیست مطالبات tenant. فیلتر اختیاری `status` (`pending|submitted|approved|rejected|paid`). هر مطالبه شامل `insurance_name` و آرایه‌ی `items` با جزئیات هر ردیف است.
## GET /api/v1/billing/claims
لیست مطالبات tenant با فیلترهای اختیاری (query params):
| پارامتر | نوع | توضیح |
|---------|-----|-------|
| `status` | string | `pending\|submitted\|approved\|rejected\|paid` |
| `insurance_id` | int | فقط مطالبات یک بیمه‌گر |
| `from` | int (Unix) | مطالبات با `created_at >= from` (شروع بازه) |
| `to` | int (Unix) | مطالبات با `created_at <= to` (پایان بازه) |
| `q` | string | جستجوی بیمار: موبایل، کدملی یا نام (LIKE) |
> بازه‌ی تاریخ شمسی (سال/ماه/روز خاص) در سمت کلاینت به `from`/`to` یونیکس تبدیل می‌شود؛ backend فقط بازه‌ی یونیکس می‌گیرد. فیلتر `q` از طریق زنجیره `claim → claim_item → invoice_item → invoice → patient_record → user` با `EXISTS` اعمال می‌شود.
هر مطالبه شامل `insurance_name`، `patient_name`، `patient_mobile` و آرایه‌ی `items` با جزئیات هر ردیف است.
هر عضو `items` علاوه بر `invoice_item_id`، `claimed_rials`، `approved_rials`، این فیلدهای نمایشی را دارد (برای جدول صفحه‌ی مطالبات):
+24 -5
View File
@@ -59,13 +59,18 @@ class BillingController extends BaseController
$sessionIds[] = $d['patient_session_id'];
}
}
$sessionDates = $this->sessionRepo->datesForIds(array_values(array_unique($sessionIds)));
$uniqueSessionIds = array_values(array_unique($sessionIds));
$sessionDates = $this->sessionRepo->datesForIds($uniqueSessionIds);
$patientInfo = $this->sessionRepo->patientInfoForIds($uniqueSessionIds);
return array_map(function (Claim $claim) use ($itemDetails, $sessionDates) {
return array_map(function (Claim $claim) use ($itemDetails, $sessionDates, $patientInfo) {
$data = $claim->toArray();
$data['insurance_name'] = $this->insuranceRepo->find($claim->getInsuranceId())?->getName();
$data['items'] = array_map(function (array $item) use ($itemDetails, $sessionDates) {
$patientName = null;
$patientMobile = null;
$data['items'] = array_map(function (array $item) use ($itemDetails, $sessionDates, $patientInfo, &$patientName, &$patientMobile) {
$detail = $itemDetails[$item['invoice_item_id']] ?? null;
$sessionId = $detail['patient_session_id'] ?? null;
$item['title'] = $detail['title'] ?? null;
@@ -73,9 +78,17 @@ class BillingController extends BaseController
$item['quantity'] = $detail['quantity'] ?? 1;
$item['total_rials'] = $detail['total_rials'] ?? null;
$item['visit_date'] = $sessionId !== null ? ($sessionDates[$sessionId] ?? null) : null;
if ($patientName === null && $sessionId !== null && isset($patientInfo[$sessionId])) {
$patientName = $patientInfo[$sessionId]['name'];
$patientMobile = $patientInfo[$sessionId]['mobile'];
}
return $item;
}, $data['items']);
$data['patient_name'] = $patientName;
$data['patient_mobile'] = $patientMobile;
return $data;
}, $claims);
}
@@ -164,8 +177,14 @@ class BillingController extends BaseController
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
}
$status = $request->query->get('status') ?: null;
$claims = $this->claimRepo->findByTenant($entityType, $entityId, $status);
$filters = [
'status' => $request->query->get('status') ?: null,
'insurance_id' => $request->query->get('insurance_id') ?: null,
'from' => $request->query->get('from') ?: null,
'to' => $request->query->get('to') ?: null,
'q' => $request->query->get('q') ?: null,
];
$claims = $this->claimRepo->findByTenant($entityType, $entityId, $filters);
return $this->success(['data' => $this->enrichClaims($claims)]);
}
+33 -4
View File
@@ -18,8 +18,11 @@ class ClaimRepository extends ServiceEntityRepository
return $this->findOneBy(['uuid' => $uuid]);
}
/** @return Claim[] */
public function findByTenant(string $entityType, int $entityId, ?string $status = null): array
/**
* @param array{status?:?string, insurance_id?:?int, from?:?int, to?:?int, q?:?string} $filters
* @return Claim[]
*/
public function findByTenant(string $entityType, int $entityId, array $filters = []): array
{
$qb = $this->createQueryBuilder('c')
->where('c.entityType = :type')
@@ -28,8 +31,34 @@ class ClaimRepository extends ServiceEntityRepository
->setParameter('id', $entityId)
->orderBy('c.id', 'DESC');
if ($status !== null) {
$qb->andWhere('c.status = :status')->setParameter('status', $status);
if (!empty($filters['status'])) {
$qb->andWhere('c.status = :status')->setParameter('status', $filters['status']);
}
if (!empty($filters['insurance_id'])) {
$qb->andWhere('c.insuranceId = :insId')->setParameter('insId', (int) $filters['insurance_id']);
}
if (!empty($filters['from'])) {
$qb->andWhere('c.createdAt >= :from')->setParameter('from', (int) $filters['from']);
}
if (!empty($filters['to'])) {
$qb->andWhere('c.createdAt <= :to')->setParameter('to', (int) $filters['to']);
}
// فیلتر بیمار: مطالباتی که آیتم‌شان به صورتحساب پرونده‌ی همان کاربر اشاره دارد (موبایل/کدملی/نام).
if (!empty($filters['q'])) {
$q = trim((string) $filters['q']);
$qb->andWhere(
'EXISTS (' .
'SELECT 1 FROM App\Billing\Entity\ClaimItem ci2 ' .
'JOIN App\Billing\Entity\InvoiceItem ii2 WITH ii2.id = ci2.invoiceItemId ' .
'JOIN App\Billing\Entity\Invoice inv2 WITH inv2 = ii2.invoice ' .
'JOIN App\Patient\Entity\PatientRecord pr2 WITH pr2.id = inv2.patientRecordId ' .
'JOIN App\Auth\Entity\User u2 WITH u2 = pr2.user ' .
'WHERE ci2.claim = c AND (' .
'u2.mobileNumber LIKE :q OR u2.nationalCode LIKE :q OR u2.realName LIKE :q' .
')' .
')'
)->setParameter('q', '%' . $q . '%');
}
return $qb->getQuery()->getResult();
@@ -89,4 +89,31 @@ class PatientSessionRepository extends ServiceEntityRepository
}
return $out;
}
/**
* نام و موبایل بیمار هر مراجعه — کلیددار بر اساس id جلسه.
* @param int[] $ids
* @return array<int, array{name: ?string, mobile: ?string}>
*/
public function patientInfoForIds(array $ids): array
{
if ($ids === []) {
return [];
}
$rows = $this->createQueryBuilder('s')
->select('s.id AS id', 'u.realName AS name', 'u.mobileNumber AS mobile')
->join('s.record', 'r')
->join('r.user', 'u')
->where('s.id IN (:ids)')
->setParameter('ids', $ids)
->getQuery()
->getArrayResult();
$out = [];
foreach ($rows as $r) {
$out[(int) $r['id']] = ['name' => $r['name'], 'mobile' => $r['mobile']];
}
return $out;
}
}