From 827560cdfdae7c48da96b52d21c365885251e6a4 Mon Sep 17 00:00:00 2001 From: Potenz Date: Sat, 25 Oct 2025 20:00:09 +0530 Subject: [PATCH] feat(collectionbydoctor query) - v1 done --- apps/Backend/src/routes/payments-reports.ts | 165 ++++++--- .../export-payments-reports-storage.ts | 26 +- .../src/storage/payments-reports-storage.ts | 326 ++++++++++-------- .../reports/collections-by-doctor-report.tsx | 87 ++++- 4 files changed, 383 insertions(+), 221 deletions(-) diff --git a/apps/Backend/src/routes/payments-reports.ts b/apps/Backend/src/routes/payments-reports.ts index b3337b4..191cacf 100644 --- a/apps/Backend/src/routes/payments-reports.ts +++ b/apps/Backend/src/routes/payments-reports.ts @@ -80,69 +80,134 @@ router.get( ); /** - * GET /api/payments-reports/by-doctor + * GET /api/payments-reports/by-doctor/balances * Query params: * - staffId (required) * - limit (optional, default 25) * - cursor (optional) * - from/to (optional ISO date strings) - filter payments by createdAt in the range (inclusive) + * + * Response: { balances, totalCount, nextCursor, hasMore } */ -router.get("/by-doctor", async (req: Request, res: Response): Promise => { - try { - const staffIdRaw = req.query.staffId; - if (!staffIdRaw) { - return res - .status(400) - .json({ message: "Missing required 'staffId' query parameter" }); - } - const staffId = Number(staffIdRaw); - if (!Number.isFinite(staffId) || staffId <= 0) { - return res - .status(400) - .json({ message: "Invalid 'staffId' query parameter" }); - } +router.get( + "/by-doctor/balances", + async (req: Request, res: Response): Promise => { + try { + const staffIdRaw = req.query.staffId; + if (!staffIdRaw) { + return res + .status(400) + .json({ message: "Missing required 'staffId' query parameter" }); + } + const staffId = Number(staffIdRaw); + if (!Number.isFinite(staffId) || staffId <= 0) { + return res + .status(400) + .json({ message: "Invalid 'staffId' query parameter" }); + } - const limit = Math.max( - 1, - Math.min(200, parseInt(String(req.query.limit || "25"), 10)) - ); + const limit = Math.max( + 1, + Math.min(200, parseInt(String(req.query.limit || "25"), 10)) + ); - const cursor = - typeof req.query.cursor === "string" ? String(req.query.cursor) : null; + const cursor = + typeof req.query.cursor === "string" ? String(req.query.cursor) : null; - const from = req.query.from ? new Date(String(req.query.from)) : undefined; - const to = req.query.to ? new Date(String(req.query.to)) : undefined; + const from = req.query.from + ? new Date(String(req.query.from)) + : undefined; + const to = req.query.to ? new Date(String(req.query.to)) : undefined; - if (req.query.from && isNaN(from?.getTime() ?? NaN)) { - return res.status(400).json({ message: "Invalid 'from' date" }); - } - if (req.query.to && isNaN(to?.getTime() ?? NaN)) { - return res.status(400).json({ message: "Invalid 'to' date" }); - } + if (req.query.from && isNaN(from?.getTime() ?? NaN)) { + return res.status(400).json({ message: "Invalid 'from' date" }); + } + if (req.query.to && isNaN(to?.getTime() ?? NaN)) { + return res.status(400).json({ message: "Invalid 'to' date" }); + } - const data = await storage.getBalancesAndSummaryByDoctor( - staffId, - limit, - cursor, - from, - to - ); - // data expected: { balances, totalCount, nextCursor, hasMore, summary } - res.json(data); - } catch (err: any) { - console.error( - "GET /api/payments-reports/by-doctor error:", - err?.message ?? err, - err?.stack - ); - // If prisma errors, return 500 with message for debugging (strip sensitive info in prod) - res - .status(500) - .json({ - message: "Failed to fetch doctor balances and summary", + // use the new storage method that returns only the paged balances + const balancesResult = await storage.getPatientsBalancesByDoctor( + staffId, + limit, + cursor, + from, + to + ); + + res.json({ + balances: balancesResult?.balances ?? [], + totalCount: Number(balancesResult?.totalCount ?? 0), + nextCursor: balancesResult?.nextCursor ?? null, + hasMore: Boolean(balancesResult?.hasMore ?? false), + }); + } catch (err: any) { + console.error( + "GET /api/payments-reports/by-doctor/balances error:", + err?.message ?? err, + err?.stack + ); + res.status(500).json({ + message: "Failed to fetch doctor balances", detail: err?.message ?? String(err), }); + } } -}); +); + +/** + * GET /api/payments-reports/by-doctor/summary + * Query params: + * - staffId (required) + * - from/to (optional ISO date strings) - filter payments by createdAt in the range (inclusive) + * + * Response: { totalPatients, totalOutstanding, totalCollected, patientsWithBalance } + */ +router.get( + "/by-doctor/summary", + async (req: Request, res: Response): Promise => { + try { + const staffIdRaw = req.query.staffId; + if (!staffIdRaw) { + return res + .status(400) + .json({ message: "Missing required 'staffId' query parameter" }); + } + const staffId = Number(staffIdRaw); + if (!Number.isFinite(staffId) || staffId <= 0) { + return res + .status(400) + .json({ message: "Invalid 'staffId' query parameter" }); + } + + const from = req.query.from + ? new Date(String(req.query.from)) + : undefined; + const to = req.query.to ? new Date(String(req.query.to)) : undefined; + + if (req.query.from && isNaN(from?.getTime() ?? NaN)) { + return res.status(400).json({ message: "Invalid 'from' date" }); + } + if (req.query.to && isNaN(to?.getTime() ?? NaN)) { + return res.status(400).json({ message: "Invalid 'to' date" }); + } + + // use the new storage method that returns only the summary for the staff + const summary = await storage.getSummaryByDoctor(staffId, from, to); + + res.json(summary); + } catch (err: any) { + console.error( + "GET /api/payments-reports/by-doctor/summary error:", + err?.message ?? err, + err?.stack + ); + res.status(500).json({ + message: "Failed to fetch doctor summary", + detail: err?.message ?? String(err), + }); + } + } +); export default router; diff --git a/apps/Backend/src/storage/export-payments-reports-storage.ts b/apps/Backend/src/storage/export-payments-reports-storage.ts index ea6ad53..ed9ba7c 100644 --- a/apps/Backend/src/storage/export-payments-reports-storage.ts +++ b/apps/Backend/src/storage/export-payments-reports-storage.ts @@ -1,5 +1,6 @@ import { storage } from "../storage"; import { getPatientFinancialRowsFn } from "./patients-storage"; +import { GetPatientBalancesResult } from "@repo/db/types"; type PatientSummaryRow = { patientId: number; @@ -20,12 +21,8 @@ export async function fetchAllPatientsWithBalances( const all: PatientSummaryRow[] = []; let cursor: string | null = null; while (true) { - const page = await storage.getPatientsWithBalances( - pageSize, - cursor, - from, - to - ); + const page: GetPatientBalancesResult = + await storage.getPatientsWithBalances(pageSize, cursor, from, to); if (!page) break; if (Array.isArray(page.balances) && page.balances.length) { for (const b of page.balances) { @@ -44,7 +41,7 @@ export async function fetchAllPatientsWithBalances( } /** - * Page through storage.getBalancesAndSummaryByDoctor to return full patient list for the staff. + * Page through storage.getPatientsBalancesByDoctor to return full patient list for the staff. */ export async function fetchAllPatientsForDoctor( staffId: number, @@ -55,13 +52,14 @@ export async function fetchAllPatientsForDoctor( const all: PatientSummaryRow[] = []; let cursor: string | null = null; while (true) { - const page = await storage.getBalancesAndSummaryByDoctor( - staffId, - pageSize, - cursor, - from, - to - ); + const page: GetPatientBalancesResult = + await storage.getPatientsBalancesByDoctor( + staffId, + pageSize, + cursor, + from, + to + ); if (!page) break; if (Array.isArray(page.balances) && page.balances.length) { for (const b of page.balances) { diff --git a/apps/Backend/src/storage/payments-reports-storage.ts b/apps/Backend/src/storage/payments-reports-storage.ts index e1a9530..47355f8 100644 --- a/apps/Backend/src/storage/payments-reports-storage.ts +++ b/apps/Backend/src/storage/payments-reports-storage.ts @@ -31,20 +31,36 @@ export interface IPaymentsReportsStorage { ): Promise; /** - * One-query approach: returns both page of patient balances for the staff and a summary + * Returns the paginated patient balances for a specific staff (doctor). + * Same semantics / columns / ordering / cursor behavior as the previous combined function. * * - staffId required * - limit: page size * - cursorToken: optional base64 cursor (must have been produced for same staffId) * - from/to: optional date range applied to Payment."createdAt" */ - getBalancesAndSummaryByDoctor( + getPatientsBalancesByDoctor( staffId: number, limit: number, cursorToken?: string | null, from?: Date | null, to?: Date | null - ): Promise; + ): Promise; + + /** + * Returns only the summary object for the given staff (doctor). + * Same summary shape as getSummary(), but scoped to claims/payments associated with the given staffId. + */ + getSummaryByDoctor( + staffId: number, + from?: Date | null, + to?: Date | null + ): Promise<{ + totalPatients: number; + totalOutstanding: number; + totalCollected: number; + patientsWithBalance: number; + }>; } /** Return ISO literal for inclusive start-of-day (UTC midnight) */ @@ -500,13 +516,21 @@ export const paymentsReportsStorage: IPaymentsReportsStorage = { } }, - async getBalancesAndSummaryByDoctor( + /** + * Return just the paged balances for a doctor (same logic/filters as previous single-query approach) + */ + async getPatientsBalancesByDoctor( staffId: number, limit = 25, cursorToken?: string | null, from?: Date | null, to?: Date | null - ): Promise { + ): Promise<{ + balances: PatientBalanceRow[]; + totalCount: number; + nextCursor: string | null; + hasMore: boolean; + }> { if (!Number.isFinite(Number(staffId)) || Number(staffId) <= 0) { throw new Error("Invalid staffId"); } @@ -526,8 +550,8 @@ export const paymentsReportsStorage: IPaymentsReportsStorage = { const hasTo = to !== undefined && to !== null; // Use inclusive start-of-day for 'from' and exclusive start-of-next-day for 'to' - const fromStart = isoStartOfDayLiteral(from); // 'YYYY-MM-DDT00:00:00.000Z' - const toNextStart = isoStartOfNextDayLiteral(to); // 'YYYY-MM-DDT00:00:00.000Z' of next day + const fromStart = isoStartOfDayLiteral(from); + const toNextStart = isoStartOfNextDayLiteral(to); // Filter payments by createdAt (time window) when provided const paymentTimeFilter = @@ -539,9 +563,7 @@ export const paymentsReportsStorage: IPaymentsReportsStorage = { ? `AND pay."createdAt" <= ${toNextStart}` : ""; - // Keyset predicate must use columns present in the 'patients' CTE rows (alias p). - // We'll compare p.last_payment_date, p.patient_created_at and p.id - + // Keyset predicate for paging (same semantics as before) let pageKeysetPredicate = ""; if (effectiveCursor) { const lp = effectiveCursor.lastPaymentDate @@ -550,90 +572,77 @@ export const paymentsReportsStorage: IPaymentsReportsStorage = { const id = Number(effectiveCursor.lastPatientId); pageKeysetPredicate = `AND ( - ( ${lp} IS NOT NULL AND ( - (p.last_payment_date IS NOT NULL AND p.last_payment_date < ${lp}) - OR (p.last_payment_date IS NOT NULL AND p.last_payment_date = ${lp} AND p.id < ${id}) - ) - ) - OR - ( ${lp} IS NULL AND ( - p.last_payment_date IS NOT NULL - OR (p.last_payment_date IS NULL AND p.id < ${id}) - ) - ) - )`; + ( ${lp} IS NOT NULL AND ( + (p.last_payment_date IS NOT NULL AND p.last_payment_date < ${lp}) + OR (p.last_payment_date IS NOT NULL AND p.last_payment_date = ${lp} AND p.id < ${id}) + ) + ) + OR + ( ${lp} IS NULL AND ( + p.last_payment_date IS NOT NULL + OR (p.last_payment_date IS NULL AND p.id < ${id}) + ) + ) + )`; } - console.debug( - "[getBalancesAndSummaryByDoctor] decodedCursor:", - effectiveCursor - ); - console.debug( - "[getBalancesAndSummaryByDoctor] pageKeysetPredicate:", - pageKeysetPredicate - ); - - // When a time window is provided, we want the patient rows to be restricted to patients who have - // payments in that window (and those payments must be linked to claims with this staffId). - // When no time-window provided, we want all patients who have appointments with this staff. - // We'll implement that by conditionally INNER JOINing payments_agg in the patients listing when window exists. const paymentsJoinForPatients = hasFrom || hasTo ? "INNER JOIN payments_agg pa ON pa.patient_id = p.id" : "LEFT JOIN payments_agg pa ON pa.patient_id = p.id"; - const sql = ` - WITH - -- patients that have at least one appointment with this staff (roster) - staff_patients AS ( - SELECT DISTINCT "patientId" AS patient_id - FROM "Appointment" - WHERE "staffId" = ${Number(staffId)} - ), + // Common CTEs (identical to previous single-query approach) + const commonCtes = ` + WITH + staff_patients AS ( + SELECT DISTINCT "patientId" AS patient_id + FROM "Appointment" + WHERE "staffId" = ${Number(staffId)} + ), - -- aggregate payments, but only payments that link to Claims with this staffId, - -- and additionally restricted by the optional time window (paymentTimeFilter) - payments_agg AS ( - SELECT - pay."patientId" AS patient_id, - SUM(pay."totalBilled")::numeric(14,2) AS total_charges, - SUM(pay."totalPaid")::numeric(14,2) AS total_paid, - SUM(pay."totalAdjusted")::numeric(14,2) AS total_adjusted, - MAX(pay."createdAt") AS last_payment_date - FROM "Payment" pay - JOIN "Claim" c ON pay."claimId" = c.id - WHERE c."staffId" = ${Number(staffId)} - ${paymentTimeFilter} - GROUP BY pay."patientId" - ), + payments_agg AS ( + SELECT + pay."patientId" AS patient_id, + SUM(pay."totalBilled")::numeric(14,2) AS total_charges, + SUM(pay."totalPaid")::numeric(14,2) AS total_paid, + SUM(pay."totalAdjusted")::numeric(14,2) AS total_adjusted, + MAX(pay."createdAt") AS last_payment_date + FROM "Payment" pay + JOIN "Claim" c ON pay."claimId" = c.id + WHERE c."staffId" = ${Number(staffId)} + ${paymentTimeFilter} + GROUP BY pay."patientId" + ), - last_appointments AS ( - SELECT "patientId" AS patient_id, MAX("date") AS last_appointment_date - FROM "Appointment" - GROUP BY "patientId" - ), + last_appointments AS ( + SELECT "patientId" AS patient_id, MAX("date") AS last_appointment_date + FROM "Appointment" + GROUP BY "patientId" + ), - -- Build the patient rows. If window provided we INNER JOIN payments_agg (so only patients with payments in window) - patients AS ( - SELECT - p.id, - p."firstName" AS first_name, - p."lastName" AS last_name, - COALESCE(pa.total_charges, 0)::numeric(14,2) AS total_charges, - COALESCE(pa.total_paid, 0)::numeric(14,2) AS total_paid, - COALESCE(pa.total_adjusted, 0)::numeric(14,2) AS total_adjusted, - (COALESCE(pa.total_charges,0) - COALESCE(pa.total_paid,0) - COALESCE(pa.total_adjusted,0))::numeric(14,2) AS current_balance, - pa.last_payment_date, - la.last_appointment_date - FROM "Patient" p - INNER JOIN staff_patients sp ON sp.patient_id = p.id - ${paymentsJoinForPatients} - LEFT JOIN last_appointments la ON la.patient_id = p.id - ) + patients AS ( + SELECT + p.id, + p."firstName" AS first_name, + p."lastName" AS last_name, + COALESCE(pa.total_charges, 0)::numeric(14,2) AS total_charges, + COALESCE(pa.total_paid, 0)::numeric(14,2) AS total_paid, + COALESCE(pa.total_adjusted, 0)::numeric(14,2) AS total_adjusted, + (COALESCE(pa.total_charges,0) - COALESCE(pa.total_paid,0) - COALESCE(pa.total_adjusted,0))::numeric(14,2) AS current_balance, + pa.last_payment_date, + la.last_appointment_date + FROM "Patient" p + INNER JOIN staff_patients sp ON sp.patient_id = p.id + ${paymentsJoinForPatients} + LEFT JOIN last_appointments la ON la.patient_id = p.id + ) + `; - SELECT - -- page rows as JSON array - (SELECT COALESCE(json_agg(row_to_json(t)), '[]'::json) FROM ( + // Query A: fetch the page of patient rows as JSON array + const balancesQuery = ` + ${commonCtes} + + SELECT COALESCE(json_agg(row_to_json(t)), '[]'::json) AS balances_json FROM ( SELECT p.id AS "patientId", p.first_name AS "firstName", @@ -649,57 +658,31 @@ export const paymentsReportsStorage: IPaymentsReportsStorage = { ${pageKeysetPredicate} ORDER BY p.last_payment_date DESC NULLS LAST, p.id DESC LIMIT ${safeLimit} - ) t) AS balances_json, + ) t; + `; - -- total_count: when window provided, count distinct patients that have payments in the window (payments_agg), - -- otherwise count all staff_patients - (CASE WHEN ${hasFrom || hasTo ? "true" : "false"} THEN - (SELECT COUNT(DISTINCT pa.patient_id) FROM payments_agg pa) - ELSE - (SELECT COUNT(*)::int FROM staff_patients) - END) AS total_count, + // Query Count: total_count (same logic as previous combined query's CASE) + const countQuery = ` + ${commonCtes} - -- summary: computed from payments_agg (already filtered by staffId and time window) - ( - SELECT json_build_object( - 'totalPatients', COALESCE(COUNT(DISTINCT pa.patient_id),0), - 'totalOutstanding', COALESCE(SUM(COALESCE(pa.total_charges,0) - COALESCE(pa.total_paid,0) - COALESCE(pa.total_adjusted,0)),0)::text, - 'totalCollected', COALESCE(SUM(COALESCE(pa.total_paid,0)),0)::text, - 'patientsWithBalance', COALESCE(SUM(CASE WHEN (COALESCE(pa.total_charges,0) - COALESCE(pa.total_paid,0) - COALESCE(pa.total_adjusted,0)) > 0 THEN 1 ELSE 0 END),0) - ) - FROM payments_agg pa - ) AS summary_json - ; - `; + SELECT + (CASE WHEN ${hasFrom || hasTo ? "true" : "false"} THEN + (SELECT COUNT(DISTINCT pa.patient_id) FROM payments_agg pa) + ELSE + (SELECT COUNT(*)::int FROM staff_patients) + END) AS total_count; + `; - const rawRows = (await prisma.$queryRawUnsafe(sql)) as Array<{ - balances_json?: any; - total_count?: number; - summary_json?: any; - }>; + // Execute balancesQuery + const balancesRawRows = (await prisma.$queryRawUnsafe( + balancesQuery + )) as Array<{ balances_json?: any }>; - const firstRow = - Array.isArray(rawRows) && rawRows.length > 0 ? rawRows[0] : undefined; + const balancesJson = (balancesRawRows?.[0]?.balances_json as any) ?? []; - if (!firstRow) { - return { - balances: [], - totalCount: 0, - nextCursor: null, - hasMore: false, - summary: { - totalPatients: 0, - totalOutstanding: 0, - totalCollected: 0, - patientsWithBalance: 0, - }, - }; - } + const balancesArr = Array.isArray(balancesJson) ? balancesJson : []; - const balancesRaw = Array.isArray(firstRow.balances_json) - ? firstRow.balances_json - : []; - const balances: PatientBalanceRow[] = balancesRaw.map((r: any) => ({ + const balances: PatientBalanceRow[] = balancesArr.map((r: any) => ({ patientId: Number(r.patientId), firstName: r.firstName ?? null, lastName: r.lastName ?? null, @@ -715,6 +698,7 @@ export const paymentsReportsStorage: IPaymentsReportsStorage = { : null, })); + // Determine hasMore and nextCursor const hasMore = balances.length === safeLimit; let nextCursor: string | null = null; if (hasMore) { @@ -728,20 +712,86 @@ export const paymentsReportsStorage: IPaymentsReportsStorage = { } } - const summaryRaw = firstRow.summary_json ?? {}; - const summary = { + // Execute countQuery + const countRows = (await prisma.$queryRawUnsafe(countQuery)) as Array<{ + total_count?: number; + }>; + const totalCount = Number(countRows?.[0]?.total_count ?? 0); + + return { + balances, + totalCount, + nextCursor, + hasMore, + }; + }, + + /** + * Return only the summary data for a doctor (same logic/filters as previous single-query approach) + */ + async getSummaryByDoctor( + staffId: number, + from?: Date | null, + to?: Date | null + ): Promise<{ + totalPatients: number; + totalOutstanding: number; + totalCollected: number; + patientsWithBalance: number; + }> { + if (!Number.isFinite(Number(staffId)) || Number(staffId) <= 0) { + throw new Error("Invalid staffId"); + } + + const hasFrom = from !== undefined && from !== null; + const hasTo = to !== undefined && to !== null; + + const fromStart = isoStartOfDayLiteral(from); + const toNextStart = isoStartOfNextDayLiteral(to); + + const paymentTimeFilter = + hasFrom && hasTo + ? `AND pay."createdAt" >= ${fromStart} AND pay."createdAt" <= ${toNextStart}` + : hasFrom + ? `AND pay."createdAt" >= ${fromStart}` + : hasTo + ? `AND pay."createdAt" <= ${toNextStart}` + : ""; + + const summaryQuery = ` + WITH + payments_agg AS ( + SELECT + pay."patientId" AS patient_id, + SUM(pay."totalBilled")::numeric(14,2) AS total_charges, + SUM(pay."totalPaid")::numeric(14,2) AS total_paid, + SUM(pay."totalAdjusted")::numeric(14,2) AS total_adjusted + FROM "Payment" pay + JOIN "Claim" c ON pay."claimId" = c.id + WHERE c."staffId" = ${Number(staffId)} + ${paymentTimeFilter} + GROUP BY pay."patientId" + ) + SELECT json_build_object( + 'totalPatients', COALESCE(COUNT(DISTINCT pa.patient_id),0), + 'totalOutstanding', COALESCE(SUM(COALESCE(pa.total_charges,0) - COALESCE(pa.total_paid,0) - COALESCE(pa.total_adjusted,0)),0)::text, + 'totalCollected', COALESCE(SUM(COALESCE(pa.total_paid,0)),0)::text, + 'patientsWithBalance', COALESCE(SUM(CASE WHEN (COALESCE(pa.total_charges,0) - COALESCE(pa.total_paid,0) - COALESCE(pa.total_adjusted,0)) > 0 THEN 1 ELSE 0 END),0) + ) AS summary_json + FROM payments_agg pa; + `; + + const rows = (await prisma.$queryRawUnsafe(summaryQuery)) as Array<{ + summary_json?: any; + }>; + + const summaryRaw = (rows?.[0]?.summary_json as any) ?? {}; + + return { totalPatients: Number(summaryRaw.totalPatients ?? 0), totalOutstanding: Number(summaryRaw.totalOutstanding ?? 0), totalCollected: Number(summaryRaw.totalCollected ?? 0), patientsWithBalance: Number(summaryRaw.patientsWithBalance ?? 0), }; - - return { - balances, - totalCount: Number(firstRow.total_count ?? 0), - nextCursor, - hasMore, - summary, - }; }, }; diff --git a/apps/Frontend/src/components/reports/collections-by-doctor-report.tsx b/apps/Frontend/src/components/reports/collections-by-doctor-report.tsx index 66ef9a6..f51ada2 100644 --- a/apps/Frontend/src/components/reports/collections-by-doctor-report.tsx +++ b/apps/Frontend/src/components/reports/collections-by-doctor-report.tsx @@ -11,7 +11,6 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { DoctorBalancesAndSummary } from "@repo/db/types"; import ExportReportButton from "./export-button"; type StaffOption = { id: number; name: string }; @@ -54,16 +53,24 @@ export default function CollectionsByDoctorReport({ staleTime: 60_000, }); - // query balances+summary by doctor + // --- balances query (paged rows) --- const { - data: collectionData, - isLoading: isLoadingRows, - isError: isErrorRows, - refetch, - isFetching, - } = useQuery({ + data: balancesResult, + isLoading: isLoadingBalances, + isError: isErrorBalances, + refetch: refetchBalances, + isFetching: isFetchingBalances, + } = useQuery< + { + balances: any[]; + totalCount: number; + nextCursor: string | null; + hasMore: boolean; + }, + Error + >({ queryKey: [ - "collections-by-doctor-rows", + "collections-by-doctor-balances", staffId, currentCursor, perPage, @@ -80,24 +87,66 @@ export default function CollectionsByDoctorReport({ const res = await apiRequest( "GET", - `/api/payments-reports/by-doctor?${params.toString()}` + `/api/payments-reports/by-doctor/balances?${params.toString()}` ); if (!res.ok) { const b = await res .json() - .catch(() => ({ message: "Failed to load collections" })); - throw new Error(b.message || "Failed to load collections"); + .catch(() => ({ message: "Failed to load collections balances" })); + throw new Error(b.message || "Failed to load collections balances"); } return res.json(); }, - enabled: Boolean(staffId), // only load when a doctor is selected + enabled: Boolean(staffId), }); - const balances = collectionData?.balances ?? []; - const totalCount = collectionData?.totalCount ?? undefined; - const serverNextCursor = collectionData?.nextCursor ?? null; - const hasMore = collectionData?.hasMore ?? false; - const summary = collectionData?.summary ?? null; + // --- summary query (staff summary) --- + const { + data: summaryData, + isLoading: isLoadingSummary, + isError: isErrorSummary, + refetch: refetchSummary, + isFetching: isFetchingSummary, + } = useQuery< + { + totalPatients: number; + totalOutstanding: number | string; + totalCollected: number | string; + patientsWithBalance: number; + }, + Error + >({ + queryKey: ["collections-by-doctor-summary", staffId, startDate, endDate], + queryFn: async () => { + const params = new URLSearchParams(); + if (staffId) params.set("staffId", staffId); + if (startDate) params.set("from", startDate); + if (endDate) params.set("to", endDate); + + const res = await apiRequest( + "GET", + `/api/payments-reports/by-doctor/summary?${params.toString()}` + ); + if (!res.ok) { + const b = await res + .json() + .catch(() => ({ message: "Failed to load collections summary" })); + throw new Error(b.message || "Failed to load collections summary"); + } + return res.json(); + }, + enabled: Boolean(staffId), + }); + + const balances = balancesResult?.balances ?? []; + const totalCount = balancesResult?.totalCount ?? undefined; + const serverNextCursor = balancesResult?.nextCursor ?? null; + const hasMore = Boolean(balancesResult?.hasMore ?? false); + const summary = summaryData ?? null; + + const isLoadingRows = isLoadingBalances; + const isErrorRows = isErrorBalances; + const isFetching = isFetchingBalances || isFetchingSummary; // Reset pagination when filters change useEffect(() => { @@ -117,7 +166,7 @@ export default function CollectionsByDoctorReport({ if (serverNextCursor) { setCursorStack((s) => [...s, serverNextCursor]); setCursorIndex((i) => i + 1); - // No manual refetch — the queryKey depends on currentCursor and React Query will fetch automatically. + // React Query will fetch automatically because queryKey includes currentCursor } } else { setCursorIndex((i) => i + 1);