Files
clinicpro/.claude/prompt/my-payments-ui-redesign.md
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

18 KiB
Raw Permalink Blame History

بازطراحی UI/UX صفحه «لیست پرداخت‌ها» (/admin/my-payments)

پروژه

clinicpro — پنل ادمین React (assets/admin/) + یک اندپوینت خلاصه در بک‌اند Symfony (src/Billing/).

زمینه

صفحه‌ی /admin/my-payments (MyPaymentsPage.tsx) با inline-styleهای دستی و یک <table> خام نوشته شده و از design-system پروژه استفاده نمی‌کند. در همان پنل، صفحه‌ی /admin/claims (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 (خلاصه‌ی بخش‌های مشکل‌دار):

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 موجود است ولی رندر نمی‌شود:

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:

/**
 * خلاصه‌ی مالی صورتحساب‌های 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:

#[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:

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:

export type InvoiceListStatus = 'paid' | 'unsettled';

در StatusBadge.tsx:

const invoiceMap: Record<InvoiceListStatus, { color: BadgeColor; label: string }> = {
  paid:      { color: 'green', label: 'پرداخت شده' },
  unsettled: { color: 'amber', label: 'تسویه نشده' },
};

و 'invoice' را به union پراپ type اضافه کن و در بدنه هندل کن.

۴. بازنویسی MyPaymentsPage.tsx بر اساس الگوی ClaimsPage

ساختار نهایی دقیقاً به این ترتیب:

<>
  <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 را حذف می‌کند):

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 دست‌ساز و آیکون ذره‌بین را حذف کن؛ به‌جایش:

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>. ستون «ردیف» را حذف کن (شماره‌ی مصنوعی در جدول صفحه‌بندی‌شده ارزشی ندارد و فضای مفید می‌گیرد) و ستون وضعیت را اضافه کن:

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 مستند شود.

۴-۵ — عملیات و حالت خالی.

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).