# بازطراحی 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های دستی و یک `
` خام نوشته شده و از 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('');
// ...
ردیف
نام بیمار
کد ملی
تاریخ
مبلغ پرداختشده
عملیات
...
```
نوع ردیف (`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) {
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>({
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 = {
paid: { color: 'green', label: 'پرداخت شده' },
unsettled: { color: 'amber', label: 'تسویه نشده' },
};
```
و `'invoice'` را به union پراپ `type` اضافه کن و در بدنه هندل کن.
### ۴. بازنویسی `MyPaymentsPage.tsx` بر اساس الگوی `ClaimsPage`
ساختار نهایی دقیقاً به این ترتیب:
```tsx
<>
{/* ۴ کارت آمار */}
{/* ردیف فیلترها: وضعیت + از تاریخ + تا تاریخ + میانبرها + پاککردن */}
{/* DataTable */}
{/* Pagination — فقط وقتی total > MY_PAYMENTS_LIMIT */}
>
```
**۴-۱ — انتقال 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) => {
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.** هر کنترل داخل یک `