From 4bac4f94e05d2984f183f129a1a97cbfa42131e3 Mon Sep 17 00:00:00 2001 From: Potenz Date: Wed, 22 Oct 2025 23:33:03 +0530 Subject: [PATCH] feat(report page) - collection by doctor - v1 -base --- apps/Backend/src/routes/payments-reports.ts | 75 +- .../src/storage/payments-reports-storage.ts | 706 ++++++++++++------ .../reports/collections-by-doctor-report.tsx | 273 +++++-- .../reports/patients-balances-list.tsx | 8 +- .../reports/patients-with-balance-report.tsx | 2 +- 5 files changed, 759 insertions(+), 305 deletions(-) diff --git a/apps/Backend/src/routes/payments-reports.ts b/apps/Backend/src/routes/payments-reports.ts index d3ba7ed..b3337b4 100644 --- a/apps/Backend/src/routes/payments-reports.ts +++ b/apps/Backend/src/routes/payments-reports.ts @@ -37,7 +37,7 @@ router.get("/summary", async (req: Request, res: Response): Promise => { * - from / to (optional ISO date strings) - filter payments by createdAt in the range (inclusive) */ router.get( - "/patient-balances", + "/patients-with-balances", async (req: Request, res: Response): Promise => { try { const limit = Math.max( @@ -60,7 +60,12 @@ router.get( return res.status(400).json({ message: "Invalid 'to' date" }); } - const data = await storage.getPatientBalances(limit, cursor, from, to); + const data = await storage.getPatientsWithBalances( + limit, + cursor, + from, + to + ); // returns { balances, totalCount, nextCursor, hasMore } res.json(data); } catch (err: any) { @@ -74,4 +79,70 @@ router.get( } ); +/** + * GET /api/payments-reports/by-doctor + * Query params: + * - staffId (required) + * - limit (optional, default 25) + * - cursor (optional) + * - from/to (optional ISO date strings) - filter payments by createdAt in the range (inclusive) + */ +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" }); + } + + 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 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" }); + } + + 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", + detail: err?.message ?? String(err), + }); + } +}); + export default router; diff --git a/apps/Backend/src/storage/payments-reports-storage.ts b/apps/Backend/src/storage/payments-reports-storage.ts index a0ec727..0fff4ef 100644 --- a/apps/Backend/src/storage/payments-reports-storage.ts +++ b/apps/Backend/src/storage/payments-reports-storage.ts @@ -1,9 +1,5 @@ -// apps/Backend/src/storage/payments-reports-storage.ts import { prisma } from "@repo/db/client"; -/** - * Row returned to the client - */ export interface PatientBalanceRow { patientId: number; firstName: string | null; @@ -24,20 +20,20 @@ export interface GetPatientBalancesResult { hasMore: boolean; } -export interface IPaymentsReportsStorage { - /** - * Cursor-based pagination: - * - limit: page size - * - cursorToken: base64(JSON) token for last-seen row (or null for first page) - * - from/to: optional date range filter applied to Payment."createdAt" - */ - getPatientBalances( - limit: number, - cursorToken?: string | null, - from?: Date | null, - to?: Date | null - ): Promise; +export interface DoctorBalancesAndSummary { + balances: PatientBalanceRow[]; + totalCount: number; + nextCursor: string | null; + hasMore: boolean; + summary: { + totalPatients: number; + totalOutstanding: number; + totalCollected: number; + patientsWithBalance: number; + }; +} +export interface IPaymentsReportsStorage { // summary now returns an extra field patientsWithBalance getSummary( from?: Date | null, @@ -48,6 +44,35 @@ export interface IPaymentsReportsStorage { totalCollected: number; patientsWithBalance: number; }>; + + /** + * Cursor-based pagination: + * - limit: page size + * - cursorToken: base64(JSON) token for last-seen row (or null for first page) + * - from/to: optional date range filter applied to Payment."createdAt" + */ + getPatientsWithBalances( + limit: number, + cursorToken?: string | null, + from?: Date | null, + to?: Date | null + ): Promise; + + /** + * One-query approach: returns both page of patient balances for the staff and a summary + * + * - 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( + staffId: number, + limit: number, + cursorToken?: string | null, + from?: Date | null, + to?: Date | null + ): Promise; } /** Helper: format Date -> SQL literal 'YYYY-MM-DDTHH:mm:ss.sssZ' or null */ @@ -59,6 +84,7 @@ function fmtDateLiteral(d?: Date | null): string | null { /** Cursor helpers — base64(JSON) */ function encodeCursor(obj: { + staffId?: number; lastPaymentDate: string | null; lastPatientCreatedAt: string; // ISO string lastPatientId: number; @@ -67,6 +93,7 @@ function encodeCursor(obj: { } function decodeCursor(token?: string | null): { + staffId?: number; // optional because older cursors might not include it lastPaymentDate: string | null; lastPatientCreatedAt: string; lastPatientId: number; @@ -81,12 +108,14 @@ function decodeCursor(token?: string | null): { "lastPatientId" in parsed ) { return { + staffId: + "staffId" in parsed ? Number((parsed as any).staffId) : undefined, lastPaymentDate: - parsed.lastPaymentDate === null + (parsed as any).lastPaymentDate === null ? null - : String(parsed.lastPaymentDate), - lastPatientCreatedAt: String(parsed.lastPatientCreatedAt), - lastPatientId: Number(parsed.lastPatientId), + : String((parsed as any).lastPaymentDate), + lastPatientCreatedAt: String((parsed as any).lastPatientCreatedAt), + lastPatientId: Number((parsed as any).lastPatientId), }; } return null; @@ -96,14 +125,212 @@ function decodeCursor(token?: string | null): { } export const paymentsReportsStorage: IPaymentsReportsStorage = { + async getSummary(from?: Date | null, to?: Date | null) { + try { + const hasFrom = from !== undefined && from !== null; + const hasTo = to !== undefined && to !== null; + const fromLit = fmtDateLiteral(from); + const toLit = fmtDateLiteral(to); + + // totalPatients: distinct patients who had payments in the date range + let patientsCountSql = ""; + if (hasFrom && hasTo) { + patientsCountSql = ` + SELECT COUNT(*)::int AS cnt FROM ( + SELECT pay."patientId" AS patient_id + FROM "Payment" pay + WHERE pay."createdAt" >= ${fromLit} AND pay."createdAt" <= ${toLit} + GROUP BY pay."patientId" + ) t + `; + } else if (hasFrom) { + patientsCountSql = ` + SELECT COUNT(*)::int AS cnt FROM ( + SELECT pay."patientId" AS patient_id + FROM "Payment" pay + WHERE pay."createdAt" >= ${fromLit} + GROUP BY pay."patientId" + ) t + `; + } else if (hasTo) { + patientsCountSql = ` + SELECT COUNT(*)::int AS cnt FROM ( + SELECT pay."patientId" AS patient_id + FROM "Payment" pay + WHERE pay."createdAt" <= ${toLit} + GROUP BY pay."patientId" + ) t + `; + } else { + patientsCountSql = `SELECT COUNT(DISTINCT "patientId")::int AS cnt FROM "Payment"`; + } + const patientsCntRows = (await prisma.$queryRawUnsafe( + patientsCountSql + )) as { cnt: number }[]; + const totalPatients = patientsCntRows?.[0]?.cnt ?? 0; + + // totalOutstanding: sum of (charges - paid - adjusted) across patients, using payments in range + let outstandingSql = ""; + if (hasFrom && hasTo) { + outstandingSql = ` + SELECT COALESCE(SUM( + COALESCE(pm.total_charges,0) - COALESCE(pm.total_paid,0) - COALESCE(pm.total_adjusted,0) + ),0)::numeric(14,2) AS outstanding + FROM ( + 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 + WHERE pay."createdAt" >= ${fromLit} AND pay."createdAt" <= ${toLit} + GROUP BY pay."patientId" + ) pm + `; + } else if (hasFrom) { + outstandingSql = ` + SELECT COALESCE(SUM( + COALESCE(pm.total_charges,0) - COALESCE(pm.total_paid,0) - COALESCE(pm.total_adjusted,0) + ),0)::numeric(14,2) AS outstanding + FROM ( + 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 + WHERE pay."createdAt" >= ${fromLit} + GROUP BY pay."patientId" + ) pm + `; + } else if (hasTo) { + outstandingSql = ` + SELECT COALESCE(SUM( + COALESCE(pm.total_charges,0) - COALESCE(pm.total_paid,0) - COALESCE(pm.total_adjusted,0) + ),0)::numeric(14,2) AS outstanding + FROM ( + 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 + WHERE pay."createdAt" <= ${toLit} + GROUP BY pay."patientId" + ) pm + `; + } else { + outstandingSql = ` + SELECT COALESCE(SUM( + COALESCE(pm.total_charges,0) - COALESCE(pm.total_paid,0) - COALESCE(pm.total_adjusted,0) + ),0)::numeric(14,2) AS outstanding + FROM ( + 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 + GROUP BY pay."patientId" + ) pm + `; + } + const outstandingRows = (await prisma.$queryRawUnsafe( + outstandingSql + )) as { outstanding: string }[]; + const totalOutstanding = Number(outstandingRows?.[0]?.outstanding ?? 0); + + // totalCollected: sum(totalPaid) in the range + let collSql = ""; + if (hasFrom && hasTo) { + collSql = `SELECT COALESCE(SUM("totalPaid"),0)::numeric(14,2) AS collected FROM "Payment" WHERE "createdAt" >= ${fromLit} AND "createdAt" <= ${toLit}`; + } else if (hasFrom) { + collSql = `SELECT COALESCE(SUM("totalPaid"),0)::numeric(14,2) AS collected FROM "Payment" WHERE "createdAt" >= ${fromLit}`; + } else if (hasTo) { + collSql = `SELECT COALESCE(SUM("totalPaid"),0)::numeric(14,2) AS collected FROM "Payment" WHERE "createdAt" <= ${toLit}`; + } else { + collSql = `SELECT COALESCE(SUM("totalPaid"),0)::numeric(14,2) AS collected FROM "Payment"`; + } + const collRows = (await prisma.$queryRawUnsafe(collSql)) as { + collected: string; + }[]; + const totalCollected = Number(collRows?.[0]?.collected ?? 0); + + // NEW: patientsWithBalance: number of patients whose (charges - paid - adjusted) > 0, within the date range + let patientsWithBalanceSql = ""; + if (hasFrom && hasTo) { + patientsWithBalanceSql = ` + SELECT COUNT(*)::int AS cnt FROM ( + 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 + WHERE pay."createdAt" >= ${fromLit} AND pay."createdAt" <= ${toLit} + GROUP BY pay."patientId" + ) t + WHERE (COALESCE(t.total_charges,0) - COALESCE(t.total_paid,0) - COALESCE(t.total_adjusted,0)) > 0 + `; + } else if (hasFrom) { + patientsWithBalanceSql = ` + SELECT COUNT(*)::int AS cnt FROM ( + 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 + WHERE pay."createdAt" >= ${fromLit} + GROUP BY pay."patientId" + ) t + WHERE (COALESCE(t.total_charges,0) - COALESCE(t.total_paid,0) - COALESCE(t.total_adjusted,0)) > 0 + `; + } else if (hasTo) { + patientsWithBalanceSql = ` + SELECT COUNT(*)::int AS cnt FROM ( + 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 + WHERE pay."createdAt" <= ${toLit} + GROUP BY pay."patientId" + ) t + WHERE (COALESCE(t.total_charges,0) - COALESCE(t.total_paid,0) - COALESCE(t.total_adjusted,0)) > 0 + `; + } else { + patientsWithBalanceSql = ` + SELECT COUNT(*)::int AS cnt FROM ( + 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 + GROUP BY pay."patientId" + ) t + WHERE (COALESCE(t.total_charges,0) - COALESCE(t.total_paid,0) - COALESCE(t.total_adjusted,0)) > 0 + `; + } + const pwbRows = (await prisma.$queryRawUnsafe( + patientsWithBalanceSql + )) as { cnt: number }[]; + const patientsWithBalance = pwbRows?.[0]?.cnt ?? 0; + + return { + totalPatients, + totalOutstanding, + totalCollected, + patientsWithBalance, + }; + } catch (err) { + console.error("[paymentsReportsStorage.getSummary] error:", err); + throw err; + } + }, + /** * Returns all patients that currently have an outstanding balance (>0) * Optionally filtered by date range. */ /** - * Cursor-based getPatientBalances + * Cursor-based getPatientsWithBalances */ - async getPatientBalances( + async getPatientsWithBalances( limit = 25, cursorToken?: string | null, from?: Date | null, @@ -309,201 +536,244 @@ export const paymentsReportsStorage: IPaymentsReportsStorage = { } }, - async getSummary(from?: Date | null, to?: Date | null) { - try { - const hasFrom = from !== undefined && from !== null; - const hasTo = to !== undefined && to !== null; - const fromLit = fmtDateLiteral(from); - const toLit = fmtDateLiteral(to); - - // totalPatients: distinct patients who had payments in the date range - let patientsCountSql = ""; - if (hasFrom && hasTo) { - patientsCountSql = ` - SELECT COUNT(*)::int AS cnt FROM ( - SELECT pay."patientId" AS patient_id - FROM "Payment" pay - WHERE pay."createdAt" >= ${fromLit} AND pay."createdAt" <= ${toLit} - GROUP BY pay."patientId" - ) t - `; - } else if (hasFrom) { - patientsCountSql = ` - SELECT COUNT(*)::int AS cnt FROM ( - SELECT pay."patientId" AS patient_id - FROM "Payment" pay - WHERE pay."createdAt" >= ${fromLit} - GROUP BY pay."patientId" - ) t - `; - } else if (hasTo) { - patientsCountSql = ` - SELECT COUNT(*)::int AS cnt FROM ( - SELECT pay."patientId" AS patient_id - FROM "Payment" pay - WHERE pay."createdAt" <= ${toLit} - GROUP BY pay."patientId" - ) t - `; - } else { - patientsCountSql = `SELECT COUNT(DISTINCT "patientId")::int AS cnt FROM "Payment"`; - } - const patientsCntRows = (await prisma.$queryRawUnsafe( - patientsCountSql - )) as { cnt: number }[]; - const totalPatients = patientsCntRows?.[0]?.cnt ?? 0; - - // totalOutstanding: sum of (charges - paid - adjusted) across patients, using payments in range - let outstandingSql = ""; - if (hasFrom && hasTo) { - outstandingSql = ` - SELECT COALESCE(SUM( - COALESCE(pm.total_charges,0) - COALESCE(pm.total_paid,0) - COALESCE(pm.total_adjusted,0) - ),0)::numeric(14,2) AS outstanding - FROM ( - 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 - WHERE pay."createdAt" >= ${fromLit} AND pay."createdAt" <= ${toLit} - GROUP BY pay."patientId" - ) pm - `; - } else if (hasFrom) { - outstandingSql = ` - SELECT COALESCE(SUM( - COALESCE(pm.total_charges,0) - COALESCE(pm.total_paid,0) - COALESCE(pm.total_adjusted,0) - ),0)::numeric(14,2) AS outstanding - FROM ( - 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 - WHERE pay."createdAt" >= ${fromLit} - GROUP BY pay."patientId" - ) pm - `; - } else if (hasTo) { - outstandingSql = ` - SELECT COALESCE(SUM( - COALESCE(pm.total_charges,0) - COALESCE(pm.total_paid,0) - COALESCE(pm.total_adjusted,0) - ),0)::numeric(14,2) AS outstanding - FROM ( - 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 - WHERE pay."createdAt" <= ${toLit} - GROUP BY pay."patientId" - ) pm - `; - } else { - outstandingSql = ` - SELECT COALESCE(SUM( - COALESCE(pm.total_charges,0) - COALESCE(pm.total_paid,0) - COALESCE(pm.total_adjusted,0) - ),0)::numeric(14,2) AS outstanding - FROM ( - 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 - GROUP BY pay."patientId" - ) pm - `; - } - const outstandingRows = (await prisma.$queryRawUnsafe( - outstandingSql - )) as { outstanding: string }[]; - const totalOutstanding = Number(outstandingRows?.[0]?.outstanding ?? 0); - - // totalCollected: sum(totalPaid) in the range - let collSql = ""; - if (hasFrom && hasTo) { - collSql = `SELECT COALESCE(SUM("totalPaid"),0)::numeric(14,2) AS collected FROM "Payment" WHERE "createdAt" >= ${fromLit} AND "createdAt" <= ${toLit}`; - } else if (hasFrom) { - collSql = `SELECT COALESCE(SUM("totalPaid"),0)::numeric(14,2) AS collected FROM "Payment" WHERE "createdAt" >= ${fromLit}`; - } else if (hasTo) { - collSql = `SELECT COALESCE(SUM("totalPaid"),0)::numeric(14,2) AS collected FROM "Payment" WHERE "createdAt" <= ${toLit}`; - } else { - collSql = `SELECT COALESCE(SUM("totalPaid"),0)::numeric(14,2) AS collected FROM "Payment"`; - } - const collRows = (await prisma.$queryRawUnsafe(collSql)) as { - collected: string; - }[]; - const totalCollected = Number(collRows?.[0]?.collected ?? 0); - - // NEW: patientsWithBalance: number of patients whose (charges - paid - adjusted) > 0, within the date range - let patientsWithBalanceSql = ""; - if (hasFrom && hasTo) { - patientsWithBalanceSql = ` - SELECT COUNT(*)::int AS cnt FROM ( - 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 - WHERE pay."createdAt" >= ${fromLit} AND pay."createdAt" <= ${toLit} - GROUP BY pay."patientId" - ) t - WHERE (COALESCE(t.total_charges,0) - COALESCE(t.total_paid,0) - COALESCE(t.total_adjusted,0)) > 0 - `; - } else if (hasFrom) { - patientsWithBalanceSql = ` - SELECT COUNT(*)::int AS cnt FROM ( - 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 - WHERE pay."createdAt" >= ${fromLit} - GROUP BY pay."patientId" - ) t - WHERE (COALESCE(t.total_charges,0) - COALESCE(t.total_paid,0) - COALESCE(t.total_adjusted,0)) > 0 - `; - } else if (hasTo) { - patientsWithBalanceSql = ` - SELECT COUNT(*)::int AS cnt FROM ( - 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 - WHERE pay."createdAt" <= ${toLit} - GROUP BY pay."patientId" - ) t - WHERE (COALESCE(t.total_charges,0) - COALESCE(t.total_paid,0) - COALESCE(t.total_adjusted,0)) > 0 - `; - } else { - patientsWithBalanceSql = ` - SELECT COUNT(*)::int AS cnt FROM ( - 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 - GROUP BY pay."patientId" - ) t - WHERE (COALESCE(t.total_charges,0) - COALESCE(t.total_paid,0) - COALESCE(t.total_adjusted,0)) > 0 - `; - } - const pwbRows = (await prisma.$queryRawUnsafe( - patientsWithBalanceSql - )) as { cnt: number }[]; - const patientsWithBalance = pwbRows?.[0]?.cnt ?? 0; - - return { - totalPatients, - totalOutstanding, - totalCollected, - patientsWithBalance, - }; - } catch (err) { - console.error("[paymentsReportsStorage.getSummary] error:", err); - throw err; + async getBalancesAndSummaryByDoctor( + staffId: number, + limit = 25, + cursorToken?: string | null, + from?: Date | null, + to?: Date | null + ): Promise { + if (!Number.isFinite(Number(staffId)) || Number(staffId) <= 0) { + throw new Error("Invalid staffId"); } + + const safeLimit = Math.max(1, Math.min(200, Number(limit) || 25)); + const decoded = decodeCursor(cursorToken); + + // Accept older cursors that didn't include staffId; if cursor has staffId and it doesn't match, ignore. + const effectiveCursor = + decoded && + typeof decoded.staffId === "number" && + decoded.staffId === Number(staffId) + ? decoded + : decoded && typeof decoded.staffId === "undefined" + ? decoded + : null; + + const hasFrom = from !== undefined && from !== null; + const hasTo = to !== undefined && to !== null; + const fromLit = fmtDateLiteral(from); + const toLit = fmtDateLiteral(to); + + // Filter payments by createdAt (time window) when provided + const paymentTimeFilter = + hasFrom && hasTo + ? `AND pay."createdAt" >= ${fromLit} AND pay."createdAt" <= ${toLit}` + : hasFrom + ? `AND pay."createdAt" >= ${fromLit}` + : hasTo + ? `AND pay."createdAt" <= ${toLit}` + : ""; + + // 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 + let pageKeysetPredicate = ""; + if (effectiveCursor) { + const lp = effectiveCursor.lastPaymentDate + ? `'${effectiveCursor.lastPaymentDate}'` + : "NULL"; + const lc = `'${effectiveCursor.lastPatientCreatedAt}'`; + const id = Number(effectiveCursor.lastPatientId); + + pageKeysetPredicate = `AND ( + (p.last_payment_date IS NOT NULL AND ${lp} IS NOT NULL AND ( + p.last_payment_date < ${lp} + OR (p.last_payment_date = ${lp} AND p.patient_created_at < ${lc}) + OR (p.last_payment_date = ${lp} AND p.patient_created_at = ${lc} AND p.id < ${id}) + )) + OR (p.last_payment_date IS NULL AND ${lp} IS NULL AND ( + p.patient_created_at < ${lc} + OR (p.patient_created_at = ${lc} AND p.id < ${id}) + )) + )`; + } + + // 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)} + ), + + -- 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" + ), + + 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, + p."createdAt" AS patient_created_at + 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 ( + SELECT + p.id AS "patientId", + p.first_name AS "firstName", + p.last_name AS "lastName", + p.total_charges::text AS "totalCharges", + p.total_paid::text AS "totalPaid", + p.total_adjusted::text AS "totalAdjusted", + p.current_balance::text AS "currentBalance", + p.last_payment_date AS "lastPaymentDate", + p.last_appointment_date AS "lastAppointmentDate", + p.patient_created_at AS "patientCreatedAt" + FROM patients p + WHERE 1=1 + ${pageKeysetPredicate} + ORDER BY p.last_payment_date DESC NULLS LAST, p.patient_created_at DESC, p.id DESC + LIMIT ${safeLimit} + ) t) AS balances_json, + + -- 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, + + -- 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 + ; + `; + + const rawRows = (await prisma.$queryRawUnsafe(sql)) as Array<{ + balances_json?: any; + total_count?: number; + summary_json?: any; + }>; + + const firstRow = + Array.isArray(rawRows) && rawRows.length > 0 ? rawRows[0] : undefined; + + if (!firstRow) { + return { + balances: [], + totalCount: 0, + nextCursor: null, + hasMore: false, + summary: { + totalPatients: 0, + totalOutstanding: 0, + totalCollected: 0, + patientsWithBalance: 0, + }, + }; + } + + const balancesRaw = Array.isArray(firstRow.balances_json) + ? firstRow.balances_json + : []; + const balances: PatientBalanceRow[] = balancesRaw.map((r: any) => ({ + patientId: Number(r.patientId), + firstName: r.firstName ?? null, + lastName: r.lastName ?? null, + totalCharges: Number(r.totalCharges ?? 0), + totalPayments: Number(r.totalPaid ?? 0), + totalAdjusted: Number(r.totalAdjusted ?? 0), + currentBalance: Number(r.currentBalance ?? 0), + lastPaymentDate: r.lastPaymentDate + ? new Date(r.lastPaymentDate).toISOString() + : null, + lastAppointmentDate: r.lastAppointmentDate + ? new Date(r.lastAppointmentDate).toISOString() + : null, + patientCreatedAt: r.patientCreatedAt + ? new Date(r.patientCreatedAt).toISOString() + : null, + })); + + const hasMore = balances.length === safeLimit; + let nextCursor: string | null = null; + if (hasMore) { + const last = balances[balances.length - 1]; + if (last) { + nextCursor = encodeCursor({ + staffId: Number(staffId), + lastPaymentDate: last.lastPaymentDate, + lastPatientCreatedAt: + last.patientCreatedAt ?? new Date().toISOString(), + lastPatientId: Number(last.patientId), + }); + } + } + + const summaryRaw = firstRow.summary_json ?? {}; + const summary = { + 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 dc9eee8..d96f9c9 100644 --- a/apps/Frontend/src/components/reports/collections-by-doctor-report.tsx +++ b/apps/Frontend/src/components/reports/collections-by-doctor-report.tsx @@ -2,24 +2,51 @@ import React, { useCallback, useEffect, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { apiRequest } from "@/lib/queryClient"; import PatientsBalancesList, { GenericRow } from "./patients-balances-list"; +import { Card, CardContent } from "@/components/ui/card"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; -type DoctorOption = { id: string; name: string }; -type DoctorCollectionRow = { - doctorId: string; - doctorName: string; - totalCollected?: number; - totalCharges?: number; - totalPayments?: number; - currentBalance?: number; +type StaffOption = { id: number; name: string }; + +type BalanceRow = { + patientId: number; + firstName: string | null; + lastName: string | null; + totalCharges: number | string; + totalPaid: number | string; + totalAdjusted: number | string; + currentBalance: number | string; + lastPaymentDate: string | null; + lastAppointmentDate: string | null; + patientCreatedAt?: string | null; }; type CollectionsResp = { - rows: DoctorCollectionRow[]; + balances: BalanceRow[]; totalCount?: number; nextCursor?: string | null; hasMore?: boolean; + summary?: { + totalPatients?: number; + totalOutstanding?: number; + totalCollected?: number; + patientsWithBalance?: number; + }; }; +function fmtCurrency(v: number) { + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + }).format(v); +} + export default function CollectionsByDoctorReport({ startDate, endDate, @@ -27,41 +54,41 @@ export default function CollectionsByDoctorReport({ startDate: string; endDate: string; }) { - const [doctorId, setDoctorId] = useState(""); + const [staffId, setStaffId] = useState(""); - // pagination (cursor) state const perPage = 10; const [cursorStack, setCursorStack] = useState<(string | null)[]>([null]); const [cursorIndex, setCursorIndex] = useState(0); const currentCursor = cursorStack[cursorIndex] ?? null; - const pageIndex = cursorIndex + 1; // 1-based for UI + const pageIndex = cursorIndex + 1; - // load doctors for selector - const { data: doctors } = useQuery({ - queryKey: ["doctors"], + // load staffs list for selector + const { data: staffs } = useQuery({ + queryKey: ["staffs"], queryFn: async () => { - const res = await apiRequest("GET", "/api/doctors"); + const res = await apiRequest("GET", "/api/staffs"); if (!res.ok) { const b = await res .json() - .catch(() => ({ message: "Failed to load doctors" })); - throw new Error(b.message || "Failed to load doctors"); + .catch(() => ({ message: "Failed to load staffs" })); + throw new Error(b.message || "Failed to load staffs"); } return res.json(); }, staleTime: 60_000, }); - // rows (collections by doctor) - cursor-based request + // query balances+summary by doctor const { data: collectionData, isLoading: isLoadingRows, isError: isErrorRows, refetch, + isFetching, } = useQuery({ queryKey: [ "collections-by-doctor-rows", - doctorId, + staffId, currentCursor, perPage, startDate, @@ -71,13 +98,13 @@ export default function CollectionsByDoctorReport({ const params = new URLSearchParams(); params.set("limit", String(perPage)); if (currentCursor) params.set("cursor", currentCursor); - if (doctorId) params.set("doctorId", doctorId); + 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/collections-by-doctor?${params.toString()}` + `/api/payments-reports/by-doctor?${params.toString()}` ); if (!res.ok) { const b = await res @@ -87,95 +114,177 @@ export default function CollectionsByDoctorReport({ } return res.json(); }, - enabled: true, + enabled: Boolean(staffId), // only load when a doctor is selected staleTime: 30_000, }); - // derived pagination info - const rows = collectionData?.rows ?? []; + const balances = collectionData?.balances ?? []; const totalCount = collectionData?.totalCount ?? undefined; - const nextCursor = collectionData?.nextCursor ?? null; + const serverNextCursor = collectionData?.nextCursor ?? null; const hasMore = collectionData?.hasMore ?? false; + const summary = collectionData?.summary ?? null; - // reset cursor when filters change (doctor/date) + // Reset pagination when filters change useEffect(() => { setCursorStack([null]); setCursorIndex(0); - refetch(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [doctorId, startDate, endDate]); - - const handleNext = useCallback(() => { - const idx = cursorIndex; - const isLastKnown = idx === cursorStack.length - 1; - if (isLastKnown) { - if (nextCursor) { - setCursorStack((s) => [...s, nextCursor]); - setCursorIndex((i) => i + 1); - } - } else { - setCursorIndex((i) => i + 1); - } - }, [cursorIndex, cursorStack.length, nextCursor]); + }, [staffId, startDate, endDate]); const handlePrev = useCallback(() => { setCursorIndex((i) => Math.max(0, i - 1)); }, []); - // Map doctor rows into GenericRow (consistent) - const mapDoctorToGeneric = (r: DoctorCollectionRow): GenericRow => { + const handleNext = useCallback(() => { + const idx = cursorIndex; + const isLastKnown = idx === cursorStack.length - 1; + + if (isLastKnown) { + if (serverNextCursor) { + setCursorStack((s) => [...s, serverNextCursor]); + setCursorIndex((i) => i + 1); + // No manual refetch — the queryKey depends on currentCursor and React Query will fetch automatically. + } + } else { + setCursorIndex((i) => i + 1); + } + }, [cursorIndex, cursorStack.length, serverNextCursor]); + + // Map server rows to GenericRow + const genericRows: GenericRow[] = balances.map((r) => { const totalCharges = Number(r.totalCharges ?? 0); - const totalPayments = Number(r.totalCollected ?? r.totalPayments ?? 0); + const totalPayments = Number(r.totalPaid ?? 0); + const currentBalance = Number(r.currentBalance ?? 0); + const name = `${r.firstName ?? ""} ${r.lastName ?? ""}`.trim() || "Unknown"; + return { - id: r.doctorId, - name: r.doctorName, - currentBalance: 0, + id: String(r.patientId), + name, + currentBalance, totalCharges, totalPayments, }; - }; - - const genericRows: GenericRow[] = rows.map(mapDoctorToGeneric); + }); return (
- setStaffId(v)} > - - {doctors?.map((d) => ( - - ))} - + + + + + + + {staffs?.map((s) => ( + + {s.name} + + ))} + + +
- 0} - hasNext={hasMore} - /> + {/* Summary card (time-window based) */} + {staffId && ( +
+ + +
+
+

+ Doctor summary +

+

+ Data covers the selected time frame +

+
+
+ +
+
+
+ {summary ? Number(summary.totalPatients ?? 0) : "—"} +
+

+ Total Patients (in window) +

+
+ +
+
+ {summary ? Number(summary.patientsWithBalance ?? 0) : "—"} +
+

With Balance

+
+ +
+
+ {summary + ? Math.max( + 0, + Number(summary.totalPatients ?? 0) - + Number(summary.patientsWithBalance ?? 0) + ) + : "—"} +
+

Zero Balance

+
+ +
+
+ {summary + ? fmtCurrency(Number(summary.totalOutstanding ?? 0)) + : "—"} +
+

Outstanding

+
+ +
+
+ {summary + ? fmtCurrency(Number(summary.totalCollected ?? 0)) + : "—"} +
+

Collected

+
+
+
+
+
+ )} + + {/* List (shows all patients under doctor but per-row totals are time-filtered) */} + {!staffId ? ( +
+ Please select a doctor to load collections. +
+ ) : ( + 0} + hasNext={hasMore} + /> + )}
); } diff --git a/apps/Frontend/src/components/reports/patients-balances-list.tsx b/apps/Frontend/src/components/reports/patients-balances-list.tsx index cfebc0e..669c283 100644 --- a/apps/Frontend/src/components/reports/patients-balances-list.tsx +++ b/apps/Frontend/src/components/reports/patients-balances-list.tsx @@ -90,7 +90,7 @@ export default function PatientsBalancesList({ ) : ( rows.map((r) => ( -
+

{r.name}

@@ -98,7 +98,11 @@ export default function PatientsBalancesList({
-
+
0 ? "text-red-600" : "text-green-600" + }`} + > {fmt(r.currentBalance)}
diff --git a/apps/Frontend/src/components/reports/patients-with-balance-report.tsx b/apps/Frontend/src/components/reports/patients-with-balance-report.tsx index e2bec2f..0b000da 100644 --- a/apps/Frontend/src/components/reports/patients-with-balance-report.tsx +++ b/apps/Frontend/src/components/reports/patients-with-balance-report.tsx @@ -40,7 +40,7 @@ export default function PatientsWithBalanceReport({ if (endDate) params.set("to", endDate); const res = await apiRequest( "GET", - `/api/payments-reports/patient-balances?${params.toString()}` + `/api/payments-reports/patients-with-balances?${params.toString()}` ); if (!res.ok) { const body = await res