- 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.
18 KiB
بازطراحی 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 با همان الگو.
مشکل
وضعیت فعلی صفحه:
- بدون design-system — جدول خام با
th/tdinline style بهجایDataTable. یعنی: بدون skeleton loading، بدون سورت، بدون empty state استاندارد. - بدون هیچ آمار خلاصهای — کاربر هیچ دید کلی از مجموع مبلغ/تعداد/تسویهنشده ندارد (بر خلاف claims که ۴
StatCardدارد). - ستون
statusنمایش داده نمیشود — با اینکهPaymentRow.status(paid | unsettled) از API میآید و فیلترش هم در UI هست، در جدول هیچ ستون وضعیتی وجود ندارد. کاربر فیلتر میکند ولی نتیجهاش را نمیبیند. - فیلترها بدون label و بدون کارت — یک ردیف شناور بالای صفحه، بدون
field-label، بدون دکمهی «پاککردن فیلترها»، بدون میانبر «یک ماه اخیر / یک سال اخیر». - فیلترها در state محلیاند، نه در query string — رفرش صفحه یا اشتراک لینک، فیلترها و شمارهی صفحه را از بین میبرد.
ClaimsPageاین را باuseSearchParamsحل کرده. - جستجو فقط کد ملی است — با
inputدستساز، در حالی کهDataTableخودشsearchValue/onSearchChangeدارد. PersianDateInputبهجایPersianDatePicker— ناهماهنگ با claims و بدونheight={38}همتراز باSearchableSelect.- 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 }} />— فقط وقتیhasFilterstrue است
۴-۴ — ستونها با 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_codenullable هستند →'—'. - RTL و اعداد فارسی:
formatRial/formatNumber/formatDateازlib/utils— عدد خام رندر نکن. کد ملی و تاریخ باdir="ltr". Paginationفقط وقتیtotal > MY_PAYMENTS_LIMITرندر شود (مثل claims).