From 818506bf36e513df98afdeac49d5735654476ee4 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Tue, 14 Jul 2026 19:00:17 +0330 Subject: [PATCH] refactor(billing): rebuild payments list as flat invoice list (tauri parity) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- assets/admin/hooks/useMyPayments.test.tsx | 8 +- assets/admin/hooks/useMyPayments.ts | 27 ++--- assets/admin/pages/MyPaymentsPage.test.tsx | 14 +-- assets/admin/pages/MyPaymentsPage.tsx | 77 +++++++------ docs/api/billing.md | 13 ++- src/Billing/Controller/BillingController.php | 14 +-- src/Billing/Repository/InvoiceRepository.php | 113 +++++++++---------- src/Billing/Service/InvoiceService.php | 19 +--- tests/Billing/PatientPaymentsTest.php | 73 ++++-------- 9 files changed, 166 insertions(+), 192 deletions(-) diff --git a/assets/admin/hooks/useMyPayments.test.tsx b/assets/admin/hooks/useMyPayments.test.tsx index e2b2c5cc..a74edc92 100644 --- a/assets/admin/hooks/useMyPayments.test.tsx +++ b/assets/admin/hooks/useMyPayments.test.tsx @@ -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; @@ -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='); diff --git a/assets/admin/hooks/useMyPayments.ts b/assets/admin/hooks/useMyPayments.ts index 0475a105..63b327d1 100644 --- a/assets/admin/hooks/useMyPayments.ts +++ b/assets/admin/hooks/useMyPayments.ts @@ -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>({ - queryKey: ['patient-payments', filters], - queryFn: () => api.get(`/api/v1/my/billing/patient-payments?${qs.toString()}`), + return useQuery>({ + queryKey: ['payments', filters], + queryFn: () => api.get(`/api/v1/my/billing/payments?${qs.toString()}`), }); } diff --git a/assets/admin/pages/MyPaymentsPage.test.tsx b/assets/admin/pages/MyPaymentsPage.test.tsx index 15512573..9b6d7d54 100644 --- a/assets/admin/pages/MyPaymentsPage.test.tsx +++ b/assets/admin/pages/MyPaymentsPage.test.tsx @@ -15,10 +15,10 @@ import MyPaymentsPage from './MyPaymentsPage'; const get = api.get as ReturnType; const ROWS = [ - { patient_uuid: 'p1', patient_name: 'دنیا خلیلی', national_code: '1744023654', - invoice_count: 2, paid_rials: 2350000, remaining_rials: 500000, status: 'unsettled' }, - { patient_uuid: 'p2', patient_name: 'علی بدیعی', national_code: '2200112233', - invoice_count: 1, paid_rials: 2000000, remaining_rials: 0, status: 'paid' }, + { invoice_uuid: 'iv1', patient_uuid: 'p1', patient_name: 'دنیا خلیلی', national_code: '1744023654', + issued_at: 1717000000, amount_rials: 2350000, status: 'paid' }, + { invoice_uuid: 'iv2', patient_uuid: 'p2', patient_name: 'علی بدیعی', national_code: '2200112233', + issued_at: 1718000000, amount_rials: 6000000, status: 'unsettled' }, ]; beforeEach(() => { @@ -28,14 +28,12 @@ beforeEach(() => { }); describe('MyPaymentsPage (لیست پرداخت‌ها)', () => { - it('renders patient payment rows with derived status labels', async () => { + it('renders a flat row per invoice with patient name and national code', async () => { renderWithProviders(, { route: '/admin/my-payments' }); expect(await screen.findByText('دنیا خلیلی')).toBeInTheDocument(); expect(screen.getByText('علی بدیعی')).toBeInTheDocument(); - // status labels appear both as a filter