From c9d08028a90e8bcbd8762773e1db1d155949d66e Mon Sep 17 00:00:00 2001 From: Gitead Date: Sun, 28 Jun 2026 20:55:41 -0400 Subject: [PATCH] feat: teach AI classifier to recognize "comp" and tooth surface notation Add "comp" as alias for composite in CDT lookup. Update classifier prompt with explicit examples for "claim comp #8 ml for lisa today" and dental surface letter definitions (M/D/L/F/B/O/V/I) so the LLM correctly treats #tooth+surfaces as composite fillings, not insurance. Co-Authored-By: Claude Sonnet 4.6 --- apps/Backend/src/ai/cdt-lookup.ts | 1 + apps/Backend/src/ai/internal-chat-graph.ts | 13 +- apps/Backend/src/routes/payments-reports.ts | 45 +++ .../src/storage/payments-reports-storage.ts | 285 ++++++++++++++---- .../reports/patients-no-balance-report.tsx | 156 ++++++++++ .../src/components/reports/summary-cards.tsx | 102 +++++-- apps/Frontend/src/pages/reports-page.tsx | 7 +- .../.prisma-zod-generator-manifest.json | 2 +- 8 files changed, 519 insertions(+), 92 deletions(-) create mode 100644 apps/Frontend/src/components/reports/patients-no-balance-report.tsx diff --git a/apps/Backend/src/ai/cdt-lookup.ts b/apps/Backend/src/ai/cdt-lookup.ts index 77e74a39..b4998c48 100644 --- a/apps/Backend/src/ai/cdt-lookup.ts +++ b/apps/Backend/src/ai/cdt-lookup.ts @@ -88,6 +88,7 @@ const ALIAS_MAP: Record = { // Fillings / restorations "filling": "resin", "composite": "resin", + "comp": "resin", "amalgam": "amalgam", "core buildup": "core buildup", "core bu": "core buildup", diff --git a/apps/Backend/src/ai/internal-chat-graph.ts b/apps/Backend/src/ai/internal-chat-graph.ts index 746e9d8c..1f797a54 100644 --- a/apps/Backend/src/ai/internal-chat-graph.ts +++ b/apps/Backend/src/ai/internal-chat-graph.ts @@ -114,6 +114,9 @@ Intents: e.g. "claim D0120 and D1110 for John Smith today" e.g. "bill adult cleaning for Maria on 05/15/2026" e.g. "claim perio exam, 2BW for John Smith" + e.g. "claim #8 ml for lisa" → procedureNames: ["#8 ml"], patientName: "lisa" (tooth+surface = composite filling, NOT insurance) + e.g. "claim comp #8 ml for lisa today" → procedureNames: ["comp #8 ml"], patientName: "lisa", appointmentDate: today + "comp" is short for "composite" — when followed by #tooth+surfaces, it is a composite filling Use this when no eligibility check is requested — just billing/claiming services Always extract appointmentDate when a date or "today" is mentioned - preauth : submit a pre-authorization request for procedures @@ -137,8 +140,14 @@ Rules: do NOT include it in procedureNames. It refers to a file attachment, not a billable procedure. Only include actual clinical procedures in procedureNames. - For composite fillings with a tooth number, preserve the EXACT notation including tooth# and surfaces: - e.g. "composite #29 O", "#8 MO", "composite #11 MOD", "# 10 DL", "# 11 ML" — keep the #number and surface letters together as one entry - Note: "# 10 DL" and "composite on # 10 DL" are the same — preserve the space-after-# as-is + e.g. "composite #29 O", "#8 MO", "composite #11 MOD", "# 10 DL", "# 11 ML", "#8 ml", "comp #8 ml" — keep the #number and surface letters together as one entry + Note: "comp" is short for "composite". "# 10 DL", "composite on # 10 DL", and "comp # 10 DL" are all the same — preserve the space-after-# as-is + IMPORTANT: Any letter(s) immediately after a tooth number are DENTAL SURFACE abbreviations indicating a composite filling: + M = Mesial, D = Distal, L = Lingual, F = Facial, B = Buccal, O = Occlusal, V = Vestibular, I = Incisal + e.g. "#8 ML" → composite on tooth 8, mesial+lingual surfaces (NOT an insurance abbreviation) + e.g. "#14 MOD" → composite on tooth 14, mesial+occlusal+distal surfaces + e.g. "#5 F" → composite on tooth 5, facial surface + Whenever you see #[tooth number] followed by any combination of M/D/L/F/B/O/V/I, it is ALWAYS a composite filling surface notation - #number always means a TOOTH number (never a case or pre-auth reference). When a single #number appears before a comma-separated list of procedures, apply it to EVERY procedure in the list. e.g. "#20 rct, post, crown" → ["#20 rct", "#20 post", "#20 crown"] e.g. "preauth #20 rct, pos, crown" → ["#20 rct", "#20 pos", "#20 crown"] diff --git a/apps/Backend/src/routes/payments-reports.ts b/apps/Backend/src/routes/payments-reports.ts index b2d939bc..42550c45 100755 --- a/apps/Backend/src/routes/payments-reports.ts +++ b/apps/Backend/src/routes/payments-reports.ts @@ -87,6 +87,51 @@ router.get( } ); +/** + * GET /api/payments-reports/patients-with-zero-balances + * Returns patients fully paid (balance <= 0) within the date/provider filter. + */ +router.get( + "/patients-with-zero-balances", + async (req: Request, res: Response): Promise => { + try { + 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 npiProviderId = req.query.npiProviderId + ? Number(req.query.npiProviderId) + : null; + + const data = await storage.getPatientsWithZeroBalance( + limit, + cursor, + from, + to, + npiProviderId + ); + res.json(data); + } catch (err: any) { + console.error( + "GET /api/payments-reports/patients-with-zero-balances error:", + err?.message ?? err, + err?.stack + ); + res.status(500).json({ message: "Failed to fetch zero-balance patients" }); + } + } +); + /** * GET /api/payments-reports/by-doctor/balances * Query params: diff --git a/apps/Backend/src/storage/payments-reports-storage.ts b/apps/Backend/src/storage/payments-reports-storage.ts index ca90385b..610c8365 100755 --- a/apps/Backend/src/storage/payments-reports-storage.ts +++ b/apps/Backend/src/storage/payments-reports-storage.ts @@ -24,6 +24,14 @@ export interface IPaymentsReportsStorage { npiProviderId?: number | null ): Promise; + getPatientsWithZeroBalance( + limit: number, + cursorToken?: string | null, + from?: Date | null, + to?: Date | null, + npiProviderId?: number | null + ): Promise; + getPatientsBalancesByDoctor( staffId: number, limit: number, @@ -136,47 +144,35 @@ export const paymentsReportsStorage: IPaymentsReportsStorage = { const patientsCntRows = (await prisma.$queryRawUnsafe(patientsCountSql)) as { cnt: number }[]; const totalPatients = patientsCntRows?.[0]?.cnt ?? 0; - const outstandingSql = ` - SELECT COALESCE(SUM( - GREATEST(COALESCE(pm.total_charges,0) - COALESCE(pm.mh_paid,0) - COALESCE(pm.copayment,0) - COALESCE(pm.adjustment,0), 0) - ),0)::numeric(14,2) AS outstanding + // effective_balance per payment: + // 0 if status='PAID' (user confirmed Pay In Full) + // billed-collected if status!='PAID' and collected < billed + // A patient has zero balance only when sum(effective_balance) = 0. + const combinedSql = ` + SELECT + COALESCE(SUM(pm.effective_balance),0)::numeric(14,2) AS outstanding, + COALESCE(SUM(pm.total_collected),0)::numeric(14,2) AS collected, + COUNT(CASE WHEN pm.effective_balance > 0 THEN 1 END)::int AS patients_with_balance FROM ( - SELECT pay."patientId" AS patient_id, - SUM(pay."totalBilled")::numeric(14,2) AS total_charges, - SUM(COALESCE(pay."mhPaidAmount",0))::numeric(14,2) AS mh_paid, - SUM(pay."copayment")::numeric(14,2) AS copayment, - SUM(pay."adjustment")::numeric(14,2) AS adjustment + SELECT + pay."patientId" AS patient_id, + SUM(COALESCE(pay."mhPaidAmount",0) + pay."copayment")::numeric(14,2) AS total_collected, + SUM( + CASE WHEN pay.status = 'PAID' THEN 0 + ELSE GREATEST(pay."totalBilled" - COALESCE(pay."mhPaidAmount",0) - pay."copayment", 0) + END + )::numeric(14,2) AS effective_balance FROM "Payment" pay ${payWhereClause} GROUP BY pay."patientId" ) pm `; - const outstandingRows = (await prisma.$queryRawUnsafe(outstandingSql)) as { outstanding: string }[]; - const totalOutstanding = Number(outstandingRows?.[0]?.outstanding ?? 0); - - const collSql = ` - SELECT COALESCE(SUM(COALESCE(pay."mhPaidAmount",0) + pay."copayment"),0)::numeric(14,2) AS collected - FROM "Payment" pay - ${payWhereClause} - `; - const collRows = (await prisma.$queryRawUnsafe(collSql)) as { collected: string }[]; - const totalCollected = Number(collRows?.[0]?.collected ?? 0); - - const patientsWithBalanceSql = ` - SELECT COUNT(*)::int AS cnt FROM ( - SELECT pay."patientId" AS patient_id, - SUM(pay."totalBilled")::numeric(14,2) AS total_charges, - SUM(COALESCE(pay."mhPaidAmount",0))::numeric(14,2) AS mh_paid, - SUM(pay."copayment")::numeric(14,2) AS copayment, - SUM(pay."adjustment")::numeric(14,2) AS adjustment - FROM "Payment" pay - ${payWhereClause} - GROUP BY pay."patientId" - ) t - WHERE (COALESCE(t.total_charges,0) - COALESCE(t.mh_paid,0) - COALESCE(t.copayment,0) - COALESCE(t.adjustment,0)) > 0 - `; - const pwbRows = (await prisma.$queryRawUnsafe(patientsWithBalanceSql)) as { cnt: number }[]; - const patientsWithBalance = pwbRows?.[0]?.cnt ?? 0; + const combinedRows = (await prisma.$queryRawUnsafe(combinedSql)) as { + outstanding: string; collected: string; patients_with_balance: number; + }[]; + const totalOutstanding = Number(combinedRows?.[0]?.outstanding ?? 0); + const totalCollected = Number(combinedRows?.[0]?.collected ?? 0); + const patientsWithBalance = combinedRows?.[0]?.patients_with_balance ?? 0; return { totalPatients, totalOutstanding, totalCollected, patientsWithBalance }; } catch (err) { @@ -236,6 +232,11 @@ export const paymentsReportsStorage: IPaymentsReportsStorage = { SUM(pay."totalBilled")::numeric(12,2) AS total_charges, SUM(COALESCE(pay."mhPaidAmount",0) + pay."copayment")::numeric(12,2) AS total_paid, SUM(pay."adjustment")::numeric(12,2) AS total_adjusted, + SUM( + CASE WHEN pay.status = 'PAID' THEN 0 + ELSE GREATEST(pay."totalBilled" - COALESCE(pay."mhPaidAmount",0) - pay."copayment", 0) + END + )::numeric(12,2) AS effective_balance, MAX(pay."createdAt") AS last_payment_date FROM "Payment" pay ${paymentWhereClause} @@ -243,9 +244,6 @@ export const paymentsReportsStorage: IPaymentsReportsStorage = { ) pm `; - // Build keyset predicate if cursor provided. - // Ordering used: pm.last_payment_date DESC NULLS LAST, p."createdAt" DESC, p.id DESC - // For keyset, we need to fetch rows strictly "less than" the cursor in this ordering. let keysetPredicate = ""; if (cursor) { const lp = cursor.lastPaymentDate @@ -253,17 +251,13 @@ export const paymentsReportsStorage: IPaymentsReportsStorage = { : "NULL"; const id = Number(cursor.lastPatientId); - // We handle NULL last_payment_date ordering: since we use "NULLS LAST" in ORDER BY, - // rows with last_payment_date = NULL are considered *after* any non-null dates. - // To page correctly when cursor's lastPaymentDate is null, we compare accordingly. - // This predicate tries to cover both cases. keysetPredicate = ` AND ( (pm.last_payment_date IS NOT NULL AND ${lp} IS NOT NULL AND ( pm.last_payment_date < ${lp} OR (pm.last_payment_date = ${lp} AND p.id < ${id}) )) - OR (pm.last_payment_date IS NULL AND ${lp} IS NOT NULL) + OR (pm.last_payment_date IS NULL AND ${lp} IS NOT NULL) OR (pm.last_payment_date IS NULL AND ${lp} IS NULL AND p.id < ${id}) ) `; @@ -277,7 +271,7 @@ export const paymentsReportsStorage: IPaymentsReportsStorage = { COALESCE(pm.total_charges,0)::numeric(12,2) AS total_charges, COALESCE(pm.total_paid,0)::numeric(12,2) AS total_paid, COALESCE(pm.total_adjusted,0)::numeric(12,2) AS total_adjusted, - (COALESCE(pm.total_charges,0) - COALESCE(pm.total_paid,0) - COALESCE(pm.total_adjusted,0))::numeric(12,2) AS current_balance, + COALESCE(pm.effective_balance,0)::numeric(12,2) AS current_balance, pm.last_payment_date, apt.last_appointment_date FROM "Patient" p @@ -287,7 +281,7 @@ export const paymentsReportsStorage: IPaymentsReportsStorage = { FROM "Appointment" GROUP BY "patientId" ) apt ON apt.patient_id = p.id - WHERE (COALESCE(pm.total_charges,0) - COALESCE(pm.total_paid,0) - COALESCE(pm.total_adjusted,0)) > 0 + WHERE COALESCE(pm.effective_balance,0) > 0 `; const orderBy = `ORDER BY pm.last_payment_date DESC NULLS LAST, p.id DESC`; @@ -350,18 +344,19 @@ export const paymentsReportsStorage: IPaymentsReportsStorage = { : null, })); - // totalCount: count of patients with positive balance within same payment date filter const countSql = ` SELECT COUNT(*)::int AS cnt FROM ( - SELECT pay."patientId" AS patient_id, - SUM(pay."totalBilled")::numeric(14,2) AS total_charges, - SUM(COALESCE(pay."mhPaidAmount",0) + pay."copayment")::numeric(14,2) AS total_paid, - SUM(pay."adjustment")::numeric(14,2) AS total_adjusted + SELECT + SUM( + CASE WHEN pay.status = 'PAID' THEN 0 + ELSE GREATEST(pay."totalBilled" - COALESCE(pay."mhPaidAmount",0) - pay."copayment", 0) + END + )::numeric(14,2) AS effective_balance FROM "Payment" pay ${paymentWhereClause} GROUP BY pay."patientId" ) t - WHERE (COALESCE(t.total_charges,0) - COALESCE(t.total_paid,0) - COALESCE(t.total_adjusted,0)) > 0; + WHERE t.effective_balance > 0; `; const cntRows = (await prisma.$queryRawUnsafe(countSql)) as { cnt: number; @@ -380,6 +375,169 @@ export const paymentsReportsStorage: IPaymentsReportsStorage = { } }, + /** + * Returns patients whose current balance is zero or negative (fully paid/over-paid). + */ + async getPatientsWithZeroBalance( + limit = 25, + cursorToken?: string | null, + from?: Date | null, + to?: Date | null, + npiProviderId?: number | null + ) { + try { + type RawRow = { + patient_id: number; + first_name: string | null; + last_name: string | null; + total_charges: string; + total_paid: string; + total_adjusted: string; + current_balance: string; + last_payment_date: Date | null; + last_appointment_date: Date | null; + }; + + const safeLimit = Math.max(1, Math.min(200, Number(limit) || 25)); + const cursor = decodeCursor(cursorToken); + + const hasFrom = from !== undefined && from !== null; + const hasTo = to !== undefined && to !== null; + + const fromStart = isoStartOfDayLiteral(from); + const toNextStart = isoStartOfNextDayLiteral(to); + + const payConditions: string[] = []; + if (hasFrom) payConditions.push(`pay."createdAt" >= ${fromStart}`); + if (hasTo) payConditions.push(`pay."createdAt" <= ${toNextStart}`); + if (npiProviderId) payConditions.push(`pay."npiProviderId" = ${Number(npiProviderId)}`); + const paymentWhereClause = payConditions.length ? `WHERE ${payConditions.join(" AND ")}` : ""; + + const pmSubquery = ` + ( + SELECT + pay."patientId" AS patient_id, + SUM(pay."totalBilled")::numeric(12,2) AS total_charges, + SUM(COALESCE(pay."mhPaidAmount",0) + pay."copayment")::numeric(12,2) AS total_paid, + SUM(pay."adjustment")::numeric(12,2) AS total_adjusted, + SUM( + CASE WHEN pay.status = 'PAID' THEN 0 + ELSE GREATEST(pay."totalBilled" - COALESCE(pay."mhPaidAmount",0) - pay."copayment", 0) + END + )::numeric(12,2) AS effective_balance, + MAX(pay."createdAt") AS last_payment_date + FROM "Payment" pay + ${paymentWhereClause} + GROUP BY pay."patientId" + ) pm + `; + + let keysetPredicate = ""; + if (cursor) { + const lp = cursor.lastPaymentDate ? `'${cursor.lastPaymentDate}'` : "NULL"; + const id = Number(cursor.lastPatientId); + keysetPredicate = ` + AND ( + (pm.last_payment_date IS NOT NULL AND ${lp} IS NOT NULL AND ( + pm.last_payment_date < ${lp} + OR (pm.last_payment_date = ${lp} AND p.id < ${id}) + )) + OR (pm.last_payment_date IS NULL AND ${lp} IS NOT NULL) + OR (pm.last_payment_date IS NULL AND ${lp} IS NULL AND p.id < ${id}) + ) + `; + } + + const baseSelect = ` + SELECT + p.id AS patient_id, + p."firstName" AS first_name, + p."lastName" AS last_name, + COALESCE(pm.total_charges,0)::numeric(12,2) AS total_charges, + COALESCE(pm.total_paid,0)::numeric(12,2) AS total_paid, + COALESCE(pm.total_adjusted,0)::numeric(12,2) AS total_adjusted, + 0::numeric(12,2) AS current_balance, + pm.last_payment_date, + apt.last_appointment_date + FROM "Patient" p + INNER JOIN ${pmSubquery} ON pm.patient_id = p.id + LEFT JOIN ( + SELECT "patientId" AS patient_id, MAX("date") AS last_appointment_date + FROM "Appointment" + GROUP BY "patientId" + ) apt ON apt.patient_id = p.id + WHERE COALESCE(pm.effective_balance, 0) = 0 + `; + + const orderBy = `ORDER BY pm.last_payment_date DESC NULLS LAST, p.id DESC`; + + const query = ` + ${baseSelect} + ${cursor ? keysetPredicate : ""} + ${orderBy} + LIMIT ${safeLimit}; + `; + + const rows = (await prisma.$queryRawUnsafe(query)) as RawRow[]; + + let nextCursor: string | null = null; + if (rows.length === safeLimit) { + const last = rows[rows.length - 1]; + if (last) { + nextCursor = encodeCursor({ + lastPaymentDate: last.last_payment_date ? new Date(last.last_payment_date).toISOString() : null, + lastPatientId: Number(last.patient_id), + }); + } + } + + const hasMore = rows.length === safeLimit; + + const balances: PatientBalanceRow[] = rows.map((r) => ({ + patientId: Number(r.patient_id), + firstName: r.first_name, + lastName: r.last_name, + totalCharges: Number(r.total_charges ?? 0), + totalPayments: Number(r.total_paid ?? 0), + totalAdjusted: Number(r.total_adjusted ?? 0), + currentBalance: Number(r.current_balance ?? 0), + lastPaymentDate: r.last_payment_date ? new Date(r.last_payment_date).toISOString() : null, + lastAppointmentDate: r.last_appointment_date ? new Date(r.last_appointment_date).toISOString() : null, + })); + + const countAndTotalsSql = ` + SELECT + COUNT(*)::int AS cnt, + COALESCE(SUM(t.total_charges),0)::numeric(14,2) AS total_charges, + COALESCE(SUM(t.total_paid),0)::numeric(14,2) AS total_collected + FROM ( + SELECT + pay."patientId" AS patient_id, + SUM(pay."totalBilled")::numeric(14,2) AS total_charges, + SUM(COALESCE(pay."mhPaidAmount",0) + pay."copayment")::numeric(14,2) AS total_paid, + SUM( + CASE WHEN pay.status = 'PAID' THEN 0 + ELSE GREATEST(pay."totalBilled" - COALESCE(pay."mhPaidAmount",0) - pay."copayment", 0) + END + )::numeric(14,2) AS effective_balance + FROM "Payment" pay + ${paymentWhereClause} + GROUP BY pay."patientId" + ) t + WHERE COALESCE(t.effective_balance, 0) = 0; + `; + const cntRows = (await prisma.$queryRawUnsafe(countAndTotalsSql)) as { cnt: number; total_charges: string; total_collected: string }[]; + const totalCount = cntRows?.[0]?.cnt ?? 0; + const totalCharges = Number(cntRows?.[0]?.total_charges ?? 0); + const totalCollected = Number(cntRows?.[0]?.total_collected ?? 0); + + return { balances, totalCount, nextCursor, hasMore, totalCharges, totalCollected }; + } catch (err) { + console.error("[paymentsReportsStorage.getPatientsWithZeroBalance] error:", err); + throw err; + } + }, + /** * Return just the paged balances for a doctor (same logic/filters as previous single-query approach) */ @@ -488,6 +646,11 @@ export const paymentsReportsStorage: IPaymentsReportsStorage = { SUM(pay."totalBilled")::numeric(14,2) AS total_charges, SUM(COALESCE(pay."mhPaidAmount",0) + pay."copayment")::numeric(14,2) AS total_paid, SUM(pay."adjustment")::numeric(14,2) AS total_adjusted, + SUM( + CASE WHEN pay.status = 'PAID' THEN 0 + ELSE GREATEST(pay."totalBilled" - COALESCE(pay."mhPaidAmount",0) - pay."copayment", 0) + END + )::numeric(14,2) AS effective_balance, MAX(pay."createdAt") AS last_payment_date FROM "Payment" pay JOIN "Claim" c ON pay."claimId" = c.id @@ -510,7 +673,7 @@ export const paymentsReportsStorage: IPaymentsReportsStorage = { 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, + COALESCE(pa.effective_balance, 0)::numeric(14,2) AS current_balance, pa.last_payment_date, -- epoch milliseconds for last payment date (NULL when last_payment_date is NULL) (CASE WHEN pa.last_payment_date IS NULL THEN NULL @@ -678,10 +841,12 @@ export const paymentsReportsStorage: IPaymentsReportsStorage = { payments_agg AS ( SELECT pay."patientId" AS patient_id, - SUM(pay."totalBilled")::numeric(14,2) AS total_charges, - SUM(COALESCE(pay."mhPaidAmount",0))::numeric(14,2) AS mh_paid, - SUM(pay."copayment")::numeric(14,2) AS copayment, - SUM(pay."adjustment")::numeric(14,2) AS adjustment + SUM(COALESCE(pay."mhPaidAmount",0) + pay."copayment")::numeric(14,2) AS total_collected, + SUM( + CASE WHEN pay.status = 'PAID' THEN 0 + ELSE GREATEST(pay."totalBilled" - COALESCE(pay."mhPaidAmount",0) - pay."copayment", 0) + END + )::numeric(14,2) AS effective_balance FROM "Payment" pay JOIN "Claim" c ON pay."claimId" = c.id WHERE c."staffId" = ${Number(staffId)} @@ -689,10 +854,10 @@ export const paymentsReportsStorage: IPaymentsReportsStorage = { GROUP BY pay."patientId" ) SELECT json_build_object( - 'totalPatients', COALESCE(COUNT(DISTINCT pa.patient_id),0), - 'totalOutstanding', COALESCE(SUM(GREATEST(COALESCE(pa.total_charges,0) - COALESCE(pa.mh_paid,0) - COALESCE(pa.copayment,0) - COALESCE(pa.adjustment,0), 0)),0)::text, - 'totalCollected', COALESCE(SUM(COALESCE(pa.mh_paid,0) + COALESCE(pa.copayment,0)),0)::text, - 'patientsWithBalance', COALESCE(SUM(CASE WHEN (COALESCE(pa.total_charges,0) - COALESCE(pa.mh_paid,0) - COALESCE(pa.copayment,0) - COALESCE(pa.adjustment,0)) > 0 THEN 1 ELSE 0 END),0) + 'totalPatients', COALESCE(COUNT(DISTINCT pa.patient_id),0), + 'totalOutstanding', COALESCE(SUM(pa.effective_balance),0)::text, + 'totalCollected', COALESCE(SUM(pa.total_collected),0)::text, + 'patientsWithBalance',COALESCE(SUM(CASE WHEN pa.effective_balance > 0 THEN 1 ELSE 0 END),0) ) AS summary_json FROM payments_agg pa; `; diff --git a/apps/Frontend/src/components/reports/patients-no-balance-report.tsx b/apps/Frontend/src/components/reports/patients-no-balance-report.tsx new file mode 100644 index 00000000..41a2f9b5 --- /dev/null +++ b/apps/Frontend/src/components/reports/patients-no-balance-report.tsx @@ -0,0 +1,156 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { apiRequest } from "@/lib/queryClient"; +import type { PatientBalanceRow } from "@repo/db/types"; +import PatientsBalancesList from "./patients-balances-list"; +import ExportReportButton from "./export-button"; + +type Resp = { + balances: PatientBalanceRow[]; + totalCount?: number; + nextCursor?: string | null; + hasMore?: boolean; + totalCharges?: number; + totalCollected?: number; +}; + +function fmtCurrency(v: number) { + return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(v); +} + +export default function PatientsNoBalanceReport({ + startDate, + endDate, + npiProviderId, +}: { + startDate: string; + endDate: string; + npiProviderId?: number | null; +}) { + const balancesPerPage = 10; + const [cursorStack, setCursorStack] = useState<(string | null)[]>([null]); + const [cursorIndex, setCursorIndex] = useState(0); + const currentCursor = cursorStack[cursorIndex] ?? null; + const pageIndex = cursorIndex + 1; + + const { data, isLoading, isError, refetch } = useQuery({ + queryKey: [ + "/api/payments-reports/patients-with-zero-balances", + currentCursor, + balancesPerPage, + startDate, + endDate, + npiProviderId ?? "all", + ], + queryFn: async () => { + const params = new URLSearchParams(); + params.set("limit", String(balancesPerPage)); + if (currentCursor) params.set("cursor", currentCursor); + if (startDate) params.set("from", startDate); + if (endDate) params.set("to", endDate); + if (npiProviderId) params.set("npiProviderId", String(npiProviderId)); + const res = await apiRequest( + "GET", + `/api/payments-reports/patients-with-zero-balances?${params.toString()}` + ); + if (!res.ok) { + const body = await res.json().catch(() => ({ message: "Failed to load" })); + throw new Error(body.message || "Failed to load zero-balance patients"); + } + return res.json(); + }, + enabled: true, + }); + + const balances = data?.balances ?? []; + const totalCount = data?.totalCount ?? undefined; + const nextCursor = data?.nextCursor ?? null; + const hasMore = data?.hasMore ?? false; + const totalCharges = data?.totalCharges ?? 0; + const totalCollected = data?.totalCollected ?? 0; + + useEffect(() => { + setCursorStack([null]); + setCursorIndex(0); + refetch(); + }, [startDate, endDate, npiProviderId]); + + const handleNext = useCallback(() => { + const isLastKnown = cursorIndex === cursorStack.length - 1; + if (isLastKnown) { + if (nextCursor) { + setCursorStack((s) => [...s, nextCursor]); + setCursorIndex((i) => i + 1); + } + } else { + setCursorIndex((i) => i + 1); + } + }, [cursorIndex, cursorStack.length, nextCursor]); + + const handlePrev = useCallback(() => { + setCursorIndex((i) => Math.max(0, i - 1)); + }, []); + + const normalized = balances.map((b) => { + const currentBalance = Number(b.currentBalance ?? 0); + const totalChargesRow = Number(b.totalCharges ?? 0); + const totalPayments = + b.totalPayments != null + ? Number(b.totalPayments) + : Number(totalChargesRow - currentBalance); + return { + id: b.patientId, + name: `${b.firstName ?? "Unknown"} ${b.lastName ?? ""}`.trim(), + currentBalance, + totalCharges: totalChargesRow, + totalPayments, + }; + }); + + return ( +
+
+ 0} + hasNext={hasMore} + headerRight={ + + } + /> +
+ + {/* Aggregate summary for the full zero-balance set */} + {!isLoading && !isError && (totalCount ?? 0) > 0 && ( +
+
+ Total patients paid in full: + {totalCount} +
+
+ Total charges billed: + {fmtCurrency(totalCharges)} +
+
+ Total collected: + {fmtCurrency(totalCollected)} +
+
+ )} +
+ ); +} diff --git a/apps/Frontend/src/components/reports/summary-cards.tsx b/apps/Frontend/src/components/reports/summary-cards.tsx index 678e349f..f8c52797 100755 --- a/apps/Frontend/src/components/reports/summary-cards.tsx +++ b/apps/Frontend/src/components/reports/summary-cards.tsx @@ -10,21 +10,28 @@ type SummaryResp = { totalCollected?: number; }; +type ZeroBalResp = { + totalCount?: number; + totalCharges?: number; + totalCollected?: number; +}; + +type ReportType = string; + function fmtCurrency(v: number) { - return new Intl.NumberFormat("en-US", { - style: "currency", - currency: "USD", - }).format(v); + return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(v); } export default function SummaryCards({ startDate, endDate, npiProviderId, + selectedReportType, }: { startDate: string; endDate: string; npiProviderId?: number | null; + selectedReportType?: ReportType; }) { const { data, isLoading, isError } = useQuery({ queryKey: ["/api/payments-reports/summary", startDate, endDate, npiProviderId ?? "all"], @@ -33,12 +40,9 @@ export default function SummaryCards({ if (startDate) params.set("from", startDate); if (endDate) params.set("to", endDate); if (npiProviderId) params.set("npiProviderId", String(npiProviderId)); - const endpoint = `/api/payments-reports/summary?${params.toString()}`; - const res = await apiRequest("GET", endpoint); + const res = await apiRequest("GET", `/api/payments-reports/summary?${params.toString()}`); if (!res.ok) { - const body = await res - .json() - .catch(() => ({ message: "Failed to load dashboard summary" })); + const body = await res.json().catch(() => ({ message: "Failed to load dashboard summary" })); throw new Error(body?.message ?? "Failed to load dashboard summary"); } return res.json(); @@ -46,29 +50,44 @@ export default function SummaryCards({ enabled: Boolean(startDate && endDate), }); - const totalPatients = data?.totalPatients ?? 0; + // Fetch zero-balance aggregates only when that report is active + const showZeroBalSummary = selectedReportType === "patients_no_balance"; + const { data: zeroBalData, isLoading: zeroBalLoading } = useQuery({ + queryKey: ["/api/payments-reports/patients-with-zero-balances/summary", startDate, endDate, npiProviderId ?? "all"], + queryFn: async () => { + const params = new URLSearchParams(); + params.set("limit", "1"); + if (startDate) params.set("from", startDate); + if (endDate) params.set("to", endDate); + if (npiProviderId) params.set("npiProviderId", String(npiProviderId)); + const res = await apiRequest("GET", `/api/payments-reports/patients-with-zero-balances?${params.toString()}`); + if (!res.ok) throw new Error("Failed to load zero balance summary"); + return res.json(); + }, + enabled: Boolean(startDate && endDate && showZeroBalSummary), + }); + + const totalPatients = data?.totalPatients ?? 0; const patientsWithBalance = data?.patientsWithBalance ?? 0; - const patientsNoBalance = Math.max( - 0, - (data?.totalPatients ?? 0) - (data?.patientsWithBalance ?? 0) - ); - const totalOutstanding = data?.totalOutstanding ?? 0; - const totalCollected = data?.totalCollected ?? 0; + const patientsNoBalance = Math.max(0, totalPatients - patientsWithBalance); + const totalOutstanding = data?.totalOutstanding ?? 0; + const totalCollected = data?.totalCollected ?? 0; + + const zeroBalCount = zeroBalData?.totalCount ?? 0; + const zeroBalBilled = zeroBalData?.totalCharges ?? 0; + const zeroBalCollected = zeroBalData?.totalCollected ?? 0; + + const isWithBalance = selectedReportType === "patients_with_balance"; return ( - {/* Heading */}
-

- Report summary -

-

- Data covers the selected time frame -

+

Report summary

+

Data covers the selected time frame

- {/* Stats grid */} + {/* Global stats */}
@@ -77,21 +96,21 @@ export default function SummaryCards({

Total Patients

-
+
{isLoading ? "—" : patientsWithBalance}

With Balance

-
+
{isLoading ? "—" : patientsNoBalance}

Zero Balance

-
+
{isLoading ? "—" : fmtCurrency(totalOutstanding)}
@@ -106,6 +125,35 @@ export default function SummaryCards({
+ {/* Zero-balance detail row */} + {showZeroBalSummary && ( +
+

+ Zero Balance — Paid in Full Summary +

+
+
+
+ {zeroBalLoading ? "—" : zeroBalCount} +
+

Patients Paid in Full

+
+
+
+ {zeroBalLoading ? "—" : fmtCurrency(zeroBalBilled)} +
+

Total Billed

+
+
+
+ {zeroBalLoading ? "—" : fmtCurrency(zeroBalCollected)} +
+

Total Collected

+
+
+
+ )} + {isError && (
Failed to load summary. Check server or network. diff --git a/apps/Frontend/src/pages/reports-page.tsx b/apps/Frontend/src/pages/reports-page.tsx index b53287a0..c6374f6b 100755 --- a/apps/Frontend/src/pages/reports-page.tsx +++ b/apps/Frontend/src/pages/reports-page.tsx @@ -2,6 +2,7 @@ import React, { useState } from "react"; import { useAuth } from "@/hooks/use-auth"; import ReportConfig from "@/components/reports/report-config"; import PatientsWithBalanceReport from "@/components/reports/patients-with-balance-report"; +import PatientsNoBalanceReport from "@/components/reports/patients-no-balance-report"; import SummaryCards from "@/components/reports/summary-cards"; import CommissionSection from "@/components/reports/commission-section"; @@ -66,7 +67,7 @@ export default function ReportPage() { {/* SINGLE authoritative SummaryCards instance for the page */}
- +
@@ -74,7 +75,9 @@ export default function ReportPage() { )} - {/* Add other report components here as needed */} + {selectedReportType === "patients_no_balance" && ( + + )}
{/* Commission section */} diff --git a/packages/db/shared/.prisma-zod-generator-manifest.json b/packages/db/shared/.prisma-zod-generator-manifest.json index 9cba41e8..076f6e43 100755 --- a/packages/db/shared/.prisma-zod-generator-manifest.json +++ b/packages/db/shared/.prisma-zod-generator-manifest.json @@ -1,7 +1,7 @@ { "version": "1.0", "generatorVersion": "1.0.0", - "generatedAt": "2026-06-27T02:57:45.959Z", + "generatedAt": "2026-06-27T03:13:10.416Z", "outputPath": "/home/gg/Desktop/DentalManagementMH06/packages/db/shared", "files": [ "schemas/enums/TransactionIsolationLevel.schema.ts",