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)}`}
|
||||
|
||||
+14
-2
@@ -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`، این فیلدهای نمایشی را دارد (برای جدول صفحهی مطالبات):
|
||||
|
||||
|
||||
@@ -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)]);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user