# بازطراحی 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.** هر کنترل داخل یک `
` با `
ردیف نام بیمار کد ملی تاریخ مبلغ پرداخت‌شده عملیات