feat: enhance payment status handling and summary in MyPaymentsPage

- Added support for 'partial' payment status in MyPaymentsPage and related components.
- Updated API responses to include 'paid_rials' and 'summary' for invoices.
- Introduced InvoicePaymentStatus service to derive payment status based on actual payments.
- Enhanced tests to cover new payment scenarios including partial payments and payment methods.
- Updated documentation to reflect changes in payment status and API responses.
This commit is contained in:
hamed
2026-07-19 10:38:29 +03:30
parent 5c8fe8ece4
commit 357adb1d14
18 changed files with 620 additions and 188 deletions
@@ -1,5 +1,6 @@
import { ChevronDownIcon } from '@heroicons/react/24/outline';
import { formatDate, formatRial } from '../lib/utils';
import { PAYMENT_METHOD_LABELS } from '../lib/paymentMethods';
import { FilesServicePaymentsCheck } from './icons/FilesServiceIcons';
export interface SessionPaymentData {
@@ -14,10 +15,7 @@ export interface SessionPaymentData {
updated_at?: number;
}
const PAYMENT_LABELS: Record<string, string> = {
cash: 'نقدی', card: 'کارت', insurance: 'بیمه', online: 'آنلاین', pending: 'در انتظار',
wallet: 'کیف پول', pos: 'کارتخوان',
};
const PAYMENT_LABELS = PAYMENT_METHOD_LABELS;
/** vertical hairline divider between meta columns (tauri MUI vertical Divider). */
function VDivider({ h = 24 }: { h?: number }) {
+33 -18
View File
@@ -23,6 +23,8 @@ interface Props<T> {
sortKey?: string | null;
sortDir?: 'asc' | 'desc';
onSort?: (key: string) => void;
/** محتوای ردیف بازشونده‌ی زیر هر ردیف؛ `null`/`undefined` یعنی بسته است. */
renderExpanded?: (row: T) => React.ReactNode;
}
function SkeletonRow({ cols }: { cols: number }) {
@@ -51,6 +53,7 @@ export default function DataTable<T extends object>({
sortKey,
sortDir,
onSort,
renderExpanded,
}: Props<T>) {
const allColumns = actions
? [...columns, { key: '__actions', header: 'اقدامات', className: 'w-[120px]' }]
@@ -129,24 +132,36 @@ export default function DataTable<T extends object>({
</td>
</tr>
) : (
data.map((row, rowIdx) => (
<tr key={rowIdx}>
{columns.map((col) => (
<td key={col.key} className={col.className ?? ''}>
{col.render
? col.render(row)
: ((row as Record<string, unknown>)[col.key] as React.ReactNode) ?? ''}
</td>
))}
{actions && (
<td>
<div className="row-actions">
{actions(row)}
</div>
</td>
)}
</tr>
))
data.map((row, rowIdx) => {
const expanded = renderExpanded?.(row);
return (
<React.Fragment key={rowIdx}>
<tr>
{columns.map((col) => (
<td key={col.key} className={col.className ?? ''}>
{col.render
? col.render(row)
: ((row as Record<string, unknown>)[col.key] as React.ReactNode) ?? '—'}
</td>
))}
{actions && (
<td>
<div className="row-actions">
{actions(row)}
</div>
</td>
)}
</tr>
{expanded && (
<tr>
<td colSpan={allColumns.length} style={{ height: 'auto', padding: '12px 16px', background: 'var(--surface-2)' }}>
{expanded}
</td>
</tr>
)}
</React.Fragment>
);
})
)}
</tbody>
</table>
@@ -47,6 +47,7 @@ const claimMap: Record<ClaimStatus, { color: BadgeColor; label: string }> = {
const invoiceMap: Record<InvoiceListStatus, { color: BadgeColor; label: string }> = {
paid: { color: 'green', label: 'پرداخت شده' },
partial: { color: 'blue', label: 'پرداخت ناقص' },
unsettled: { color: 'amber', label: 'تسویه نشده' },
};
+21 -5
View File
@@ -2,8 +2,11 @@ import { useQuery } from '@tanstack/react-query';
import { api } from '../lib/api';
import type { ApiResponse, PaginatedResponse } from '../lib/api';
/** Two-state invoice status shown on both the list (node 1) and detail (node 2). */
export type PaymentRowStatus = 'paid' | 'unsettled';
/**
* وضعیت پرداخت صورتحساب — مشتق از مجموع پرداخت‌های مراجعه در برابر سهم بیمار،
* نه ستون `invoices.status` (که فقط چرخه‌ی حیات را نگه می‌دارد).
*/
export type PaymentRowStatus = 'paid' | 'partial' | 'unsettled';
/** One recorded invoice on the flat payments list (node 1). */
export interface PaymentRow {
@@ -13,33 +16,46 @@ export interface PaymentRow {
national_code: string | null;
issued_at: number;
amount_rials: number;
paid_rials: number;
status: PaymentRowStatus;
}
export interface PaymentFilters {
page: number;
national_code?: string;
status?: string; // paid | unsettled
status?: string; // paid | partial | unsettled
from?: number; // unix seconds
to?: number; // unix seconds
}
/** A single invoice status on the detail table (node 2) — two-state. */
export type InvoiceRowStatus = 'paid' | 'unsettled';
/** همان وضعیت مشتق‌شده، روی جدول جزئیات بیمار (node 2). */
export type InvoiceRowStatus = PaymentRowStatus;
export interface PatientInvoiceRow {
uuid: string;
number: number;
issued_at: number;
total_rials: number;
patient_rials: number;
paid_rials: number;
status: InvoiceRowStatus;
service_title: string | null;
items: Array<{ uuid: string; title: string; quantity: number; total_rials: number; patient_rials: number }>;
payments: InvoicePaymentRow[];
}
/** یک پرداخت ثبت‌شده روی مراجعه‌ی همان صورتحساب. */
export interface InvoicePaymentRow {
method: string;
amount_rials: number;
paid_at: number;
created_by_name: string | null;
}
export interface PatientInvoicesPayload {
patient: { uuid: string; name: string | null; national_code: string | null };
data: PatientInvoiceRow[];
summary: PaymentsSummary;
meta: { totalRecords: number; totalPages: number; currentPage: number };
}
+13
View File
@@ -0,0 +1,13 @@
/** برچسب فارسی روش‌های پرداخت — تنها مرجع؛ در اکاردئون مراجعه و صفحه‌ی پرداخت‌ها مشترک است. */
export const PAYMENT_METHOD_LABELS: Record<string, string> = {
cash: 'نقدی',
card: 'کارت',
pos: 'کارتخوان',
wallet: 'کیف پول',
insurance: 'بیمه',
online: 'آنلاین',
pending: 'در انتظار',
};
export const paymentMethodLabel = (method: string): string =>
PAYMENT_METHOD_LABELS[method] ?? method;
+49 -10
View File
@@ -14,12 +14,20 @@ const get = api.get as ReturnType<typeof vi.fn>;
const PAYLOAD = {
patient: { uuid: 'abc', name: 'دنیا خلیلی', national_code: '1744023654' },
data: [
{ uuid: 'i1', number: 12345, issued_at: 1717000000, total_rials: 2350000, status: 'paid',
service_title: 'روکش دندان',
items: [{ uuid: 'it1', title: 'روکش دندان', quantity: 1, total_rials: 2350000, patient_rials: 2350000 }] },
{ uuid: 'i2', number: 52614, issued_at: 1718000000, total_rials: 6000000, status: 'unsettled',
service_title: 'طرح لبخند', items: [] },
{ uuid: 'i1', number: 12345, issued_at: 1717000000, total_rials: 2350000, patient_rials: 2350000,
paid_rials: 2350000, status: 'paid', service_title: 'روکش دندان',
items: [{ uuid: 'it1', title: 'روکش دندان', quantity: 1, total_rials: 2350000, patient_rials: 2350000 }],
payments: [
{ method: 'cash', amount_rials: 350000, paid_at: 1717000000, created_by_name: 'منشی' },
{ method: 'pos', amount_rials: 2000000, paid_at: 1717000500, created_by_name: null },
] },
{ uuid: 'i2', number: 52614, issued_at: 1718000000, total_rials: 6000000, patient_rials: 6000000,
paid_rials: 0, status: 'unsettled', service_title: 'طرح لبخند', items: [], payments: [] },
{ uuid: 'i3', number: 52615, issued_at: 1719000000, total_rials: 1000000, patient_rials: 1000000,
paid_rials: 400000, status: 'partial', service_title: 'جرم‌گیری', items: [],
payments: [{ method: 'wallet', amount_rials: 400000, paid_at: 1719000000, created_by_name: null }] },
],
summary: { total_rials: 8350000, paid_rials: 2350000, unsettled_rials: 6000000, invoices_count: 2 },
meta: { totalRecords: 2, totalPages: 1, currentPage: 1 },
};
@@ -40,31 +48,62 @@ beforeEach(() => {
describe('MyPaymentDetailPage (پرداخت‌های ثبت‌شده)', () => {
it('renders the patient header and invoice rows', async () => {
renderPage();
expect(await screen.findByText('دنیا خلیلی')).toBeInTheDocument();
// the name shows twice: breadcrumb + identity card
expect(await screen.findAllByText('دنیا خلیلی')).toHaveLength(2);
expect(screen.getByText(/1744023654/)).toBeInTheDocument();
expect(screen.getByText('روکش دندان')).toBeInTheDocument();
expect(screen.getByText('طرح لبخند')).toBeInTheDocument();
expect(screen.getByText('پرداخت شده')).toBeInTheDocument();
expect(screen.getByText('تسویه نشده')).toBeInTheDocument();
expect(screen.getByText('پرداخت ناقص')).toBeInTheDocument();
expect(screen.getByText(/پرداخت‌شده:/)).toBeInTheDocument();
});
it('renders the summary stat cards', async () => {
renderPage();
expect(await screen.findByText('مجموع صورتحساب‌ها')).toBeInTheDocument();
expect(screen.getByText('پرداخت‌شده')).toBeInTheDocument();
expect(screen.getByText('تسویه‌نشده')).toBeInTheDocument();
expect(screen.getByText('تعداد صورتحساب')).toBeInTheDocument();
});
it('fetches invoices for the patient uuid from the route', async () => {
renderPage();
await screen.findByText('دنیا خلیلی');
await screen.findAllByText('دنیا خلیلی');
expect(get.mock.calls[0][0]).toContain('/api/v1/my/billing/patients/abc/invoices');
});
it('expands a row to show its line items on بیشتر', async () => {
it('expands a row to show its line items and payment methods on بیشتر', async () => {
renderPage();
await screen.findByText('روکش دندان');
fireEvent.click(screen.getAllByRole('button', { name: /بیشتر/ })[0]);
// the item breakdown appears (title repeated inside the expanded sub-row)
expect(await screen.findAllByText('روکش دندان')).toHaveLength(2);
// and each payment shows its method in Persian
expect(screen.getByText('روش‌های پرداخت')).toBeInTheDocument();
expect(screen.getByText('نقدی')).toBeInTheDocument();
expect(screen.getByText('کارتخوان')).toBeInTheDocument();
});
it('says so when an invoice has no recorded payment', async () => {
renderPage();
await screen.findByText('طرح لبخند');
fireEvent.click(screen.getAllByRole('button', { name: /بیشتر/ })[1]);
expect(await screen.findByText('پرداختی ثبت نشده است.')).toBeInTheDocument();
});
it('shows an empty state when the patient has no invoices', async () => {
get.mockResolvedValue({ success: true, data: { patient: PAYLOAD.patient, data: [], meta: { totalRecords: 0, totalPages: 0, currentPage: 1 } } });
get.mockResolvedValue({
success: true,
data: {
patient: PAYLOAD.patient,
data: [],
summary: { total_rials: 0, paid_rials: 0, unsettled_rials: 0, invoices_count: 0 },
meta: { totalRecords: 0, totalPages: 0, currentPage: 1 },
},
});
renderPage();
expect(await screen.findByText('صورتحسابی ثبت نشده است')).toBeInTheDocument();
expect(await screen.findByText('صورتحسابی ثبت نشده است.')).toBeInTheDocument();
});
});
+138 -97
View File
@@ -1,20 +1,20 @@
import { Fragment, useState } from 'react';
import { useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { ChevronRightIcon, ChevronDownIcon, PlusIcon } from '@heroicons/react/24/outline';
import { toast } from 'sonner';
import PageHeader from '../components/ui/PageHeader';
import Pagination from '../components/ui/Pagination';
import StatCard from '../components/ui/StatCard';
import StatusBadge from '../components/ui/StatusBadge';
import DataTable, { type Column } from '../components/ui/DataTable';
import { formatRial, formatDate, formatNumber } from '../lib/utils';
import { paymentMethodLabel } from '../lib/paymentMethods';
import {
usePatientInvoices,
MY_PAYMENTS_LIMIT,
type PatientInvoiceRow,
type InvoiceRowStatus,
} from '../hooks/useMyPayments';
const STATUS_LABEL: Record<InvoiceRowStatus, string> = { paid: 'پرداخت شده', unsettled: 'تسویه نشده' };
const STATUS_BADGE: Record<InvoiceRowStatus, string> = { paid: 'green', unsettled: 'amber' };
const EMPTY: PatientInvoiceRow[] = [];
/** HH:MM (Persian digits) from a unix timestamp. */
@@ -22,7 +22,73 @@ function formatTime(unix: number): string {
return new Intl.DateTimeFormat('fa-IR', { hour: '2-digit', minute: '2-digit' }).format(new Date(unix * 1000));
}
/** پرداخت‌های ثبت‌شده — a single patient's recorded invoices (node 2). */
function Avatar({ name }: { name: string | null }) {
return (
<div style={{
width: 44, height: 44, borderRadius: '50%', flexShrink: 0,
background: 'linear-gradient(145deg, var(--primary), var(--primary-700, var(--primary)))',
display: 'grid', placeItems: 'center', color: '#fff', fontSize: 17, fontWeight: 700,
}}>
{(name ?? '؟').charAt(0)}
</div>
);
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div style={{ minWidth: 220, flex: '1 1 260px' }}>
<div style={{ fontSize: 12, fontWeight: 700, color: 'var(--text-3)', marginBottom: 8 }}>{title}</div>
{children}
</div>
);
}
/** آیتم‌های صورتحساب و پرداخت‌های ثبت‌شده‌ی آن، کنار هم در ردیف بازشونده. */
function InvoiceBreakdown({ row }: { row: PatientInvoiceRow }) {
return (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 24 }}>
<Section title="آیتم‌های صورتحساب">
{row.items.length === 0 ? (
<span style={{ fontSize: 12.5, color: 'var(--text-3)' }}>آیتمی ثبت نشده است.</span>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{row.items.map((it) => (
<div key={it.uuid} style={{ display: 'flex', justifyContent: 'space-between', gap: 12, fontSize: 13 }}>
<span style={{ color: 'var(--text-2)' }}>
{it.title}{it.quantity > 1 ? ` × ${formatNumber(it.quantity)}` : ''}
</span>
<span style={{ fontWeight: 600 }}>{formatRial(it.total_rials)}</span>
</div>
))}
</div>
)}
</Section>
<Section title="روش‌های پرداخت">
{row.payments.length === 0 ? (
<span style={{ fontSize: 12.5, color: 'var(--text-3)' }}>پرداختی ثبت نشده است.</span>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{row.payments.map((p, i) => (
<div key={i} style={{ display: 'flex', justifyContent: 'space-between', gap: 12, fontSize: 13 }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, color: 'var(--text-2)' }}>
<span className="badge gray"><span className="bdot" />{paymentMethodLabel(p.method)}</span>
<span style={{ fontSize: 11.5, color: 'var(--text-3)' }} dir="ltr">{formatDate(p.paid_at)}</span>
</span>
<span style={{ fontWeight: 600 }}>{formatRial(p.amount_rials)}</span>
</div>
))}
</div>
)}
</Section>
</div>
);
}
/**
* پرداخت‌های ثبت‌شده‌ی یک بیمار — سربرگ بیمار، کارت‌های آمار و جدول صورتحساب‌ها
* با ردیف بازشونده‌ی آیتم‌ها و روش‌های پرداخت.
*/
export default function MyPaymentDetailPage() {
const { patientUuid } = useParams<{ patientUuid: string }>();
const navigate = useNavigate();
@@ -33,15 +99,41 @@ export default function MyPaymentDetailPage() {
const payload = data?.data;
const patient = payload?.patient;
const rows = payload?.data ?? EMPTY;
const summary = payload?.summary;
const total = payload?.meta?.totalRecords ?? 0;
const th: React.CSSProperties = { textAlign: 'right', padding: '12px 16px', fontWeight: 600 };
const td: React.CSSProperties = { padding: '12px 16px' };
const columns: Column<PatientInvoiceRow>[] = [
{ key: 'number', header: 'شماره صورتحساب', render: (r) => <span style={{ fontWeight: 600 }} dir="ltr">#{formatNumber(r.number)}</span> },
{ key: 'issued_at', header: 'تاریخ', render: (r) => formatDate(r.issued_at) },
{ key: 'time', header: 'ساعت', render: (r) => <span dir="ltr">{formatTime(r.issued_at)}</span> },
{ key: 'service_title', header: 'نوع خدمت', render: (r) => <span style={{ color: 'var(--text-2)' }}>{r.service_title ?? '—'}</span> },
{
key: 'total_rials',
header: 'مبلغ کل',
render: (r) => (
<div>
<div style={{ fontWeight: 600 }}>{formatRial(r.total_rials)}</div>
{r.status === 'partial' && (
<div style={{ fontSize: 11.5, color: 'var(--text-3)', marginTop: 2 }}>
پرداختشده: {formatRial(r.paid_rials)}
</div>
)}
</div>
),
},
{ key: 'status', header: 'وضعیت', render: (r) => <StatusBadge type="invoice" value={r.status} /> },
];
return (
<>
<PageHeader
title="پرداخت‌های ثبت‌شده"
description="صورتحساب‌های ثبت‌شده‌ی این بیمار"
breadcrumbs={[
{ label: 'داشبورد', to: '/admin' },
{ label: 'لیست پرداخت‌ها', to: '/admin/my-payments' },
{ label: patient?.name ?? 'بیمار' },
]}
action={
<div style={{ display: 'flex', gap: 8 }}>
<button className="btn sm" onClick={() => navigate('/admin/my-payments')}>
@@ -54,97 +146,46 @@ export default function MyPaymentDetailPage() {
}
/>
{/* سربرگ بیمار */}
<div style={{
display: 'flex', gap: 24, flexWrap: 'wrap', alignItems: 'center',
marginBottom: 16, padding: '14px 16px',
background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 'var(--r)',
}}>
<span style={{ fontSize: 14 }}>
نام بیمار: <b style={{ fontWeight: 700 }}>{patient?.name ?? '—'}</b>
</span>
<span style={{ fontSize: 14, color: 'var(--text-2)' }} dir="ltr">
کدملی: {patient?.national_code ?? '—'}
</span>
<div className="card" style={{ display: 'flex', alignItems: 'center', gap: 14, padding: '14px 18px', marginBottom: 'var(--gap)' }}>
<Avatar name={patient?.name ?? null} />
<div style={{ minWidth: 0 }}>
<div style={{ fontWeight: 700, fontSize: 15 }}>{patient?.name ?? '—'}</div>
<div style={{ fontSize: 12.5, color: 'var(--text-3)', marginTop: 3 }} dir="ltr">
{patient?.national_code ?? '—'}
</div>
</div>
</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)' }}>
<div style={{ fontWeight: 600, fontSize: 15, marginBottom: 6, color: 'var(--text-2)' }}>صورتحسابی ثبت نشده است</div>
<div style={{ fontSize: 13 }}>برای این بیمار هنوز پرداخت ثبتشدهای وجود ندارد.</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}>وضعیت</th>
<th style={{ ...th, textAlign: 'left' }}>جزئیات</th>
</tr>
</thead>
<tbody>
{rows.map((row) => {
const open = expanded === row.uuid;
return (
<Fragment key={row.uuid}>
<tr style={{ borderBottom: open ? 'none' : '1px solid var(--border)' }}>
<td style={{ ...td, fontWeight: 600 }} dir="ltr">#{formatNumber(row.number)}</td>
<td style={td}>{formatDate(row.issued_at)}</td>
<td style={td} dir="ltr">{formatTime(row.issued_at)}</td>
<td style={{ ...td, color: 'var(--text-2)' }}>{row.service_title ?? '—'}</td>
<td style={{ ...td, fontWeight: 600 }}>{formatRial(row.total_rials)}</td>
<td style={td}>
<span className={`badge ${STATUS_BADGE[row.status]}`}><span className="bdot" />{STATUS_LABEL[row.status]}</span>
</td>
<td style={{ ...td, textAlign: 'left' }}>
<button
className="btn sm ghost"
style={{ display: 'inline-flex', alignItems: 'center', gap: 4, color: 'var(--primary)' }}
onClick={() => setExpanded(open ? null : row.uuid)}
>
بیشتر <ChevronDownIcon style={{ width: 15, transform: open ? 'rotate(180deg)' : undefined, transition: 'transform .15s' }} />
</button>
</td>
</tr>
{open && (
<tr style={{ borderBottom: '1px solid var(--border)', background: 'var(--surface-2)' }}>
<td colSpan={7} style={{ padding: '10px 16px 14px' }}>
{row.items.length === 0 ? (
<span style={{ fontSize: 12.5, color: 'var(--text-3)' }}>آیتمی ثبت نشده است.</span>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{row.items.map((it) => (
<div key={it.uuid} style={{ display: 'flex', justifyContent: 'space-between', gap: 12, fontSize: 13 }}>
<span style={{ color: 'var(--text-2)' }}>
{it.title}{it.quantity > 1 ? ` × ${formatNumber(it.quantity)}` : ''}
</span>
<span style={{ fontWeight: 600 }}>{formatRial(it.total_rials)}</span>
</div>
))}
</div>
)}
</td>
</tr>
)}
</Fragment>
);
})}
</tbody>
</table>
</div>
<div style={{ marginTop: 16 }}>
<Pagination page={page} total={total} limit={MY_PAYMENTS_LIMIT} onPageChange={setPage} />
</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>
<div className="card" style={{ padding: 18 }}>
<DataTable
columns={columns}
data={rows}
loading={isLoading}
emptyMessage="صورتحسابی ثبت نشده است."
actions={(row) => (
<button
className="btn ghost sm"
style={{ display: 'inline-flex', alignItems: 'center', gap: 4, color: 'var(--primary)' }}
onClick={() => setExpanded(expanded === row.uuid ? null : row.uuid)}
>
بیشتر
<ChevronDownIcon style={{ width: 15, transform: expanded === row.uuid ? 'rotate(180deg)' : undefined, transition: 'transform .15s' }} />
</button>
)}
renderExpanded={(row) => (expanded === row.uuid ? <InvoiceBreakdown row={row} /> : null)}
/>
{total > MY_PAYMENTS_LIMIT && (
<Pagination page={page} total={total} limit={MY_PAYMENTS_LIMIT} onPageChange={setPage} />
)}
</div>
</>
);
}
+11 -3
View File
@@ -16,9 +16,11 @@ const get = api.get as ReturnType<typeof vi.fn>;
const ROWS = [
{ invoice_uuid: 'iv1', patient_uuid: 'p1', patient_name: 'دنیا خلیلی', national_code: '1744023654',
issued_at: 1717000000, amount_rials: 2350000, status: 'paid' },
issued_at: 1717000000, amount_rials: 2350000, paid_rials: 2350000, status: 'paid' },
{ invoice_uuid: 'iv2', patient_uuid: 'p2', patient_name: 'علی بدیعی', national_code: '2200112233',
issued_at: 1718000000, amount_rials: 6000000, status: 'unsettled' },
issued_at: 1718000000, amount_rials: 6000000, paid_rials: 0, status: 'unsettled' },
{ invoice_uuid: 'iv3', patient_uuid: 'p3', patient_name: 'مریم رضایی', national_code: '3300112233',
issued_at: 1719000000, amount_rials: 1000000, paid_rials: 400000, status: 'partial' },
];
const SUMMARY = { total_rials: 8350000, paid_rials: 2350000, unsettled_rials: 6000000, invoices_count: 2 };
@@ -47,10 +49,16 @@ describe('MyPaymentsPage (لیست پرداخت‌ها)', () => {
expect(screen.getByText('2200112233')).toBeInTheDocument();
});
it('renders the two-state invoice status badge', async () => {
it('renders the derived payment status badge for each state', async () => {
renderWithProviders(<MyPaymentsPage />, { route: '/admin/my-payments' });
expect(await screen.findByText('پرداخت شده')).toBeInTheDocument();
expect(screen.getByText('تسویه نشده')).toBeInTheDocument();
expect(screen.getByText('پرداخت ناقص')).toBeInTheDocument();
});
it('shows how much was collected on a partially paid invoice', async () => {
renderWithProviders(<MyPaymentsPage />, { route: '/admin/my-payments' });
expect(await screen.findByText(/پرداخت‌شده:/)).toBeInTheDocument();
});
it('renders the summary stat cards', async () => {
+15 -1
View File
@@ -18,6 +18,7 @@ import {
const STATUS_OPTIONS = [
{ value: '', label: 'همه وضعیت‌ها' },
{ value: 'paid', label: 'پرداخت شده' },
{ value: 'partial', label: 'پرداخت ناقص' },
{ value: 'unsettled', label: 'تسویه نشده' },
];
@@ -108,7 +109,20 @@ export default function MyPaymentsPage() {
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: 'amount_rials',
header: 'مبلغ',
render: (r) => (
<div>
<div style={{ fontWeight: 600 }}>{formatRial(r.amount_rials)}</div>
{r.status === 'partial' && (
<div style={{ fontSize: 11.5, color: 'var(--text-3)', marginTop: 2 }}>
پرداختشده: {formatRial(r.paid_rials)}
</div>
)}
</div>
),
},
{ key: 'status', header: 'وضعیت', render: (r) => <StatusBadge type="invoice" value={r.status} /> },
];
+5 -2
View File
@@ -204,8 +204,11 @@ export type SettlementStatus = "pending" | "approved" | "rejected";
/** `mixed` فقط در نمای تجمیعی بیمار معنا دارد: مطالبات آن بیمار وضعیت یکسان ندارند. */
export type ClaimStatus = "pending" | "submitted" | "approved" | "rejected" | "paid" | "mixed";
/** وضعیت دوحالته‌ی صورتحساب در فهرست پرداخت‌ها — با `PaymentStatus` درگاه فرق دارد. */
export type InvoiceListStatus = "paid" | "unsettled";
/**
* وضعیت پرداخت صورتحساب — از مجموع پرداخت‌های مراجعه در برابر سهم بیمار مشتق
* می‌شود (ستون `invoices.status` فقط چرخه‌ی حیات است). با `PaymentStatus` درگاه فرق دارد.
*/
export type InvoiceListStatus = "paid" | "partial" | "unsettled";
export interface ClaimPatientRow {
patient_uuid: string;