Files
clinicpro/assets/admin/pages/MyPaymentDetailPage.tsx
T
hamed 6876135a53 feat: add BlogBodySanitizer for HTML sanitization on article save
- Implemented BlogBodySanitizer to clean HTML content before saving articles, ensuring security against XSS attacks.
- Added tests for BlogBodySanitizer to verify that unsafe tags and attributes are stripped from the content.
- Introduced ApiLeastPrivilegeTest to ensure that unauthorized users cannot access sensitive API routes, maintaining strict access control.
2026-08-07 21:13:38 +03:30

193 lines
8.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState } from 'react';
import { useNavigate, useParams } from 'react-router';
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,
} from '../hooks/useMyPayments';
const EMPTY: PatientInvoiceRow[] = [];
/** HH:MM (Persian digits) from a unix timestamp. */
function formatTime(unix: number): string {
return new Intl.DateTimeFormat('fa-IR', { hour: '2-digit', minute: '2-digit' }).format(new Date(unix * 1000));
}
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: 'var(--on-primary)', 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();
const [page, setPage] = useState(1);
const [expanded, setExpanded] = useState<string | null>(null);
const { data, isLoading } = usePatientInvoices(patientUuid, page);
const payload = data?.data;
const patient = payload?.patient;
const rows = payload?.data ?? EMPTY;
const summary = payload?.summary;
const total = payload?.meta?.totalRecords ?? 0;
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
backTo="/admin/my-payments"
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')}>
<ChevronRightIcon style={{ width: 15 }} /> بازگشت
</button>
<button className="btn primary sm" onClick={() => toast.info('ثبت پرداخت جدید به‌زودی اضافه می‌شود')}>
<PlusIcon style={{ width: 15 }} /> پرداخت جدید
</button>
</div>
}
/>
<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>
<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>
</>
);
}