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:
@@ -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 }) {
|
||||
|
||||
@@ -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: 'تسویه نشده' },
|
||||
};
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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} /> },
|
||||
];
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user