diff --git a/assets/admin/pages/ClaimsPage.tsx b/assets/admin/pages/ClaimsPage.tsx index f088a62f..1e9f9edc 100644 --- a/assets/admin/pages/ClaimsPage.tsx +++ b/assets/admin/pages/ClaimsPage.tsx @@ -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(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() {
-
+

مطالبات

-
+ {(statusFilter || insuranceFilter || fromDate || toDate || search) && ( + + )} +
+ + {/* نوار فیلتر */} +
+
+ + ({ value: String(o.insurance_id), label: o.insurance_name ?? `بیمه #${o.insurance_id}` }))]} + value={insuranceFilter} + onChange={(v) => setInsuranceFilter(v ? String(v) : '')} + placeholder="همه بیمه‌ها" + height={38} + /> +
+
+ ({ value: s, label: s === '' ? 'همه وضعیت‌ها' : STATUS_META[s]?.label ?? s }))} value={statusFilter} onChange={(v) => setStatusFilter(v ? String(v) : '')} placeholder="همه وضعیت‌ها" + height={38} />
+
+ + +
+
+ + +
+
+ +
+ + setSearchInput(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') setSearch(searchInput); }} + placeholder="نام، موبایل یا کدملی بیمار" + /> +
+
+
{claimsQuery.isLoading ? ( @@ -142,6 +228,12 @@ export default function ClaimsPage() { {KIND_LABEL[c.insurance_kind] ?? c.insurance_kind} {meta.label}
+ {c.patient_name && ( +
+ بیمار: {c.patient_name} + {c.patient_mobile && {' '}{c.patient_mobile}} +
+ )}
ادعا {formatRial(c.total_claimed_rials)} {c.total_approved_rials != null && ` · تأیید ${formatRial(c.total_approved_rials)}`} diff --git a/docs/api/billing.md b/docs/api/billing.md index 4fd24dbd..cb1cd5fb 100644 --- a/docs/api/billing.md +++ b/docs/api/billing.md @@ -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`، این فیلدهای نمایشی را دارد (برای جدول صفحه‌ی مطالبات): diff --git a/src/Billing/Controller/BillingController.php b/src/Billing/Controller/BillingController.php index d93cd47b..1a970317 100644 --- a/src/Billing/Controller/BillingController.php +++ b/src/Billing/Controller/BillingController.php @@ -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)]); } diff --git a/src/Billing/Repository/ClaimRepository.php b/src/Billing/Repository/ClaimRepository.php index f652ab3d..4493deca 100644 --- a/src/Billing/Repository/ClaimRepository.php +++ b/src/Billing/Repository/ClaimRepository.php @@ -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(); diff --git a/src/Patient/Repository/PatientSessionRepository.php b/src/Patient/Repository/PatientSessionRepository.php index 1be247f9..f7bcb18c 100644 --- a/src/Patient/Repository/PatientSessionRepository.php +++ b/src/Patient/Repository/PatientSessionRepository.php @@ -89,4 +89,31 @@ class PatientSessionRepository extends ServiceEntityRepository } return $out; } + + /** + * نام و موبایل بیمار هر مراجعه — کلیددار بر اساس id جلسه. + * @param int[] $ids + * @return array + */ + 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; + } }