Files
clinicpro/.claude/prompt/my-payments-ui-redesign.md
T
hamed 5c8fe8ece4 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.
2026-07-19 10:12:01 +03:30

295 lines
18 KiB
Markdown

# بازطراحی 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).