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.
This commit is contained in:
hamed
2026-07-19 10:12:01 +03:30
parent 7ebc04fc3f
commit 5c8fe8ece4
11 changed files with 735 additions and 129 deletions
+10 -2
View File
@@ -1,5 +1,5 @@
import React from 'react';
import type { AppointmentStatus, PaymentStatus, SmsTemplateStatus, SettlementStatus, ClaimStatus } from '../../types';
import type { AppointmentStatus, PaymentStatus, SmsTemplateStatus, SettlementStatus, ClaimStatus, InvoiceListStatus } from '../../types';
type BadgeColor = 'green' | 'amber' | 'red' | 'blue' | 'violet' | 'gray';
@@ -45,8 +45,13 @@ const claimMap: Record<ClaimStatus, { color: BadgeColor; label: string }> = {
mixed: { color: 'violet', label: 'وضعیت‌های مختلف' },
};
const invoiceMap: Record<InvoiceListStatus, { color: BadgeColor; label: string }> = {
paid: { color: 'green', label: 'پرداخت شده' },
unsettled: { color: 'amber', label: 'تسویه نشده' },
};
interface Props {
type: 'appointment' | 'payment' | 'sms' | 'settlement' | 'active' | 'claim';
type: 'appointment' | 'payment' | 'sms' | 'settlement' | 'active' | 'claim' | 'invoice';
value: string;
}
@@ -69,6 +74,9 @@ export default function StatusBadge({ type, value }: Props) {
} else if (type === 'claim') {
const m = claimMap[value as ClaimStatus];
if (m) { color = m.color; label = m.label; }
} else if (type === 'invoice') {
const m = invoiceMap[value as InvoiceListStatus];
if (m) { color = m.color; label = m.label; }
} else if (type === 'active') {
color = value === 'true' || value === 'active' ? 'green' : 'gray';
label = value === 'true' || value === 'active' ? 'فعال' : 'غیرفعال';
+21
View File
@@ -59,6 +59,27 @@ export function usePayments(filters: PaymentFilters) {
});
}
export interface PaymentsSummary {
total_rials: number;
paid_rials: number;
unsettled_rials: number;
invoices_count: number;
}
/** Summary over the same filters as the list, so the stat cards match the table. */
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()}`),
});
}
/** Node 2 — a single patient's recorded invoices (header + paginated list). */
export function usePatientInvoices(patientUuid: string | undefined, page: number) {
return useQuery<ApiResponse<PatientInvoicesPayload>>({
+38 -8
View File
@@ -21,10 +21,21 @@ const ROWS = [
issued_at: 1718000000, amount_rials: 6000000, status: 'unsettled' },
];
const SUMMARY = { total_rials: 8350000, paid_rials: 2350000, unsettled_rials: 6000000, invoices_count: 2 };
/** The page fires two queries; route by URL so each gets its own envelope. */
const mockApi = (rows = ROWS, total = rows.length) => {
get.mockImplementation((url: string) =>
url.includes('/payments/summary')
? Promise.resolve({ success: true, data: SUMMARY })
: Promise.resolve({ success: true, data: rows, meta: { totalRecords: total, totalPages: 1, currentPage: 1 } }),
);
};
beforeEach(() => {
navigate.mockReset();
get.mockReset();
get.mockResolvedValue({ success: true, data: ROWS, meta: { totalRecords: 2, totalPages: 1, currentPage: 1 } });
mockApi();
});
describe('MyPaymentsPage (لیست پرداخت‌ها)', () => {
@@ -36,23 +47,42 @@ describe('MyPaymentsPage (لیست پرداخت‌ها)', () => {
expect(screen.getByText('2200112233')).toBeInTheDocument();
});
it('navigates to the patient detail on مشاهده', async () => {
it('renders the two-state invoice status badge', async () => {
renderWithProviders(<MyPaymentsPage />, { route: '/admin/my-payments' });
expect(await screen.findByText('پرداخت شده')).toBeInTheDocument();
expect(screen.getByText('تسویه نشده')).toBeInTheDocument();
});
it('renders the summary stat cards', async () => {
renderWithProviders(<MyPaymentsPage />, { route: '/admin/my-payments' });
expect(await screen.findByText('مجموع صورتحساب‌ها')).toBeInTheDocument();
expect(screen.getByText('پرداخت‌شده')).toBeInTheDocument();
expect(screen.getByText('تسویه‌نشده')).toBeInTheDocument();
expect(screen.getByText('تعداد صورتحساب')).toBeInTheDocument();
});
it('navigates to the patient detail on جزئیات', async () => {
renderWithProviders(<MyPaymentsPage />, { route: '/admin/my-payments' });
await screen.findByText('دنیا خلیلی');
fireEvent.click(screen.getAllByRole('button', { name: /مشاهده/ })[0]);
fireEvent.click(screen.getAllByRole('button', { name: /جزئیات/ })[0]);
expect(navigate).toHaveBeenCalledWith('/admin/my-payments/p1');
});
it('shows an empty state when there are no payments', async () => {
get.mockResolvedValue({ success: true, data: [], meta: { totalRecords: 0, totalPages: 0, currentPage: 1 } });
mockApi([], 0);
renderWithProviders(<MyPaymentsPage />, { route: '/admin/my-payments' });
expect(await screen.findByText('پرداختی یافت نشد')).toBeInTheDocument();
expect(await screen.findByText('پرداختی ثبت نشده است.')).toBeInTheDocument();
});
it('sends the national_code filter to the API', async () => {
it('sends the national_code filter to both the list and the summary', async () => {
renderWithProviders(<MyPaymentsPage />, { route: '/admin/my-payments' });
await screen.findByText('دنیا خلیلی');
fireEvent.change(screen.getByPlaceholderText('کد ملی بیمار را وارد کنید...'), { target: { value: '1744' } });
await waitFor(() => expect(get.mock.calls.some(([u]) => String(u).includes('national_code=1744'))).toBe(true));
fireEvent.change(screen.getByPlaceholderText('کد ملی بیمار'), { target: { value: '1744' } });
await waitFor(() => {
const urls = get.mock.calls.map(([u]) => String(u)).filter((u) => u.includes('national_code=1744'));
expect(urls.some((u) => u.includes('/payments?'))).toBe(true);
expect(urls.some((u) => u.includes('/payments/summary?'))).toBe(true);
});
});
});
+127 -112
View File
@@ -1,18 +1,32 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { MagnifyingGlassIcon, EyeIcon, BanknotesIcon, UserPlusIcon } from '@heroicons/react/24/outline';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { ArrowPathIcon } from '@heroicons/react/24/outline';
import PageHeader from '../components/ui/PageHeader';
import Pagination from '../components/ui/Pagination';
import PersianDateInput from '../components/ui/PersianDateInput';
import PersianDatePicker from '../components/ui/PersianDatePicker';
import SearchableSelect from '../components/ui/SearchableSelect';
import StatCard from '../components/ui/StatCard';
import StatusBadge from '../components/ui/StatusBadge';
import DataTable, { type Column } from '../components/ui/DataTable';
import { formatRial, formatDate, formatNumber, toDate } from '../lib/utils';
import {
usePayments,
usePaymentsSummary,
MY_PAYMENTS_LIMIT,
type PaymentRow,
} from '../hooks/useMyPayments';
const EMPTY: PaymentRow[] = [];
const STATUS_OPTIONS = [
{ value: '', label: 'همه وضعیت‌ها' },
{ value: 'paid', label: 'پرداخت شده' },
{ value: 'unsettled', label: 'تسویه نشده' },
];
const isoNDaysAgo = (days: number): string => {
const d = new Date();
d.setDate(d.getDate() - days);
return d.toISOString().slice(0, 10);
};
const todayIso = (): string => new Date().toISOString().slice(0, 10);
/** HH:MM (Persian digits) from a unix timestamp. */
function formatTime(unix: number): string {
@@ -39,133 +53,134 @@ function Avatar({ name }: { name: string | null }) {
);
}
/** لیست پرداخت‌ها — flat list of the tenant's recorded invoices (ported from tauri /payments). */
/**
* لیست پرداخت‌ها — فهرست تخت صورتحساب‌های ثبت‌شده‌ی tenant.
* فیلترها در query string زندگی می‌کنند تا رفرش و اشتراک لینک، نما را حفظ کند.
*/
export default function MyPaymentsPage() {
const navigate = useNavigate();
const [page, setPage] = useState(1);
const [nationalCode, setNationalCode] = useState('');
const [status, setStatus] = useState('');
const [from, setFrom] = useState('');
const [to, setTo] = useState('');
const [params, setParams] = useSearchParams();
const reset = () => setPage(1);
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 { data, isLoading } = usePayments({
page,
national_code: nationalCode.trim() || undefined,
const hasFilters = !!(search || status || from || to);
/** تغییر فیلتر همیشه به صفحه‌ی اول برمی‌گردد؛ ماندن روی صفحه ۵ با نتیجه‌ی جدید بی‌معناست. */
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 });
};
const filters = {
national_code: search || undefined,
status: status || undefined,
from: from ? dayBound(from, false) : undefined,
to: to ? dayBound(to, true) : undefined,
});
};
const rows = data?.data ?? EMPTY;
const total = data?.meta?.totalRecords ?? 0;
const listQuery = usePayments({ page, ...filters });
const summaryQuery = usePaymentsSummary(filters);
const th: React.CSSProperties = { textAlign: 'right', padding: '12px 16px', fontWeight: 600 };
const td: React.CSSProperties = { padding: '12px 16px' };
const rows = listQuery.data?.data ?? [];
const total = listQuery.data?.meta?.totalRecords ?? 0;
const summary = summaryQuery.data?.data;
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} /> },
];
return (
<>
<PageHeader
title="لیست پرداخت‌ها"
description="پرداخت‌های ثبت‌شده‌ی بیماران شما"
action={
<button className="btn primary sm" onClick={() => navigate('/admin/patients/new')}>
<UserPlusIcon style={{ width: 16 }} /> اضافه کردن بیمار
</button>
}
breadcrumbs={[{ label: 'داشبورد', to: '/admin' }, { label: 'لیست پرداخت‌ها' }]}
/>
{/* فیلترها — کد ملی + وضعیت + بازه‌ی تاریخ */}
<div style={{ display: 'flex', gap: 10, marginBottom: 16, flexWrap: 'wrap', alignItems: 'center' }}>
<div style={{
flex: '1 1 240px', minWidth: 200, display: 'flex', alignItems: 'center', gap: 8,
background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 'var(--r)', padding: '9px 12px',
}}>
<MagnifyingGlassIcon style={{ width: 18, color: 'var(--text-3)', flexShrink: 0 }} />
<input
style={{ border: 'none', outline: 'none', background: 'transparent', flex: 1, fontSize: 14 }}
value={nationalCode}
onChange={(e) => { setNationalCode(e.target.value.replace(/\D/g, '')); reset(); }}
placeholder="کد ملی بیمار را وارد کنید..."
dir="ltr"
inputMode="numeric"
/>
</div>
<div style={{ flex: '0 0 auto', width: 160 }}>
<SearchableSelect
options={[{ value: 'paid', label: 'پرداخت شده' }, { value: 'unsettled', label: 'تسویه نشده' }]}
value={status || null}
onChange={(v) => { setStatus(v ? String(v) : ''); reset(); }}
placeholder="همه وضعیت‌ها"
isClearable
height={38}
/>
</div>
<div style={{ width: 150 }}>
<PersianDateInput value={from} onChange={(v) => { setFrom(v); reset(); }} placeholder="از تاریخ" />
</div>
<div style={{ width: 150 }}>
<PersianDateInput value={to} onChange={(v) => { setTo(v); reset(); }} placeholder="تا تاریخ" />
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 'var(--gap)', marginBottom: 'var(--gap)' }}>
<StatCard tone="violet" label="مجموع صورتحساب‌ها" value={formatRial(summary?.total_rials ?? 0)} />
<StatCard tone="green" label="پرداخت‌شده" value={formatRial(summary?.paid_rials ?? 0)} />
<StatCard tone="pink" label="تسویه‌نشده" value={formatRial(summary?.unsettled_rials ?? 0)} />
<StatCard tone="amber" label="تعداد صورتحساب" value={formatNumber(summary?.invoices_count ?? 0)} />
</div>
{isLoading ? (
<div className="card" style={{ padding: 32, textAlign: 'center', color: 'var(--text-3)' }}>در حال بارگذاری</div>
) : rows.length === 0 ? (
<div className="card" style={{ padding: '60px 24px', textAlign: 'center', color: 'var(--text-3)' }}>
<BanknotesIcon style={{ width: 48, margin: '0 auto 16px', display: 'block', opacity: 0.4 }} />
<div style={{ fontWeight: 600, fontSize: 15, marginBottom: 6, color: 'var(--text-2)' }}>پرداختی یافت نشد</div>
<div style={{ fontSize: 13 }}>با ثبت صورتحساب برای بیماران، این فهرست پر میشود.</div>
<div className="card" style={{ padding: 18 }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 10, alignItems: 'flex-end', marginBottom: 16 }}>
<div style={{ minWidth: 150 }}>
<label className="field-label">وضعیت</label>
<SearchableSelect
options={STATUS_OPTIONS}
value={status}
onChange={(v) => setParam({ status: v ? String(v) : '' })}
placeholder="همه وضعیت‌ها"
height={38}
/>
</div>
<div style={{ minWidth: 140 }}>
<label className="field-label">از تاریخ</label>
<PersianDatePicker value={from} onChange={(v) => setParam({ from: v })} height={38} />
</div>
<div style={{ minWidth: 140 }}>
<label className="field-label">تا تاریخ</label>
<PersianDatePicker value={to} onChange={(v) => setParam({ to: v })} height={38} />
</div>
<div style={{ display: 'flex', gap: 6 }}>
<button className="btn ghost sm" onClick={() => setParam({ from: isoNDaysAgo(30), to: todayIso() })}>یک ماه اخیر</button>
<button className="btn ghost sm" onClick={() => setParam({ from: isoNDaysAgo(365), to: todayIso() })}>یک سال اخیر</button>
{hasFilters && (
<button
className="btn ghost sm"
onClick={() => setParams(new URLSearchParams(), { replace: true })}
style={{ display: 'flex', alignItems: 'center', gap: 4 }}
>
<ArrowPathIcon style={{ width: 13 }} /> پاککردن فیلترها
</button>
)}
</div>
</div>
) : (
<>
<div className="card" style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13.5 }}>
<thead>
<tr style={{ borderBottom: '1px solid var(--border)', color: 'var(--text-3)', fontSize: 12 }}>
<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>
<tbody>
{rows.map((row, i) => (
<tr key={row.invoice_uuid} style={{ borderBottom: '1px solid var(--border)' }}>
<td style={td}>{formatNumber((page - 1) * MY_PAYMENTS_LIMIT + i + 1)}</td>
<td style={td}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Avatar name={row.patient_name} />
<span style={{ fontWeight: 600 }}>{row.patient_name ?? '—'}</span>
</div>
</td>
<td style={{ ...td, color: 'var(--text-2)' }} dir="ltr">{row.national_code ?? '—'}</td>
<td style={td} dir="ltr">{formatDate(row.issued_at)} - {formatTime(row.issued_at)}</td>
<td style={{ ...td, fontWeight: 600 }}>{formatRial(row.amount_rials)}</td>
<td style={{ ...td, textAlign: 'left' }}>
<button
className="cp-btn-secondary"
style={{ height: 32, padding: '0 12px', display: 'inline-flex', alignItems: 'center', gap: 5 }}
onClick={() => navigate(`/admin/my-payments/${row.patient_uuid}`)}
>
<EyeIcon style={{ width: 15 }} /> مشاهده
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div style={{ marginTop: 16 }}>
<Pagination page={page} total={total} limit={MY_PAYMENTS_LIMIT} onPageChange={setPage} />
</div>
</>
)}
<DataTable
columns={columns}
data={rows}
loading={listQuery.isLoading}
searchValue={search}
onSearchChange={(v) => setParam({ search: v.replace(/\D/g, '') })}
searchPlaceholder="کد ملی بیمار"
emptyMessage="پرداختی ثبت نشده است."
actions={(row) => (
<button className="btn primary sm" onClick={() => navigate(`/admin/my-payments/${row.patient_uuid}`)}>
جزئیات
</button>
)}
/>
{total > MY_PAYMENTS_LIMIT && (
<Pagination page={page} total={total} limit={MY_PAYMENTS_LIMIT} onPageChange={(p) => setParam({ page: String(p) })} />
)}
</div>
</>
);
}
+3
View File
@@ -204,6 +204,9 @@ export type SettlementStatus = "pending" | "approved" | "rejected";
/** `mixed` فقط در نمای تجمیعی بیمار معنا دارد: مطالبات آن بیمار وضعیت یکسان ندارند. */
export type ClaimStatus = "pending" | "submitted" | "approved" | "rejected" | "paid" | "mixed";
/** وضعیت دوحالته‌ی صورتحساب در فهرست پرداخت‌ها — با `PaymentStatus` درگاه فرق دارد. */
export type InvoiceListStatus = "paid" | "unsettled";
export interface ClaimPatientRow {
patient_uuid: string;
record_uuid: string;