feat: add payments summary endpoint and UI redesign for MyPaymentsPage
- Implemented a new API endpoint `/api/v1/my/billing/payments/summary` to provide a financial summary of payments with filters for national code, status, and date range. - Updated the InvoiceRepository to aggregate totals for paid and unsettled invoices. - Created a new hook `usePaymentsSummary` to fetch summary data in the frontend. - Redesigned the MyPaymentsPage to align with the ClaimsPage structure, incorporating a design system, summary statistics, and improved filtering options. - Added tests for the new payments summary endpoint to ensure correct functionality and filtering behavior.
This commit is contained in:
@@ -0,0 +1,294 @@
|
||||
# بازطراحی UI/UX صفحه «لیست پرداختها» (`/admin/my-payments`)
|
||||
|
||||
## پروژه
|
||||
|
||||
`clinicpro` — پنل ادمین React (`assets/admin/`) + یک اندپوینت خلاصه در بکاند Symfony (`src/Billing/`).
|
||||
|
||||
## زمینه
|
||||
|
||||
صفحهی `/admin/my-payments` ([MyPaymentsPage.tsx](clinicpro/assets/admin/pages/MyPaymentsPage.tsx)) با inline-styleهای دستی و یک `<table>` خام نوشته شده و از design-system پروژه استفاده نمیکند. در همان پنل، صفحهی `/admin/claims` ([ClaimsPage.tsx](clinicpro/assets/admin/pages/ClaimsPage.tsx)) الگوی درست و پختهی یک صفحهی لیست است: `PageHeader` با breadcrumb، ردیف `StatCard`، کارت فیلترها با `field-label`، میانبرهای بازهی زمانی، `DataTable` (سورت + جستجو + skeleton + empty state) و `Pagination`. هدف: همسطحکردن `my-payments` با همان الگو.
|
||||
|
||||
## مشکل
|
||||
|
||||
وضعیت فعلی صفحه:
|
||||
|
||||
1. **بدون design-system** — جدول خام با `th`/`td` inline style بهجای `DataTable`. یعنی: بدون skeleton loading، بدون سورت، بدون empty state استاندارد.
|
||||
2. **بدون هیچ آمار خلاصهای** — کاربر هیچ دید کلی از مجموع مبلغ/تعداد/تسویهنشده ندارد (بر خلاف claims که ۴ `StatCard` دارد).
|
||||
3. **ستون `status` نمایش داده نمیشود** — با اینکه `PaymentRow.status` (`paid | unsettled`) از API میآید و فیلترش هم در UI هست، در جدول هیچ ستون وضعیتی وجود ندارد. کاربر فیلتر میکند ولی نتیجهاش را نمیبیند.
|
||||
4. **فیلترها بدون label و بدون کارت** — یک ردیف شناور بالای صفحه، بدون `field-label`، بدون دکمهی «پاککردن فیلترها»، بدون میانبر «یک ماه اخیر / یک سال اخیر».
|
||||
5. **فیلترها در state محلیاند، نه در query string** — رفرش صفحه یا اشتراک لینک، فیلترها و شمارهی صفحه را از بین میبرد. `ClaimsPage` این را با `useSearchParams` حل کرده.
|
||||
6. **جستجو فقط کد ملی است** — با `input` دستساز، در حالی که `DataTable` خودش `searchValue`/`onSearchChange` دارد.
|
||||
7. **`PersianDateInput` بهجای `PersianDatePicker`** — ناهماهنگ با claims و بدون `height={38}` همتراز با `SearchableSelect`.
|
||||
8. **action هدر بیربط است** — دکمهی «اضافه کردن بیمار» در صفحهی پرداختها منطق ندارد.
|
||||
|
||||
## فایلهای مرتبط
|
||||
|
||||
| فایل | نقش |
|
||||
|------|-----|
|
||||
| `clinicpro/assets/admin/pages/MyPaymentsPage.tsx` | صفحهای که بازنویسی میشود |
|
||||
| `clinicpro/assets/admin/pages/ClaimsPage.tsx` | **الگوی مرجع** — ساختار را از این کپی کن |
|
||||
| `clinicpro/assets/admin/hooks/useMyPayments.ts` | `usePayments`، `PaymentRow`، `MY_PAYMENTS_LIMIT` — hook خلاصه اینجا اضافه میشود |
|
||||
| `clinicpro/assets/admin/components/ui/DataTable.tsx` | جدول design-system |
|
||||
| `clinicpro/assets/admin/components/ui/StatCard.tsx` | کارت آمار (`tone: amber\|violet\|green\|pink`) |
|
||||
| `clinicpro/assets/admin/components/ui/StatusBadge.tsx` | بج وضعیت — نیاز به type جدید `invoice` |
|
||||
| `clinicpro/assets/admin/components/ui/PersianDatePicker.tsx` | انتخاب تاریخ همراستا با claims |
|
||||
| `clinicpro/assets/admin/types/index.ts` | تعریف `InvoiceListStatus` |
|
||||
| `clinicpro/src/Billing/Controller/BillingController.php` | اندپوینت `listPayments` (L163) — اندپوینت خلاصه کنارش |
|
||||
| `clinicpro/src/Billing/Service/InvoiceService.php` | `tenantInvoiceList` — متد خلاصه کنارش |
|
||||
| `clinicpro/docs/api/billing.md` | مستند API (Standing Rule) |
|
||||
| `clinicpro/assets/admin/pages/MyPaymentsPage.test.tsx` | تستهای موجود — باید بهروز شوند |
|
||||
|
||||
## وضعیت فعلی
|
||||
|
||||
`MyPaymentsPage.tsx` (خلاصهی بخشهای مشکلدار):
|
||||
|
||||
```tsx
|
||||
const th: React.CSSProperties = { textAlign: 'right', padding: '12px 16px', fontWeight: 600 };
|
||||
const td: React.CSSProperties = { padding: '12px 16px' };
|
||||
|
||||
const [page, setPage] = useState(1);
|
||||
const [nationalCode, setNationalCode] = useState('');
|
||||
const [status, setStatus] = useState('');
|
||||
const [from, setFrom] = useState('');
|
||||
const [to, setTo] = useState('');
|
||||
// ...
|
||||
<div className="card" style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13.5 }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '1px solid var(--border)', ... }}>
|
||||
<th style={th}>ردیف</th>
|
||||
<th style={th}>نام بیمار</th>
|
||||
<th style={th}>کد ملی</th>
|
||||
<th style={th}>تاریخ</th>
|
||||
<th style={th}>مبلغ پرداختشده</th>
|
||||
<th style={{ ...th, textAlign: 'left' }}>عملیات</th>
|
||||
</tr>
|
||||
</thead>
|
||||
...
|
||||
```
|
||||
|
||||
نوع ردیف (`useMyPayments.ts`) — دقت کن `status` موجود است ولی رندر نمیشود:
|
||||
|
||||
```ts
|
||||
export type PaymentRowStatus = 'paid' | 'unsettled';
|
||||
|
||||
export interface PaymentRow {
|
||||
invoice_uuid: string;
|
||||
patient_uuid: string;
|
||||
patient_name: string | null;
|
||||
national_code: string | null;
|
||||
issued_at: number;
|
||||
amount_rials: number;
|
||||
status: PaymentRowStatus;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## وظایف
|
||||
|
||||
### ۱. اندپوینت خلاصهی پرداختها (بکاند)
|
||||
|
||||
طبق قاعدهی «اول بگرد، بعد توسعه بده، در آخر بساز»: هیچ اندپوینتی خلاصهی مالی tenant را برنمیگرداند (`/api/v1/billing/reports/insurance-debt` فقط بدهی بیمه است، نه پرداختهای بیمار). پس یک اندپوینت جدید لازم است — اما **همان فیلترهای `listPayments` را میپذیرد** تا کارتها با جدول همخوان بمانند.
|
||||
|
||||
در `InvoiceService`:
|
||||
|
||||
```php
|
||||
/**
|
||||
* خلاصهی مالی صورتحسابهای tenant با همان فیلترهای tenantInvoiceList.
|
||||
* @return array{total_rials:int, paid_rials:int, unsettled_rials:int, invoices_count:int}
|
||||
*/
|
||||
public function tenantInvoiceSummary(string $entityType, int $entityId, array $filters): array
|
||||
```
|
||||
|
||||
پیادهسازی با یک DQL aggregate (`SUM`/`COUNT` + `CASE WHEN status = 'paid'`), **نه** با بارگذاری همهی ردیفها در PHP. شرطهای فیلتر (`national_code`, `status`, `from`, `to`) را دقیقاً از `tenantInvoiceList` بازاستفاده کن — منطق `where` را در یک متد private مشترک بگذار تا دو نسخه از هم واگرا نشوند (SOLID/DRY).
|
||||
|
||||
در `BillingController` کنار `listPayments`:
|
||||
|
||||
```php
|
||||
#[Route('/api/v1/my/billing/payments/summary', methods: ['GET'])]
|
||||
public function paymentsSummary(Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
if ($entityId === null) {
|
||||
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
|
||||
}
|
||||
// همان استخراج $filters که در listPayments هست
|
||||
return $this->success($this->invoiceService->tenantInvoiceSummary($entityType, $entityId, $filters));
|
||||
}
|
||||
```
|
||||
|
||||
**دقت:** payload را مستقیم پاس بده (`$this->success($summary)`) نه `['data' => $summary]` — در غیر اینصورت فرانت باید `data?.data?.data` بخواند (pitfall نامبرده در CLAUDE.md).
|
||||
|
||||
**نکتهی مسیریابی:** روت `/payments/summary` نباید با روتهای پارامتری موجود تداخل کند؛ بعد از افزودن، با `ddev exec php bin/console debug:router | grep billing` تأیید کن.
|
||||
|
||||
### ۲. hook خلاصه در فرانت
|
||||
|
||||
در `assets/admin/hooks/useMyPayments.ts`:
|
||||
|
||||
```ts
|
||||
export interface PaymentsSummary {
|
||||
total_rials: number;
|
||||
paid_rials: number;
|
||||
unsettled_rials: number;
|
||||
invoices_count: number;
|
||||
}
|
||||
|
||||
/** خلاصهی مالی با همان فیلترهای لیست — کارتهای آمار همیشه با جدول همخوان میمانند. */
|
||||
export function usePaymentsSummary(filters: Omit<PaymentFilters, 'page'>) {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.national_code) qs.set('national_code', filters.national_code);
|
||||
if (filters.status) qs.set('status', filters.status);
|
||||
if (filters.from) qs.set('from', String(filters.from));
|
||||
if (filters.to) qs.set('to', String(filters.to));
|
||||
|
||||
return useQuery<ApiResponse<PaymentsSummary>>({
|
||||
queryKey: ['payments-summary', filters],
|
||||
queryFn: () => api.get(`/api/v1/my/billing/payments/summary?${qs.toString()}`),
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
خواندن در صفحه: `summaryQuery.data?.data`.
|
||||
|
||||
### ۳. بج وضعیت صورتحساب
|
||||
|
||||
`StatusBadge` هیچ mapی برای `paid | unsettled` ندارد (`paymentMap` مربوط به درگاه است: `pending/success/failed/...`). یک type جدید اضافه کن — map موجود را دستکاری نکن:
|
||||
|
||||
در `types/index.ts`:
|
||||
|
||||
```ts
|
||||
export type InvoiceListStatus = 'paid' | 'unsettled';
|
||||
```
|
||||
|
||||
در `StatusBadge.tsx`:
|
||||
|
||||
```ts
|
||||
const invoiceMap: Record<InvoiceListStatus, { color: BadgeColor; label: string }> = {
|
||||
paid: { color: 'green', label: 'پرداخت شده' },
|
||||
unsettled: { color: 'amber', label: 'تسویه نشده' },
|
||||
};
|
||||
```
|
||||
|
||||
و `'invoice'` را به union پراپ `type` اضافه کن و در بدنه هندل کن.
|
||||
|
||||
### ۴. بازنویسی `MyPaymentsPage.tsx` بر اساس الگوی `ClaimsPage`
|
||||
|
||||
ساختار نهایی دقیقاً به این ترتیب:
|
||||
|
||||
```tsx
|
||||
<>
|
||||
<PageHeader
|
||||
title="لیست پرداختها"
|
||||
description="پرداختهای ثبتشدهی بیماران شما"
|
||||
breadcrumbs={[{ label: 'داشبورد', to: '/admin' }, { label: 'لیست پرداختها' }]}
|
||||
/>
|
||||
|
||||
{/* ۴ کارت آمار */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 'var(--gap)', marginBottom: 'var(--gap)' }}>
|
||||
<StatCard tone="violet" label="مجموع صورتحسابها" value={formatRial(s.total_rials)} />
|
||||
<StatCard tone="green" label="پرداختشده" value={formatRial(s.paid_rials)} />
|
||||
<StatCard tone="pink" label="تسویهنشده" value={formatRial(s.unsettled_rials)} />
|
||||
<StatCard tone="amber" label="تعداد صورتحساب" value={formatNumber(s.invoices_count)} />
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ padding: 18 }}>
|
||||
{/* ردیف فیلترها: وضعیت + از تاریخ + تا تاریخ + میانبرها + پاککردن */}
|
||||
{/* DataTable */}
|
||||
{/* Pagination — فقط وقتی total > MY_PAYMENTS_LIMIT */}
|
||||
</div>
|
||||
</>
|
||||
```
|
||||
|
||||
**۴-۱ — انتقال state به query string.** `useState`های `page/nationalCode/status/from/to` را با `useSearchParams` جایگزین کن، دقیقاً با همان `setParam` صفحهی claims (که با هر تغییر فیلتر، `page` را حذف میکند):
|
||||
|
||||
```tsx
|
||||
const [params, setParams] = useSearchParams();
|
||||
const search = params.get('search') ?? ''; // کد ملی / نام
|
||||
const status = params.get('status') ?? '';
|
||||
const from = params.get('from') ?? '';
|
||||
const to = params.get('to') ?? '';
|
||||
const page = Math.max(1, Number(params.get('page') ?? 1));
|
||||
|
||||
const setParam = (patch: Record<string, string>) => {
|
||||
const next = new URLSearchParams(params);
|
||||
Object.entries(patch).forEach(([k, v]) => (v ? next.set(k, v) : next.delete(k)));
|
||||
if (!('page' in patch)) next.delete('page');
|
||||
setParams(next, { replace: true });
|
||||
};
|
||||
```
|
||||
|
||||
**۴-۲ — جستجو داخل `DataTable`.** `input` دستساز و آیکون ذرهبین را حذف کن؛ بهجایش:
|
||||
|
||||
```tsx
|
||||
searchValue={search}
|
||||
onSearchChange={(v) => setParam({ search: v.replace(/\D/g, '') })}
|
||||
searchPlaceholder="کد ملی بیمار"
|
||||
```
|
||||
|
||||
مقدار بهعنوان `national_code` به `usePayments` میرود (API فقط `national_code` را میشناسد؛ جستجوی نام سمت سرور وجود ندارد — placeholder را همینطور صادقانه بگذار و ادعای جستجوی نام نکن).
|
||||
|
||||
**۴-۳ — فیلترها با label، مثل claims.** هر کنترل داخل یک `<div style={{ minWidth: ... }}>` با `<label className="field-label">`:
|
||||
|
||||
- «وضعیت» → `SearchableSelect` با `[{value:'',label:'همه وضعیتها'},{value:'paid',label:'پرداخت شده'},{value:'unsettled',label:'تسویه نشده'}]`، `height={38}`
|
||||
- «از تاریخ» / «تا تاریخ» → `PersianDatePicker` با `height={38}` (جایگزین `PersianDateInput`)
|
||||
- میانبرها: `<button className="btn ghost sm">` برای «یک ماه اخیر» و «یک سال اخیر» — همان `isoNDaysAgo(30)/isoNDaysAgo(365)` + `todayIso()` صفحهی claims
|
||||
- «پاککردن فیلترها» با `<ArrowPathIcon style={{ width: 13 }} />` — فقط وقتی `hasFilters` true است
|
||||
|
||||
**۴-۴ — ستونها با `Column<PaymentRow>`.** ستون «ردیف» را حذف کن (شمارهی مصنوعی در جدول صفحهبندیشده ارزشی ندارد و فضای مفید میگیرد) و ستون وضعیت را اضافه کن:
|
||||
|
||||
```tsx
|
||||
const columns: Column<PaymentRow>[] = [
|
||||
{ key: 'patient_name', header: 'بیمار', render: (r) => (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<Avatar name={r.patient_name} />
|
||||
<span style={{ fontWeight: 600 }}>{r.patient_name ?? '—'}</span>
|
||||
</div>
|
||||
) },
|
||||
{ key: 'national_code', header: 'کد ملی', render: (r) => (
|
||||
<span dir="ltr">{r.national_code ?? '—'}</span>
|
||||
) },
|
||||
{ key: 'issued_at', header: 'تاریخ', render: (r) => (
|
||||
<span dir="ltr">{formatDate(r.issued_at)} - {formatTime(r.issued_at)}</span>
|
||||
) },
|
||||
{ key: 'amount_rials', header: 'مبلغ', render: (r) => (
|
||||
<span style={{ fontWeight: 600 }}>{formatRial(r.amount_rials)}</span>
|
||||
) },
|
||||
{ key: 'status', header: 'وضعیت', render: (r) => <StatusBadge type="invoice" value={r.status} /> },
|
||||
];
|
||||
```
|
||||
|
||||
`sortable` را روی هیچ ستونی نگذار مگر اینکه اندپوینت `listPayments` واقعاً `sort`/`dir` بپذیرد — سورت غیرفعال بهتر از سورتِ بیاثر است. اگر تصمیم گرفتی سورت اضافه کنی، باید هم در `tenantInvoiceList` و هم در کنترلر پشتیبانی شود و در `docs/api/billing.md` مستند شود.
|
||||
|
||||
**۴-۵ — عملیات و حالت خالی.**
|
||||
|
||||
```tsx
|
||||
actions={(row) => (
|
||||
<button className="btn primary sm" onClick={() => navigate(`/admin/my-payments/${row.patient_uuid}`)}>
|
||||
جزئیات
|
||||
</button>
|
||||
)}
|
||||
emptyMessage="پرداختی ثبت نشده است."
|
||||
loading={listQuery.isLoading}
|
||||
```
|
||||
|
||||
**۴-۶ — حذف چیزهای زائد.** `th`/`td`ی inline، بلوک `isLoading` دستی، بلوک empty state دستی، `import` های `MagnifyingGlassIcon`/`EyeIcon`/`BanknotesIcon`/`UserPlusIcon`/`PersianDateInput`، ثابت `EMPTY` و دکمهی «اضافه کردن بیمار» از `PageHeader` حذف شوند. `Avatar`، `formatTime` و `dayBound` بمانند.
|
||||
|
||||
### ۵. مستندات و تست
|
||||
|
||||
- `clinicpro/docs/api/billing.md`: اندپوینت `GET /api/v1/my/billing/payments/summary` را با پارامترهای query و نمونهی پاسخ اضافه کن (Standing Rule).
|
||||
- `MyPaymentsPage.test.tsx` را به ساختار جدید بهروز کن: باید render کارتهای آمار، نمایش بج وضعیت، و بهروزرسانی query string با تغییر فیلتر را پوشش دهد. صفحه حالا `useSearchParams` دارد → تست باید داخل `MemoryRouter` رندر شود.
|
||||
- تست بکاند برای `tenantInvoiceSummary`: حالت موفق، حالت با فیلتر، و حالت خالی (باید صفر برگرداند نه `null`).
|
||||
- اجرا: `ddev exec npx tsc --noEmit --project tsconfig.json` · `ddev exec yarn test` · `ddev exec php bin/phpunit`
|
||||
|
||||
## نکات مهم
|
||||
|
||||
- **الگو را از `ClaimsPage` کپی کن، طراحی جدید نساز.** همان توکنها (`var(--gap)`, `var(--r-lg)`), همان کلاسها (`card`, `btn ghost sm`, `btn primary sm`, `field-label`), همان چیدمان.
|
||||
- تاریخها Unix ثانیهاند. `dayBound(from,false)` / `dayBound(to,true)` را برای مرز روز نگه دار — API مقدار خام روز را نمیفهمد.
|
||||
- **همیشه `SearchableSelect`، هرگز `<select>` بومی** (قاعدهی پروژه).
|
||||
- کارتهای آمار باید فیلترهای فعال را منعکس کنند: `usePaymentsSummary` همان `national_code/status/from/to` را میگیرد. اگر خلاصه بدون فیلتر بماند، عدد کارت با جمع جدول نمیخواند و کاربر گمراه میشود.
|
||||
- **Edge case:** وقتی `summaryQuery` هنوز loading است یا خطا داده، کارتها باید `formatRial(0)` نشان دهند نه `NaN`/`undefined` — با `?? 0` پیش از فرمت.
|
||||
- **Edge case:** فیلتر `status=paid` باعث میشود `unsettled_rials` صفر شود؛ این درست است، نه باگ.
|
||||
- **Edge case:** `patient_name` و `national_code` nullable هستند → `'—'`.
|
||||
- RTL و اعداد فارسی: `formatRial`/`formatNumber`/`formatDate` از `lib/utils` — عدد خام رندر نکن. کد ملی و تاریخ با `dir="ltr"`.
|
||||
- `Pagination` فقط وقتی `total > MY_PAYMENTS_LIMIT` رندر شود (مثل claims).
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import type { AppointmentStatus, PaymentStatus, SmsTemplateStatus, SettlementStatus, ClaimStatus } from '../../types';
|
||||
import type { AppointmentStatus, PaymentStatus, SmsTemplateStatus, SettlementStatus, ClaimStatus, InvoiceListStatus } from '../../types';
|
||||
|
||||
type BadgeColor = 'green' | 'amber' | 'red' | 'blue' | 'violet' | 'gray';
|
||||
|
||||
@@ -45,8 +45,13 @@ const claimMap: Record<ClaimStatus, { color: BadgeColor; label: string }> = {
|
||||
mixed: { color: 'violet', label: 'وضعیتهای مختلف' },
|
||||
};
|
||||
|
||||
const invoiceMap: Record<InvoiceListStatus, { color: BadgeColor; label: string }> = {
|
||||
paid: { color: 'green', label: 'پرداخت شده' },
|
||||
unsettled: { color: 'amber', label: 'تسویه نشده' },
|
||||
};
|
||||
|
||||
interface Props {
|
||||
type: 'appointment' | 'payment' | 'sms' | 'settlement' | 'active' | 'claim';
|
||||
type: 'appointment' | 'payment' | 'sms' | 'settlement' | 'active' | 'claim' | 'invoice';
|
||||
value: string;
|
||||
}
|
||||
|
||||
@@ -69,6 +74,9 @@ export default function StatusBadge({ type, value }: Props) {
|
||||
} else if (type === 'claim') {
|
||||
const m = claimMap[value as ClaimStatus];
|
||||
if (m) { color = m.color; label = m.label; }
|
||||
} else if (type === 'invoice') {
|
||||
const m = invoiceMap[value as InvoiceListStatus];
|
||||
if (m) { color = m.color; label = m.label; }
|
||||
} else if (type === 'active') {
|
||||
color = value === 'true' || value === 'active' ? 'green' : 'gray';
|
||||
label = value === 'true' || value === 'active' ? 'فعال' : 'غیرفعال';
|
||||
|
||||
@@ -59,6 +59,27 @@ export function usePayments(filters: PaymentFilters) {
|
||||
});
|
||||
}
|
||||
|
||||
export interface PaymentsSummary {
|
||||
total_rials: number;
|
||||
paid_rials: number;
|
||||
unsettled_rials: number;
|
||||
invoices_count: number;
|
||||
}
|
||||
|
||||
/** Summary over the same filters as the list, so the stat cards match the table. */
|
||||
export function usePaymentsSummary(filters: Omit<PaymentFilters, 'page'>) {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.national_code) qs.set('national_code', filters.national_code);
|
||||
if (filters.status) qs.set('status', filters.status);
|
||||
if (filters.from) qs.set('from', String(filters.from));
|
||||
if (filters.to) qs.set('to', String(filters.to));
|
||||
|
||||
return useQuery<ApiResponse<PaymentsSummary>>({
|
||||
queryKey: ['payments-summary', filters],
|
||||
queryFn: () => api.get(`/api/v1/my/billing/payments/summary?${qs.toString()}`),
|
||||
});
|
||||
}
|
||||
|
||||
/** Node 2 — a single patient's recorded invoices (header + paginated list). */
|
||||
export function usePatientInvoices(patientUuid: string | undefined, page: number) {
|
||||
return useQuery<ApiResponse<PatientInvoicesPayload>>({
|
||||
|
||||
@@ -21,10 +21,21 @@ const ROWS = [
|
||||
issued_at: 1718000000, amount_rials: 6000000, status: 'unsettled' },
|
||||
];
|
||||
|
||||
const SUMMARY = { total_rials: 8350000, paid_rials: 2350000, unsettled_rials: 6000000, invoices_count: 2 };
|
||||
|
||||
/** The page fires two queries; route by URL so each gets its own envelope. */
|
||||
const mockApi = (rows = ROWS, total = rows.length) => {
|
||||
get.mockImplementation((url: string) =>
|
||||
url.includes('/payments/summary')
|
||||
? Promise.resolve({ success: true, data: SUMMARY })
|
||||
: Promise.resolve({ success: true, data: rows, meta: { totalRecords: total, totalPages: 1, currentPage: 1 } }),
|
||||
);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
navigate.mockReset();
|
||||
get.mockReset();
|
||||
get.mockResolvedValue({ success: true, data: ROWS, meta: { totalRecords: 2, totalPages: 1, currentPage: 1 } });
|
||||
mockApi();
|
||||
});
|
||||
|
||||
describe('MyPaymentsPage (لیست پرداختها)', () => {
|
||||
@@ -36,23 +47,42 @@ describe('MyPaymentsPage (لیست پرداختها)', () => {
|
||||
expect(screen.getByText('2200112233')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('navigates to the patient detail on مشاهده', async () => {
|
||||
it('renders the two-state invoice status badge', async () => {
|
||||
renderWithProviders(<MyPaymentsPage />, { route: '/admin/my-payments' });
|
||||
expect(await screen.findByText('پرداخت شده')).toBeInTheDocument();
|
||||
expect(screen.getByText('تسویه نشده')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the summary stat cards', async () => {
|
||||
renderWithProviders(<MyPaymentsPage />, { route: '/admin/my-payments' });
|
||||
expect(await screen.findByText('مجموع صورتحسابها')).toBeInTheDocument();
|
||||
expect(screen.getByText('پرداختشده')).toBeInTheDocument();
|
||||
expect(screen.getByText('تسویهنشده')).toBeInTheDocument();
|
||||
expect(screen.getByText('تعداد صورتحساب')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('navigates to the patient detail on جزئیات', async () => {
|
||||
renderWithProviders(<MyPaymentsPage />, { route: '/admin/my-payments' });
|
||||
await screen.findByText('دنیا خلیلی');
|
||||
fireEvent.click(screen.getAllByRole('button', { name: /مشاهده/ })[0]);
|
||||
fireEvent.click(screen.getAllByRole('button', { name: /جزئیات/ })[0]);
|
||||
expect(navigate).toHaveBeenCalledWith('/admin/my-payments/p1');
|
||||
});
|
||||
|
||||
it('shows an empty state when there are no payments', async () => {
|
||||
get.mockResolvedValue({ success: true, data: [], meta: { totalRecords: 0, totalPages: 0, currentPage: 1 } });
|
||||
mockApi([], 0);
|
||||
renderWithProviders(<MyPaymentsPage />, { route: '/admin/my-payments' });
|
||||
expect(await screen.findByText('پرداختی یافت نشد')).toBeInTheDocument();
|
||||
expect(await screen.findByText('پرداختی ثبت نشده است.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('sends the national_code filter to the API', async () => {
|
||||
it('sends the national_code filter to both the list and the summary', async () => {
|
||||
renderWithProviders(<MyPaymentsPage />, { route: '/admin/my-payments' });
|
||||
await screen.findByText('دنیا خلیلی');
|
||||
fireEvent.change(screen.getByPlaceholderText('کد ملی بیمار را وارد کنید...'), { target: { value: '1744' } });
|
||||
await waitFor(() => expect(get.mock.calls.some(([u]) => String(u).includes('national_code=1744'))).toBe(true));
|
||||
fireEvent.change(screen.getByPlaceholderText('کد ملی بیمار'), { target: { value: '1744' } });
|
||||
|
||||
await waitFor(() => {
|
||||
const urls = get.mock.calls.map(([u]) => String(u)).filter((u) => u.includes('national_code=1744'));
|
||||
expect(urls.some((u) => u.includes('/payments?'))).toBe(true);
|
||||
expect(urls.some((u) => u.includes('/payments/summary?'))).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,18 +1,32 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { MagnifyingGlassIcon, EyeIcon, BanknotesIcon, UserPlusIcon } from '@heroicons/react/24/outline';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { ArrowPathIcon } from '@heroicons/react/24/outline';
|
||||
import PageHeader from '../components/ui/PageHeader';
|
||||
import Pagination from '../components/ui/Pagination';
|
||||
import PersianDateInput from '../components/ui/PersianDateInput';
|
||||
import PersianDatePicker from '../components/ui/PersianDatePicker';
|
||||
import SearchableSelect from '../components/ui/SearchableSelect';
|
||||
import StatCard from '../components/ui/StatCard';
|
||||
import StatusBadge from '../components/ui/StatusBadge';
|
||||
import DataTable, { type Column } from '../components/ui/DataTable';
|
||||
import { formatRial, formatDate, formatNumber, toDate } from '../lib/utils';
|
||||
import {
|
||||
usePayments,
|
||||
usePaymentsSummary,
|
||||
MY_PAYMENTS_LIMIT,
|
||||
type PaymentRow,
|
||||
} from '../hooks/useMyPayments';
|
||||
|
||||
const EMPTY: PaymentRow[] = [];
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: '', label: 'همه وضعیتها' },
|
||||
{ value: 'paid', label: 'پرداخت شده' },
|
||||
{ value: 'unsettled', label: 'تسویه نشده' },
|
||||
];
|
||||
|
||||
const isoNDaysAgo = (days: number): string => {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() - days);
|
||||
return d.toISOString().slice(0, 10);
|
||||
};
|
||||
const todayIso = (): string => new Date().toISOString().slice(0, 10);
|
||||
|
||||
/** HH:MM (Persian digits) from a unix timestamp. */
|
||||
function formatTime(unix: number): string {
|
||||
@@ -39,133 +53,134 @@ function Avatar({ name }: { name: string | null }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** لیست پرداختها — flat list of the tenant's recorded invoices (ported from tauri /payments). */
|
||||
/**
|
||||
* لیست پرداختها — فهرست تخت صورتحسابهای ثبتشدهی tenant.
|
||||
* فیلترها در query string زندگی میکنند تا رفرش و اشتراک لینک، نما را حفظ کند.
|
||||
*/
|
||||
export default function MyPaymentsPage() {
|
||||
const navigate = useNavigate();
|
||||
const [page, setPage] = useState(1);
|
||||
const [nationalCode, setNationalCode] = useState('');
|
||||
const [status, setStatus] = useState('');
|
||||
const [from, setFrom] = useState('');
|
||||
const [to, setTo] = useState('');
|
||||
const [params, setParams] = useSearchParams();
|
||||
|
||||
const reset = () => setPage(1);
|
||||
const search = params.get('search') ?? '';
|
||||
const status = params.get('status') ?? '';
|
||||
const from = params.get('from') ?? '';
|
||||
const to = params.get('to') ?? '';
|
||||
const page = Math.max(1, Number(params.get('page') ?? 1));
|
||||
|
||||
const { data, isLoading } = usePayments({
|
||||
page,
|
||||
national_code: nationalCode.trim() || undefined,
|
||||
const hasFilters = !!(search || status || from || to);
|
||||
|
||||
/** تغییر فیلتر همیشه به صفحهی اول برمیگردد؛ ماندن روی صفحه ۵ با نتیجهی جدید بیمعناست. */
|
||||
const setParam = (patch: Record<string, string>) => {
|
||||
const next = new URLSearchParams(params);
|
||||
Object.entries(patch).forEach(([k, v]) => (v ? next.set(k, v) : next.delete(k)));
|
||||
if (!('page' in patch)) next.delete('page');
|
||||
setParams(next, { replace: true });
|
||||
};
|
||||
|
||||
const filters = {
|
||||
national_code: search || undefined,
|
||||
status: status || undefined,
|
||||
from: from ? dayBound(from, false) : undefined,
|
||||
to: to ? dayBound(to, true) : undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const rows = data?.data ?? EMPTY;
|
||||
const total = data?.meta?.totalRecords ?? 0;
|
||||
const listQuery = usePayments({ page, ...filters });
|
||||
const summaryQuery = usePaymentsSummary(filters);
|
||||
|
||||
const th: React.CSSProperties = { textAlign: 'right', padding: '12px 16px', fontWeight: 600 };
|
||||
const td: React.CSSProperties = { padding: '12px 16px' };
|
||||
const rows = listQuery.data?.data ?? [];
|
||||
const total = listQuery.data?.meta?.totalRecords ?? 0;
|
||||
const summary = summaryQuery.data?.data;
|
||||
|
||||
const columns: Column<PaymentRow>[] = [
|
||||
{
|
||||
key: 'patient_name',
|
||||
header: 'بیمار',
|
||||
render: (r) => (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<Avatar name={r.patient_name} />
|
||||
<span style={{ fontWeight: 600 }}>{r.patient_name ?? '—'}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: 'national_code', header: 'کد ملی', render: (r) => <span dir="ltr">{r.national_code ?? '—'}</span> },
|
||||
{
|
||||
key: 'issued_at',
|
||||
header: 'تاریخ',
|
||||
render: (r) => <span dir="ltr">{formatDate(r.issued_at)} - {formatTime(r.issued_at)}</span>,
|
||||
},
|
||||
{ key: 'amount_rials', header: 'مبلغ', render: (r) => <span style={{ fontWeight: 600 }}>{formatRial(r.amount_rials)}</span> },
|
||||
{ key: 'status', header: 'وضعیت', render: (r) => <StatusBadge type="invoice" value={r.status} /> },
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="لیست پرداختها"
|
||||
description="پرداختهای ثبتشدهی بیماران شما"
|
||||
action={
|
||||
<button className="btn primary sm" onClick={() => navigate('/admin/patients/new')}>
|
||||
<UserPlusIcon style={{ width: 16 }} /> اضافه کردن بیمار
|
||||
</button>
|
||||
}
|
||||
breadcrumbs={[{ label: 'داشبورد', to: '/admin' }, { label: 'لیست پرداختها' }]}
|
||||
/>
|
||||
|
||||
{/* فیلترها — کد ملی + وضعیت + بازهی تاریخ */}
|
||||
<div style={{ display: 'flex', gap: 10, marginBottom: 16, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<div style={{
|
||||
flex: '1 1 240px', minWidth: 200, display: 'flex', alignItems: 'center', gap: 8,
|
||||
background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 'var(--r)', padding: '9px 12px',
|
||||
}}>
|
||||
<MagnifyingGlassIcon style={{ width: 18, color: 'var(--text-3)', flexShrink: 0 }} />
|
||||
<input
|
||||
style={{ border: 'none', outline: 'none', background: 'transparent', flex: 1, fontSize: 14 }}
|
||||
value={nationalCode}
|
||||
onChange={(e) => { setNationalCode(e.target.value.replace(/\D/g, '')); reset(); }}
|
||||
placeholder="کد ملی بیمار را وارد کنید..."
|
||||
dir="ltr"
|
||||
inputMode="numeric"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: '0 0 auto', width: 160 }}>
|
||||
<SearchableSelect
|
||||
options={[{ value: 'paid', label: 'پرداخت شده' }, { value: 'unsettled', label: 'تسویه نشده' }]}
|
||||
value={status || null}
|
||||
onChange={(v) => { setStatus(v ? String(v) : ''); reset(); }}
|
||||
placeholder="همه وضعیتها"
|
||||
isClearable
|
||||
height={38}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ width: 150 }}>
|
||||
<PersianDateInput value={from} onChange={(v) => { setFrom(v); reset(); }} placeholder="از تاریخ" />
|
||||
</div>
|
||||
<div style={{ width: 150 }}>
|
||||
<PersianDateInput value={to} onChange={(v) => { setTo(v); reset(); }} placeholder="تا تاریخ" />
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 'var(--gap)', marginBottom: 'var(--gap)' }}>
|
||||
<StatCard tone="violet" label="مجموع صورتحسابها" value={formatRial(summary?.total_rials ?? 0)} />
|
||||
<StatCard tone="green" label="پرداختشده" value={formatRial(summary?.paid_rials ?? 0)} />
|
||||
<StatCard tone="pink" label="تسویهنشده" value={formatRial(summary?.unsettled_rials ?? 0)} />
|
||||
<StatCard tone="amber" label="تعداد صورتحساب" value={formatNumber(summary?.invoices_count ?? 0)} />
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="card" style={{ padding: 32, textAlign: 'center', color: 'var(--text-3)' }}>در حال بارگذاری…</div>
|
||||
) : rows.length === 0 ? (
|
||||
<div className="card" style={{ padding: '60px 24px', textAlign: 'center', color: 'var(--text-3)' }}>
|
||||
<BanknotesIcon style={{ width: 48, margin: '0 auto 16px', display: 'block', opacity: 0.4 }} />
|
||||
<div style={{ fontWeight: 600, fontSize: 15, marginBottom: 6, color: 'var(--text-2)' }}>پرداختی یافت نشد</div>
|
||||
<div style={{ fontSize: 13 }}>با ثبت صورتحساب برای بیماران، این فهرست پر میشود.</div>
|
||||
<div className="card" style={{ padding: 18 }}>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 10, alignItems: 'flex-end', marginBottom: 16 }}>
|
||||
<div style={{ minWidth: 150 }}>
|
||||
<label className="field-label">وضعیت</label>
|
||||
<SearchableSelect
|
||||
options={STATUS_OPTIONS}
|
||||
value={status}
|
||||
onChange={(v) => setParam({ status: v ? String(v) : '' })}
|
||||
placeholder="همه وضعیتها"
|
||||
height={38}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ minWidth: 140 }}>
|
||||
<label className="field-label">از تاریخ</label>
|
||||
<PersianDatePicker value={from} onChange={(v) => setParam({ from: v })} height={38} />
|
||||
</div>
|
||||
<div style={{ minWidth: 140 }}>
|
||||
<label className="field-label">تا تاریخ</label>
|
||||
<PersianDatePicker value={to} onChange={(v) => setParam({ to: v })} height={38} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<button className="btn ghost sm" onClick={() => setParam({ from: isoNDaysAgo(30), to: todayIso() })}>یک ماه اخیر</button>
|
||||
<button className="btn ghost sm" onClick={() => setParam({ from: isoNDaysAgo(365), to: todayIso() })}>یک سال اخیر</button>
|
||||
{hasFilters && (
|
||||
<button
|
||||
className="btn ghost sm"
|
||||
onClick={() => setParams(new URLSearchParams(), { replace: true })}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 4 }}
|
||||
>
|
||||
<ArrowPathIcon style={{ width: 13 }} /> پاککردن فیلترها
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="card" style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13.5 }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '1px solid var(--border)', color: 'var(--text-3)', fontSize: 12 }}>
|
||||
<th style={th}>ردیف</th>
|
||||
<th style={th}>نام بیمار</th>
|
||||
<th style={th}>کد ملی</th>
|
||||
<th style={th}>تاریخ</th>
|
||||
<th style={th}>مبلغ پرداختشده</th>
|
||||
<th style={{ ...th, textAlign: 'left' }}>عملیات</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, i) => (
|
||||
<tr key={row.invoice_uuid} style={{ borderBottom: '1px solid var(--border)' }}>
|
||||
<td style={td}>{formatNumber((page - 1) * MY_PAYMENTS_LIMIT + i + 1)}</td>
|
||||
<td style={td}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<Avatar name={row.patient_name} />
|
||||
<span style={{ fontWeight: 600 }}>{row.patient_name ?? '—'}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ ...td, color: 'var(--text-2)' }} dir="ltr">{row.national_code ?? '—'}</td>
|
||||
<td style={td} dir="ltr">{formatDate(row.issued_at)} - {formatTime(row.issued_at)}</td>
|
||||
<td style={{ ...td, fontWeight: 600 }}>{formatRial(row.amount_rials)}</td>
|
||||
<td style={{ ...td, textAlign: 'left' }}>
|
||||
<button
|
||||
className="cp-btn-secondary"
|
||||
style={{ height: 32, padding: '0 12px', display: 'inline-flex', alignItems: 'center', gap: 5 }}
|
||||
onClick={() => navigate(`/admin/my-payments/${row.patient_uuid}`)}
|
||||
>
|
||||
<EyeIcon style={{ width: 15 }} /> مشاهده
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<Pagination page={page} total={total} limit={MY_PAYMENTS_LIMIT} onPageChange={setPage} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
loading={listQuery.isLoading}
|
||||
searchValue={search}
|
||||
onSearchChange={(v) => setParam({ search: v.replace(/\D/g, '') })}
|
||||
searchPlaceholder="کد ملی بیمار"
|
||||
emptyMessage="پرداختی ثبت نشده است."
|
||||
actions={(row) => (
|
||||
<button className="btn primary sm" onClick={() => navigate(`/admin/my-payments/${row.patient_uuid}`)}>
|
||||
جزئیات
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
|
||||
{total > MY_PAYMENTS_LIMIT && (
|
||||
<Pagination page={page} total={total} limit={MY_PAYMENTS_LIMIT} onPageChange={(p) => setParam({ page: String(p) })} />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -204,6 +204,9 @@ export type SettlementStatus = "pending" | "approved" | "rejected";
|
||||
/** `mixed` فقط در نمای تجمیعی بیمار معنا دارد: مطالبات آن بیمار وضعیت یکسان ندارند. */
|
||||
export type ClaimStatus = "pending" | "submitted" | "approved" | "rejected" | "paid" | "mixed";
|
||||
|
||||
/** وضعیت دوحالتهی صورتحساب در فهرست پرداختها — با `PaymentStatus` درگاه فرق دارد. */
|
||||
export type InvoiceListStatus = "paid" | "unsettled";
|
||||
|
||||
export interface ClaimPatientRow {
|
||||
patient_uuid: string;
|
||||
record_uuid: string;
|
||||
|
||||
@@ -345,6 +345,27 @@
|
||||
|
||||
**Errors:** `403` (`ERR_FORBIDDEN_001`) پروفایل tenant یافت نشد.
|
||||
|
||||
## GET /api/v1/my/billing/payments/summary
|
||||
خلاصهی مالی **همان مجموعهی فیلترشدهی** `GET /api/v1/my/billing/payments` — برای کارتهای آمار بالای صفحهی «لیست پرداختها». همان دامنهی رکوردها (فقط `finalized`/`paid` همان tenant).
|
||||
|
||||
**Query params:** دقیقاً `national_code`، `status`، `from`، `to` مثل اندپوینت لیست (بدون `page`/`limit`).
|
||||
|
||||
**Response 200:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"total_rials": 8350000,
|
||||
"paid_rials": 2350000,
|
||||
"unsettled_rials": 6000000,
|
||||
"invoices_count": 2
|
||||
}
|
||||
}
|
||||
```
|
||||
> مبالغ جمع `patient_rials` هستند. `unsettled_rials = total_rials - paid_rials`. با فیلتر `status=paid` مقدار `unsettled_rials` صفر میشود (رفتار درست، نه باگ). وقتی هیچ رکوردی مطابقت ندارد، همهی مقادیر `0` برمیگردند.
|
||||
|
||||
**Errors:** `403` (`ERR_FORBIDDEN_001`) پروفایل tenant یافت نشد.
|
||||
|
||||
## GET /api/v1/my/billing/patients/{patientUuid}/invoices
|
||||
«پرداختهای ثبتشده» — سربرگ بیمار + فهرست صفحهبندیشدهی صورتحسابهای `finalized`/`paid` او (جدیدترین اول). فقط مالک رکورد (همان tenant) دسترسی دارد.
|
||||
|
||||
|
||||
@@ -168,18 +168,50 @@ class BillingController extends BaseController
|
||||
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
|
||||
}
|
||||
|
||||
$filters = [
|
||||
$page = max(1, (int) $request->query->get('page', 1));
|
||||
$limit = min(100, max(1, (int) $request->query->get('limit', 20)));
|
||||
|
||||
$result = $this->invoiceService->tenantInvoiceList(
|
||||
$entityType,
|
||||
$entityId,
|
||||
$this->paymentFilters($request),
|
||||
$page,
|
||||
$limit,
|
||||
);
|
||||
|
||||
return $this->paginated($result['items'], $result['total'], $page, $limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* خلاصهی مالی همان مجموعهی فیلترشدهی listPayments — برای کارتهای آمار.
|
||||
* پاسخ: { total_rials, paid_rials, unsettled_rials, invoices_count }.
|
||||
*/
|
||||
#[Route('/api/v1/my/billing/payments/summary', methods: ['GET'])]
|
||||
public function paymentsSummary(Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
if ($entityId === null) {
|
||||
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
|
||||
}
|
||||
|
||||
return $this->success(
|
||||
$this->invoiceService->tenantInvoiceSummary($entityType, $entityId, $this->paymentFilters($request)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* فیلترهای مشترک لیست پرداختها و خلاصهی آن؛ یک منبع تا دو نما واگرا نشوند.
|
||||
*
|
||||
* @return array{national_code:?string,status:?string,from:?string,to:?string}
|
||||
*/
|
||||
private function paymentFilters(Request $request): array
|
||||
{
|
||||
return [
|
||||
'national_code' => $request->query->get('national_code') ?: null,
|
||||
'status' => $request->query->get('status') ?: null,
|
||||
'from' => $request->query->get('from') ?: null,
|
||||
'to' => $request->query->get('to') ?: null,
|
||||
];
|
||||
$page = max(1, (int) $request->query->get('page', 1));
|
||||
$limit = min(100, max(1, (int) $request->query->get('limit', 20)));
|
||||
|
||||
$result = $this->invoiceService->tenantInvoiceList($entityType, $entityId, $filters, $page, $limit);
|
||||
|
||||
return $this->paginated($result['items'], $result['total'], $page, $limit);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -67,6 +67,36 @@ class InvoiceRepository extends ServiceEntityRepository
|
||||
->getSingleScalarResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate totals over the same filtered set as {@see tenantInvoices}, so the
|
||||
* summary cards always agree with the table below them.
|
||||
*
|
||||
* @param array{national_code?:?string,status?:?string,from?:?int,to?:?int} $filters
|
||||
* @return array{total_rials:int,paid_rials:int,unsettled_rials:int,invoices_count:int}
|
||||
*/
|
||||
public function tenantInvoiceSummary(string $entityType, int $entityId, array $filters): array
|
||||
{
|
||||
$row = $this->tenantInvoicesQuery($entityType, $entityId, $filters)
|
||||
->select(
|
||||
'COALESCE(SUM(i.patientRials), 0) AS total_rials',
|
||||
'COALESCE(SUM(CASE WHEN i.status = :paidStatus THEN i.patientRials ELSE 0 END), 0) AS paid_rials',
|
||||
'COUNT(i.id) AS invoices_count',
|
||||
)
|
||||
->setParameter('paidStatus', Invoice::STATUS_PAID)
|
||||
->getQuery()
|
||||
->getSingleResult();
|
||||
|
||||
$total = (int) $row['total_rials'];
|
||||
$paid = (int) $row['paid_rials'];
|
||||
|
||||
return [
|
||||
'total_rials' => $total,
|
||||
'paid_rials' => $paid,
|
||||
'unsettled_rials' => $total - $paid,
|
||||
'invoices_count' => (int) $row['invoices_count'],
|
||||
];
|
||||
}
|
||||
|
||||
private function tenantInvoicesQuery(string $entityType, int $entityId, array $filters): QueryBuilder
|
||||
{
|
||||
$qb = $this->createQueryBuilder('i')
|
||||
|
||||
@@ -109,6 +109,18 @@ class InvoiceService
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Financial summary of the same filtered invoice set as {@see tenantInvoiceList},
|
||||
* used by the payments page stat cards.
|
||||
*
|
||||
* @param array{national_code?:?string,status?:?string,from?:?int,to?:?int} $filters
|
||||
* @return array{total_rials:int,paid_rials:int,unsettled_rials:int,invoices_count:int}
|
||||
*/
|
||||
public function tenantInvoiceSummary(string $entityType, int $entityId, array $filters): array
|
||||
{
|
||||
return $this->invoiceRepo->tenantInvoiceSummary($entityType, $entityId, $filters);
|
||||
}
|
||||
|
||||
/**
|
||||
* A patient's recorded invoices, shaped for the detail table: number, issue
|
||||
* time, a single service title (first item, "+ more" when several), total,
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Billing;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Billing\Entity\Invoice;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Patient\Entity\PatientRecord;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* GET /api/v1/my/billing/payments/summary — aggregate totals over the same
|
||||
* filtered set as the payments list, feeding the admin page's stat cards.
|
||||
*/
|
||||
class PaymentsSummaryTest extends ApiTestCase
|
||||
{
|
||||
/** @return array{0: User, 1: Doctor} owner user + their doctor profile */
|
||||
private function doctor(): array
|
||||
{
|
||||
$owner = $this->createUser(['ROLE_DOCTOR']);
|
||||
$doctor = new Doctor($owner, 'دکتر تست');
|
||||
$this->em->persist($doctor);
|
||||
$this->em->flush();
|
||||
|
||||
return [$owner, $doctor];
|
||||
}
|
||||
|
||||
private function patientRecord(Doctor $doctor, ?string $nationalCode = null): PatientRecord
|
||||
{
|
||||
$patient = $this->createUser(['ROLE_USER']);
|
||||
if ($nationalCode !== null) {
|
||||
$patient->setNationalCode($nationalCode);
|
||||
}
|
||||
$record = new PatientRecord('doctor', $doctor->getId(), $patient, 'doctor', $doctor->getId());
|
||||
$this->em->persist($record);
|
||||
$this->em->flush();
|
||||
|
||||
return $record;
|
||||
}
|
||||
|
||||
private function setField(object $obj, string $prop, mixed $value): void
|
||||
{
|
||||
$ref = new \ReflectionProperty($obj, $prop);
|
||||
$ref->setAccessible(true);
|
||||
$ref->setValue($obj, $value);
|
||||
}
|
||||
|
||||
private function invoice(Doctor $doctor, PatientRecord $record, int $patientRials, string $status, int $issuedAt): Invoice
|
||||
{
|
||||
$invoice = new Invoice('doctor', $doctor->getId());
|
||||
$invoice->setPatientRecordId($record->getId());
|
||||
$this->setField($invoice, 'patientRials', $patientRials);
|
||||
$this->setField($invoice, 'status', $status);
|
||||
$this->setField($invoice, 'issuedAt', $issuedAt);
|
||||
$this->em->persist($invoice);
|
||||
$this->em->flush();
|
||||
|
||||
return $invoice;
|
||||
}
|
||||
|
||||
public function testSummarySplitsPaidAndUnsettled(): void
|
||||
{
|
||||
[$owner, $doctor] = $this->doctor();
|
||||
$record = $this->patientRecord($doctor);
|
||||
|
||||
$this->invoice($doctor, $record, 1_000_000, Invoice::STATUS_PAID, 1_700_000_000);
|
||||
$this->invoice($doctor, $record, 400_000, Invoice::STATUS_FINALIZED, 1_700_000_100);
|
||||
// draft invoices are not part of the payments list, so they must not count
|
||||
$this->invoice($doctor, $record, 999_000, Invoice::STATUS_DRAFT, 1_700_000_200);
|
||||
|
||||
$res = $this->authJson('GET', '/api/v1/my/billing/payments/summary', $owner);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
|
||||
$data = $res['data'];
|
||||
self::assertSame(1_400_000, $data['total_rials']);
|
||||
self::assertSame(1_000_000, $data['paid_rials']);
|
||||
self::assertSame(400_000, $data['unsettled_rials']);
|
||||
self::assertSame(2, $data['invoices_count']);
|
||||
}
|
||||
|
||||
public function testSummaryHonoursStatusAndDateFilters(): void
|
||||
{
|
||||
[$owner, $doctor] = $this->doctor();
|
||||
$record = $this->patientRecord($doctor);
|
||||
|
||||
$this->invoice($doctor, $record, 1_000_000, Invoice::STATUS_PAID, 1_700_000_000);
|
||||
$this->invoice($doctor, $record, 400_000, Invoice::STATUS_FINALIZED, 1_700_000_000);
|
||||
$this->invoice($doctor, $record, 700_000, Invoice::STATUS_PAID, 1_800_000_000);
|
||||
|
||||
$res = $this->authJson('GET', '/api/v1/my/billing/payments/summary?status=paid', $owner);
|
||||
self::assertSame(1_700_000, $res['data']['total_rials']);
|
||||
self::assertSame(1_700_000, $res['data']['paid_rials']);
|
||||
self::assertSame(0, $res['data']['unsettled_rials']);
|
||||
self::assertSame(2, $res['data']['invoices_count']);
|
||||
|
||||
$res = $this->authJson('GET', '/api/v1/my/billing/payments/summary?from=1700000000&to=1700000001', $owner);
|
||||
self::assertSame(1_400_000, $res['data']['total_rials']);
|
||||
self::assertSame(2, $res['data']['invoices_count']);
|
||||
}
|
||||
|
||||
public function testSummaryFiltersByNationalCode(): void
|
||||
{
|
||||
[$owner, $doctor] = $this->doctor();
|
||||
// db_test is never reset — a fixed code would eventually collide on the unique column
|
||||
$code = (string) random_int(1_000_000_000, 9_999_999_999);
|
||||
$mine = $this->patientRecord($doctor, $code);
|
||||
$other = $this->patientRecord($doctor, (string) random_int(1_000_000_000, 9_999_999_999));
|
||||
|
||||
$this->invoice($doctor, $mine, 500_000, Invoice::STATUS_PAID, 1_700_000_000);
|
||||
$this->invoice($doctor, $other, 800_000, Invoice::STATUS_PAID, 1_700_000_000);
|
||||
|
||||
$res = $this->authJson('GET', '/api/v1/my/billing/payments/summary?national_code=' . $code, $owner);
|
||||
self::assertSame(500_000, $res['data']['total_rials']);
|
||||
self::assertSame(1, $res['data']['invoices_count']);
|
||||
}
|
||||
|
||||
public function testSummaryIsZeroWhenNothingMatches(): void
|
||||
{
|
||||
[$owner] = $this->doctor();
|
||||
|
||||
$res = $this->authJson('GET', '/api/v1/my/billing/payments/summary', $owner);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertSame(
|
||||
['total_rials' => 0, 'paid_rials' => 0, 'unsettled_rials' => 0, 'invoices_count' => 0],
|
||||
$res['data'],
|
||||
);
|
||||
}
|
||||
|
||||
public function testSummaryExcludesOtherTenants(): void
|
||||
{
|
||||
[$owner, $doctor] = $this->doctor();
|
||||
$record = $this->patientRecord($doctor);
|
||||
$this->invoice($doctor, $record, 300_000, Invoice::STATUS_PAID, 1_700_000_000);
|
||||
|
||||
[$otherOwner] = $this->doctor();
|
||||
$res = $this->authJson('GET', '/api/v1/my/billing/payments/summary', $otherOwner);
|
||||
self::assertSame(0, $res['data']['total_rials']);
|
||||
self::assertSame(0, $res['data']['invoices_count']);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user