refactor(billing): rebuild payments list as flat invoice list (tauri parity)

Align /admin/my-payments with the tauri /payments source (per user): the list
is now a flat, newest-first list of the tenant's recorded invoices — one row
per invoice — instead of the per-patient aggregation built from Figma.

Backend:
- replace InvoiceRepository::patientPaymentSummary aggregation with
  tenantInvoices/countTenantInvoices (flat, joins patient name/national code).
- InvoiceService::patientPaymentList → tenantInvoiceList.
- BillingController: GET /api/v1/my/billing/patient-payments →
  GET /api/v1/my/billing/payments returning
  { invoice_uuid, patient_uuid, patient_name, national_code, issued_at,
    amount_rials, status } rows.
- node-2 patient invoices endpoint unchanged.

Frontend:
- useMyPayments: usePatientPayments → usePayments (flat PaymentRow).
- MyPaymentsPage columns match tauri DetailT: row #, avatar+name, national
  code, date-time, amount paid, مشاهده (no status column); 'اضافه کردن بیمار'
  links to /admin/patients/new. Filters (national code / status / Jalali date
  range) kept.

Tests + docs/api/billing.md updated. Intentionally omitted tauri extras:
mobile Cards view and the advanced ModalFilter.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-14 19:00:17 +03:30
co-authored by Claude Opus 4.8
parent 8d6278e125
commit 818506bf36
9 changed files with 166 additions and 192 deletions
+4 -4
View File
@@ -9,7 +9,7 @@ vi.mock('../lib/api', () => ({
}));
import { api } from '../lib/api';
import { usePatientPayments, usePatientInvoices } from './useMyPayments';
import { usePayments, usePatientInvoices } from './useMyPayments';
const get = api.get as ReturnType<typeof vi.fn>;
@@ -25,10 +25,10 @@ beforeEach(() => {
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 });
renderHook(() => usePayments({ 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('/api/v1/my/billing/payments?');
expect(url).toContain('page=2');
expect(url).toContain('national_code=1744');
expect(url).toContain('status=paid');
@@ -37,7 +37,7 @@ describe('useMyPayments', () => {
});
it('omits empty filters from the list URL', async () => {
renderHook(() => usePatientPayments({ page: 1 }), { wrapper });
renderHook(() => usePayments({ page: 1 }), { wrapper });
await waitFor(() => expect(get).toHaveBeenCalled());
const url = get.mock.calls[0][0] as string;
expect(url).not.toContain('national_code=');
+14 -13
View File
@@ -2,23 +2,24 @@ 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';
/** Two-state invoice status shown on both the list (node 1) and detail (node 2). */
export type PaymentRowStatus = 'paid' | 'unsettled';
export interface PatientPaymentRow {
/** One recorded invoice on the flat payments list (node 1). */
export interface PaymentRow {
invoice_uuid: string;
patient_uuid: string;
patient_name: string | null;
national_code: string | null;
invoice_count: number;
paid_rials: number;
remaining_rials: number;
issued_at: number;
amount_rials: number;
status: PaymentRowStatus;
}
export interface PatientPaymentFilters {
export interface PaymentFilters {
page: number;
national_code?: string;
status?: string; // paid | unsettled | unpaid
status?: string; // paid | unsettled
from?: number; // unix seconds
to?: number; // unix seconds
}
@@ -44,17 +45,17 @@ export interface PatientInvoicesPayload {
const LIMIT = 20;
/** Node 1 — paginated per-patient payment summary for the current tenant. */
export function usePatientPayments(filters: PatientPaymentFilters) {
/** Node 1 — flat, paginated list of the current tenant's recorded invoices. */
export function usePayments(filters: PaymentFilters) {
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()}`),
return useQuery<PaginatedResponse<PaymentRow>>({
queryKey: ['payments', filters],
queryFn: () => api.get(`/api/v1/my/billing/payments?${qs.toString()}`),
});
}