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:
@@ -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=');
|
||||
|
||||
@@ -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()}`),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user