Files
clinicpro/assets/admin/pages/MyPaymentDetailPage.tsx
T
hamedandClaude Opus 4.8 4c29fa3274 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>
2026-07-14 18:24:33 +03:30

151 lines
7.4 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 { 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>
</>
)}
</>
);
}