Files
clinicpro/assets/admin/pages/MyPaymentDetailPage.test.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

71 lines
3.0 KiB
TypeScript

import { describe, it, expect, beforeEach, vi } from 'vitest';
import { screen, fireEvent } from '@testing-library/react';
import { Route, Routes } from 'react-router-dom';
import { renderWithProviders } from '../test/utils';
vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } }));
vi.mock('../lib/api', () => ({ api: { get: vi.fn() }, ApiError: class extends Error {} }));
import { api } from '../lib/api';
import MyPaymentDetailPage from './MyPaymentDetailPage';
const get = api.get as ReturnType<typeof vi.fn>;
const PAYLOAD = {
patient: { uuid: 'abc', name: 'دنیا خلیلی', national_code: '1744023654' },
data: [
{ uuid: 'i1', number: 12345, issued_at: 1717000000, total_rials: 2350000, status: 'paid',
service_title: 'روکش دندان',
items: [{ uuid: 'it1', title: 'روکش دندان', quantity: 1, total_rials: 2350000, patient_rials: 2350000 }] },
{ uuid: 'i2', number: 52614, issued_at: 1718000000, total_rials: 6000000, status: 'unsettled',
service_title: 'طرح لبخند', items: [] },
],
meta: { totalRecords: 2, totalPages: 1, currentPage: 1 },
};
function renderPage() {
return renderWithProviders(
<Routes>
<Route path="/admin/my-payments/:patientUuid" element={<MyPaymentDetailPage />} />
</Routes>,
{ route: '/admin/my-payments/abc' },
);
}
beforeEach(() => {
get.mockReset();
get.mockResolvedValue({ success: true, data: PAYLOAD });
});
describe('MyPaymentDetailPage (پرداخت‌های ثبت‌شده)', () => {
it('renders the patient header and invoice rows', async () => {
renderPage();
expect(await screen.findByText('دنیا خلیلی')).toBeInTheDocument();
expect(screen.getByText(/1744023654/)).toBeInTheDocument();
expect(screen.getByText('روکش دندان')).toBeInTheDocument();
expect(screen.getByText('طرح لبخند')).toBeInTheDocument();
expect(screen.getByText('پرداخت شده')).toBeInTheDocument();
expect(screen.getByText('تسویه نشده')).toBeInTheDocument();
});
it('fetches invoices for the patient uuid from the route', async () => {
renderPage();
await screen.findByText('دنیا خلیلی');
expect(get.mock.calls[0][0]).toContain('/api/v1/my/billing/patients/abc/invoices');
});
it('expands a row to show its line items on بیشتر', async () => {
renderPage();
await screen.findByText('روکش دندان');
fireEvent.click(screen.getAllByRole('button', { name: /بیشتر/ })[0]);
// the item breakdown appears (title repeated inside the expanded sub-row)
expect(await screen.findAllByText('روکش دندان')).toHaveLength(2);
});
it('shows an empty state when the patient has no invoices', async () => {
get.mockResolvedValue({ success: true, data: { patient: PAYLOAD.patient, data: [], meta: { totalRecords: 0, totalPages: 0, currentPage: 1 } } });
renderPage();
expect(await screen.findByText('صورتحسابی ثبت نشده است')).toBeInTheDocument();
});
});