- 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.
193 lines
8.5 KiB
TypeScript
193 lines
8.5 KiB
TypeScript
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>
|
||
</>
|
||
);
|
||
}
|