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:
@@ -0,0 +1,57 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import React from 'react';
|
||||
|
||||
vi.mock('../lib/api', () => ({
|
||||
api: { get: vi.fn() },
|
||||
ApiError: class extends Error {},
|
||||
}));
|
||||
|
||||
import { api } from '../lib/api';
|
||||
import { usePatientPayments, usePatientInvoices } from './useMyPayments';
|
||||
|
||||
const get = api.get as ReturnType<typeof vi.fn>;
|
||||
|
||||
function wrapper({ children }: { children: React.ReactNode }) {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
get.mockReset();
|
||||
get.mockResolvedValue({ success: true, data: [], meta: { totalRecords: 0, totalPages: 0, currentPage: 1 } });
|
||||
});
|
||||
|
||||
describe('useMyPayments', () => {
|
||||
it('builds the list URL with all active filters', async () => {
|
||||
renderHook(() => usePatientPayments({ page: 2, national_code: '1744', status: 'paid', from: 100, to: 200 }), { wrapper });
|
||||
await waitFor(() => expect(get).toHaveBeenCalled());
|
||||
const url = get.mock.calls[0][0] as string;
|
||||
expect(url).toContain('/api/v1/my/billing/patient-payments?');
|
||||
expect(url).toContain('page=2');
|
||||
expect(url).toContain('national_code=1744');
|
||||
expect(url).toContain('status=paid');
|
||||
expect(url).toContain('from=100');
|
||||
expect(url).toContain('to=200');
|
||||
});
|
||||
|
||||
it('omits empty filters from the list URL', async () => {
|
||||
renderHook(() => usePatientPayments({ page: 1 }), { wrapper });
|
||||
await waitFor(() => expect(get).toHaveBeenCalled());
|
||||
const url = get.mock.calls[0][0] as string;
|
||||
expect(url).not.toContain('national_code=');
|
||||
expect(url).not.toContain('status=');
|
||||
});
|
||||
|
||||
it('does not fetch invoices until a patient uuid is provided', async () => {
|
||||
const { rerender } = renderHook(({ uuid }: { uuid?: string }) => usePatientInvoices(uuid, 1), {
|
||||
wrapper, initialProps: { uuid: undefined as string | undefined },
|
||||
});
|
||||
expect(get).not.toHaveBeenCalled();
|
||||
|
||||
rerender({ uuid: 'abc' });
|
||||
await waitFor(() => expect(get).toHaveBeenCalled());
|
||||
expect(get.mock.calls[0][0]).toContain('/api/v1/my/billing/patients/abc/invoices');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api } from '../lib/api';
|
||||
import type { ApiResponse, PaginatedResponse } from '../lib/api';
|
||||
|
||||
/** A derived per-patient payment status shown on the list (node 1). */
|
||||
export type PaymentRowStatus = 'paid' | 'unpaid' | 'unsettled';
|
||||
|
||||
export interface PatientPaymentRow {
|
||||
patient_uuid: string;
|
||||
patient_name: string | null;
|
||||
national_code: string | null;
|
||||
invoice_count: number;
|
||||
paid_rials: number;
|
||||
remaining_rials: number;
|
||||
status: PaymentRowStatus;
|
||||
}
|
||||
|
||||
export interface PatientPaymentFilters {
|
||||
page: number;
|
||||
national_code?: string;
|
||||
status?: string; // paid | unsettled | unpaid
|
||||
from?: number; // unix seconds
|
||||
to?: number; // unix seconds
|
||||
}
|
||||
|
||||
/** A single invoice status on the detail table (node 2) — two-state. */
|
||||
export type InvoiceRowStatus = 'paid' | 'unsettled';
|
||||
|
||||
export interface PatientInvoiceRow {
|
||||
uuid: string;
|
||||
number: number;
|
||||
issued_at: number;
|
||||
total_rials: number;
|
||||
status: InvoiceRowStatus;
|
||||
service_title: string | null;
|
||||
items: Array<{ uuid: string; title: string; quantity: number; total_rials: number; patient_rials: number }>;
|
||||
}
|
||||
|
||||
export interface PatientInvoicesPayload {
|
||||
patient: { uuid: string; name: string | null; national_code: string | null };
|
||||
data: PatientInvoiceRow[];
|
||||
meta: { totalRecords: number; totalPages: number; currentPage: number };
|
||||
}
|
||||
|
||||
const LIMIT = 20;
|
||||
|
||||
/** Node 1 — paginated per-patient payment summary for the current tenant. */
|
||||
export function usePatientPayments(filters: PatientPaymentFilters) {
|
||||
const qs = new URLSearchParams({ page: String(filters.page), limit: String(LIMIT) });
|
||||
if (filters.national_code) qs.set('national_code', filters.national_code);
|
||||
if (filters.status) qs.set('status', filters.status);
|
||||
if (filters.from) qs.set('from', String(filters.from));
|
||||
if (filters.to) qs.set('to', String(filters.to));
|
||||
|
||||
return useQuery<PaginatedResponse<PatientPaymentRow>>({
|
||||
queryKey: ['patient-payments', filters],
|
||||
queryFn: () => api.get(`/api/v1/my/billing/patient-payments?${qs.toString()}`),
|
||||
});
|
||||
}
|
||||
|
||||
/** Node 2 — a single patient's recorded invoices (header + paginated list). */
|
||||
export function usePatientInvoices(patientUuid: string | undefined, page: number) {
|
||||
return useQuery<ApiResponse<PatientInvoicesPayload>>({
|
||||
queryKey: ['patient-invoices', patientUuid, page],
|
||||
queryFn: () => api.get(`/api/v1/my/billing/patients/${patientUuid}/invoices?page=${page}&limit=${LIMIT}`),
|
||||
enabled: !!patientUuid,
|
||||
});
|
||||
}
|
||||
|
||||
export const MY_PAYMENTS_LIMIT = LIMIT;
|
||||
Reference in New Issue
Block a user