feat(billing): patient payments list + patient invoices detail (doctor/clinic)

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>
This commit is contained in:
hamed
2026-07-14 18:24:33 +03:30
co-authored by Claude Opus 4.8
parent bfeffbc7b3
commit 4c29fa3274
15 changed files with 1101 additions and 0 deletions
+150
View File
@@ -0,0 +1,150 @@
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>
</>
)}
</>
);
}