Port two nobat724 Figma screens into the admin SPA for the doctor/clinic
tenant panel:
- node 1 — لیست پرداختها (/admin/my-payments): per-patient payment summary
(invoice count, paid, remaining, derived status paid/unsettled/unpaid),
filters by national code / status / Jalali date range, pagination.
- node 2 — پرداختهای ثبتشده (/admin/my-payments/:patientUuid): a patient's
recorded invoices with patient header, service title, total, status badge,
and an expandable per-invoice item breakdown.
Backend (App\Billing):
- InvoiceRepository::patientPaymentSummary/countPatientPaymentSummary — DQL
aggregation grouped by patient record (arbitrary join Invoice→PatientRecord
→User), draft/void excluded, derived-status HAVING filters.
- InvoiceRepository::invoicesForPatient/count + InvoiceService methods that
shape rows and derive status.
- BillingController: GET /api/v1/my/billing/patient-payments and
GET /api/v1/my/billing/patients/{patientUuid}/invoices (thin, resolveEntity,
tenant-scoped, 403/404). Invoice::getIssuedAt / InvoiceItem::getTitle added.
- docs/api/billing.md documents both endpoints.
Frontend: useMyPayments hooks, MyPaymentsPage, MyPaymentDetailPage, routes in
App.tsx (doctor/secretary/clinic, blockClinicScope) and a sidebar entry.
Persian strings hardcoded per existing admin convention (no i18n infra).
Tests: tests/Billing/PatientPaymentsTest.php (8), useMyPayments + both page
tests (11). Note: pre-existing LoginPage.test failures are unrelated (proven
by stashing this change).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
151 lines
7.4 KiB
TypeScript
151 lines
7.4 KiB
TypeScript
import { Fragment, 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 { formatRial, formatDate, formatNumber } from '../lib/utils';
|
||
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. */
|
||
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). */
|
||
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 total = payload?.meta?.totalRecords ?? 0;
|
||
|
||
const th: React.CSSProperties = { textAlign: 'right', padding: '12px 16px', fontWeight: 600 };
|
||
const td: React.CSSProperties = { padding: '12px 16px' };
|
||
|
||
return (
|
||
<>
|
||
<PageHeader
|
||
title="پرداختهای ثبتشده"
|
||
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 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>
|
||
|
||
{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>
|
||
</>
|
||
)}
|
||
</>
|
||
);
|
||
}
|