diff --git a/apps/Backend/src/routes/index.ts b/apps/Backend/src/routes/index.ts index 53d99fb..43be9e3 100644 --- a/apps/Backend/src/routes/index.ts +++ b/apps/Backend/src/routes/index.ts @@ -13,6 +13,7 @@ import databaseManagementRoutes from "./database-management"; import notificationsRoutes from "./notifications"; import paymentOcrRoutes from "./paymentOcrExtraction"; import cloudStorageRoutes from "./cloud-storage"; +import paymentsReportsRoutes from "./payments-reports"; const router = Router(); @@ -30,5 +31,6 @@ router.use("/database-management", databaseManagementRoutes); router.use("/notifications", notificationsRoutes); router.use("/payment-ocr", paymentOcrRoutes); router.use("/cloud-storage", cloudStorageRoutes); +router.use("/payments-reports", paymentsReportsRoutes); export default router; diff --git a/apps/Backend/src/routes/payments-reports.ts b/apps/Backend/src/routes/payments-reports.ts new file mode 100644 index 0000000..ca28412 --- /dev/null +++ b/apps/Backend/src/routes/payments-reports.ts @@ -0,0 +1,121 @@ +import { Router } from "express"; +import type { Request, Response } from "express"; +import { storage } from "../storage"; +const router = Router(); + +/** + * GET /api/payments-reports/summary + * optional query: from=YYYY-MM-DD&to=YYYY-MM-DD (ISO date strings) + */ +router.get("/summary", async (req, res) => { + try { + 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 summary = await storage.getSummary(from, to); + res.json(summary); + } catch (err: any) { + console.error( + "GET /api/payments-reports/summary error:", + err?.message ?? err, + err?.stack + ); + res.status(500).json({ message: "Failed to fetch dashboard summary" }); + } +}); +/** + * GET /api/payments-reports/summary + * optional query: from=YYYY-MM-DD&to=YYYY-MM-DD (ISO date strings) + */ +router.get("/summary", async (req: Request, res: Response): Promise => { + try { + 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 summary = await storage.getSummary(from, to); + res.json(summary); + } catch (err: any) { + console.error( + "GET /api/payments-reports/summary error:", + err?.message ?? err, + err?.stack + ); + res.status(500).json({ message: "Failed to fetch dashboard summary" }); + } +}); + +/** + * GET /api/payments-reports/patient-balances + * query: + * - limit (default 25) + * - offset (default 0) + * - minBalance (true|false) + * - from / to (optional ISO date strings) - filter payments by createdAt in the range (inclusive) + */ +router.get( + "/patient-balances", + async (req: Request, res: Response): Promise => { + try { + const limit = Math.max( + 1, + Math.min(200, parseInt(String(req.query.limit || "25"), 10)) + ); + const offset = Math.max(0, parseInt(String(req.query.offset || "0"), 10)); + const minBalance = + String(req.query.minBalance || "false").toLowerCase() === "true"; + + 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.getPatientBalances( + limit, + offset, + from, + to, + minBalance + ); + res.json(data); + } catch (err: any) { + console.error( + "GET /api/payments-reports/patient-balances error:", + err?.message ?? err, + err?.stack + ); + res.status(500).json({ message: "Failed to fetch patient balances" }); + } + } +); + +// // GET /api/payments-by-date?from=ISO&to=ISO&limit=&offset= +// router.get("/payments-by-date", async (req: Request, res: Response): Promise => { +// try { +// const from = req.query.from ? new Date(String(req.query.from)) : null; +// const to = req.query.to ? new Date(String(req.query.to)) : null; +// if (!from || !to) return res.status(400).json({ message: "from and to are required" }); + +// const limit = Math.min(parseInt(req.query.limit as string) || 1000, 5000); +// const offset = parseInt(req.query.offset as string) || 0; + +// const { payments, totalCount } = await storage.getPaymentsByDateRange(from, to, limit, offset); +// res.json({ payments, totalCount }); +// } catch (err) { +// console.error(err); +// res.status(500).json({ message: "Failed to fetch payments by date" }); +// } +// }); + +export default router; diff --git a/apps/Backend/src/storage/index.ts b/apps/Backend/src/storage/index.ts index fbf03e3..1886301 100644 --- a/apps/Backend/src/storage/index.ts +++ b/apps/Backend/src/storage/index.ts @@ -11,6 +11,7 @@ import { paymentsStorage } from './payments-storage'; import { databaseBackupStorage } from './database-backup-storage'; import { notificationsStorage } from './notifications-storage'; import { cloudStorageStorage } from './cloudStorage-storage'; +import { paymentsReportsStorage } from './payments-reports-storage'; @@ -26,6 +27,7 @@ export const storage = { ...databaseBackupStorage, ...notificationsStorage, ...cloudStorageStorage, + ...paymentsReportsStorage, }; diff --git a/apps/Backend/src/storage/payments-reports-storage.ts b/apps/Backend/src/storage/payments-reports-storage.ts new file mode 100644 index 0000000..60cf711 --- /dev/null +++ b/apps/Backend/src/storage/payments-reports-storage.ts @@ -0,0 +1,481 @@ +// 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; + lastName: string | null; + totalCharges: number; + totalPayments: number; + totalAdjusted: number; + currentBalance: number; + lastPaymentDate: string | null; + lastAppointmentDate: string | null; +} + +export interface IPaymentsReportsStorage { + getPatientBalances( + limit: number, + offset: number, + from?: Date | null, + to?: Date | null, + minBalanceOnly?: boolean + ): Promise<{ balances: PatientBalanceRow[]; totalCount: number }>; + + // summary now returns an extra field patientsWithBalance + getSummary( + from?: Date | null, + to?: Date | null + ): Promise<{ + totalPatients: number; + totalOutstanding: number; + totalCollected: number; + patientsWithBalance: number; + }>; +} + +/** Helper: format Date -> SQL literal 'YYYY-MM-DDTHH:mm:ss.sssZ' or null */ +function fmtDateLiteral(d?: Date | null): string | null { + if (!d) return null; + const iso = new Date(d).toISOString(); + return `'${iso}'`; +} + +export const paymentsReportsStorage: IPaymentsReportsStorage = { + async getPatientBalances( + limit = 25, + offset = 0, + from?: Date | null, + to?: Date | null, + minBalanceOnly = false + ) { + try { + type RawRow = { + patient_id: number; + first_name: string | null; + last_name: string | null; + total_charges: string; + total_payments: 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 safeOffset = Math.max(0, Number(offset) || 0); + + const hasFrom = from !== undefined && from !== null; + const hasTo = to !== undefined && to !== null; + const fromLit = fmtDateLiteral(from); + const toLit = fmtDateLiteral(to); + + // Build pm subquery (aggregated payments in the date window) — only Payment table used + let pmSubquery = ""; + if (hasFrom && hasTo) { + pmSubquery = ` + ( + SELECT + pay."patientId" AS patient_id, + SUM(pay."totalBilled")::numeric(12,2) AS total_charges, + SUM(pay."totalPaid")::numeric(12,2) AS total_paid, + SUM(pay."totalAdjusted")::numeric(12,2) AS total_adjusted, + MAX(pay."createdAt") AS last_payment_date + FROM "Payment" pay + WHERE pay."createdAt" >= ${fromLit} AND pay."createdAt" <= ${toLit} + GROUP BY pay."patientId" + ) pm + `; + } else if (hasFrom) { + pmSubquery = ` + ( + SELECT + pay."patientId" AS patient_id, + SUM(pay."totalBilled")::numeric(12,2) AS total_charges, + SUM(pay."totalPaid")::numeric(12,2) AS total_paid, + SUM(pay."totalAdjusted")::numeric(12,2) AS total_adjusted, + MAX(pay."createdAt") AS last_payment_date + FROM "Payment" pay + WHERE pay."createdAt" >= ${fromLit} + GROUP BY pay."patientId" + ) pm + `; + } else if (hasTo) { + pmSubquery = ` + ( + SELECT + pay."patientId" AS patient_id, + SUM(pay."totalBilled")::numeric(12,2) AS total_charges, + SUM(pay."totalPaid")::numeric(12,2) AS total_paid, + SUM(pay."totalAdjusted")::numeric(12,2) AS total_adjusted, + MAX(pay."createdAt") AS last_payment_date + FROM "Payment" pay + WHERE pay."createdAt" <= ${toLit} + GROUP BY pay."patientId" + ) pm + `; + } else { + pmSubquery = ` + ( + SELECT + pay."patientId" AS patient_id, + SUM(pay."totalBilled")::numeric(12,2) AS total_charges, + SUM(pay."totalPaid")::numeric(12,2) AS total_paid, + SUM(pay."totalAdjusted")::numeric(12,2) AS total_adjusted, + MAX(pay."createdAt") AS last_payment_date + FROM "Payment" pay + GROUP BY pay."patientId" + ) pm + `; + } + + const baseQuery = ` + 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_payments, + 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, + pm.last_payment_date, + apt.last_appointment_date + FROM "Patient" p + LEFT 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 + `; + + const balanceWhere = `(COALESCE(pm.total_charges,0) - COALESCE(pm.total_paid,0) - COALESCE(pm.total_adjusted,0)) > 0`; + + const finalQuery = minBalanceOnly + ? `${baseQuery} WHERE ${balanceWhere} ORDER BY pm.last_payment_date DESC NULLS LAST, p."createdAt" DESC LIMIT ${safeLimit} OFFSET ${safeOffset}` + : `${baseQuery} ORDER BY pm.last_payment_date DESC NULLS LAST, p."createdAt" DESC LIMIT ${safeLimit} OFFSET ${safeOffset}`; + + // Execute query + const rows = (await prisma.$queryRawUnsafe(finalQuery)) as RawRow[]; + + // totalCount — count distinct patients that have payments in the date window (and if minBalanceOnly, only those with positive balance) + let countSql = ""; + if (hasFrom && hasTo) { + if (minBalanceOnly) { + countSql = ` + 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 { + countSql = ` + 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) { + if (minBalanceOnly) { + countSql = ` + 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 { + countSql = ` + 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) { + if (minBalanceOnly) { + countSql = ` + 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 { + countSql = ` + 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 { + if (minBalanceOnly) { + countSql = ` + 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 + `; + } else { + countSql = `SELECT COUNT(DISTINCT "patientId")::int AS cnt FROM "Payment"`; + } + } + + const cntRows = (await prisma.$queryRawUnsafe(countSql)) as { + cnt: number; + }[]; + const totalCount = cntRows?.[0]?.cnt ?? 0; + + 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_payments ?? 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, + })); + + return { balances, totalCount }; + } catch (err) { + console.error("[paymentsReportsStorage.getPatientBalances] error:", err); + throw err; + } + }, + + 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; + } + }, +}; diff --git a/apps/Frontend/src/pages/preauthorizations-page.tsx b/apps/Frontend/src/pages/preauthorizations-page.tsx deleted file mode 100644 index 4fc4779..0000000 --- a/apps/Frontend/src/pages/preauthorizations-page.tsx +++ /dev/null @@ -1,339 +0,0 @@ -import { useState } from "react"; -import { useQuery } from "@tanstack/react-query"; -import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; -import { Button } from "@/components/ui/button"; -import { useToast } from "@/hooks/use-toast"; -import { useAuth } from "@/hooks/use-auth"; -import { Plus, ClipboardCheck, Clock, CheckCircle, AlertCircle } from "lucide-react"; -import { format } from "date-fns"; -import { Appointment, Patient } from "@repo/db/types"; - -export default function PreAuthorizationsPage() { - const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); - const [isPreAuthFormOpen, setIsPreAuthFormOpen] = useState(false); - const [selectedPatient, setSelectedPatient] = useState(null); - const [selectedProcedure, setSelectedProcedure] = useState(null); - - const { toast } = useToast(); - const { user } = useAuth(); - - // Fetch patients - const { data: patients = [], isLoading: isLoadingPatients } = useQuery({ - queryKey: ["/api/patients"], - enabled: !!user, - }); - - // Fetch appointments - const { - data: appointments = [] as Appointment[], - isLoading: isLoadingAppointments - } = useQuery({ - queryKey: ["/api/appointments"], - enabled: !!user, - }); - - const toggleMobileMenu = () => { - setIsMobileMenuOpen(!isMobileMenuOpen); - }; - - const handleNewPreAuth = (patientId: number, procedure: string) => { - setSelectedPatient(patientId); - setSelectedProcedure(procedure); - setIsPreAuthFormOpen(true); - - // Show a toast notification of success - const patient = patients.find(p => p.id === patientId); - toast({ - title: "Pre-authorization Request Started", - description: `Started pre-auth for ${patient?.firstName} ${patient?.lastName} - ${procedure}`, - }); - }; - - // Common dental procedures requiring pre-authorization - const dentalProcedures = [ - { code: "D2740", name: "Crown - porcelain/ceramic" }, - { code: "D2950", name: "Core buildup, including any pins" }, - { code: "D3330", name: "Root Canal - molar" }, - { code: "D4341", name: "Periodontal scaling & root planing" }, - { code: "D4910", name: "Periodontal maintenance" }, - { code: "D5110", name: "Complete denture - maxillary" }, - { code: "D6010", name: "Surgical placement of implant body" }, - { code: "D7240", name: "Removal of impacted tooth" }, - ]; - - // Get patients with active insurance - const patientsWithInsurance = patients.filter(patient => - patient.insuranceProvider && patient.insuranceId - ); - - // Sample pre-authorization data - const samplePreAuths = [ - { - id: "PA2023-001", - patientId: patientsWithInsurance[0]?.id || 1, - procedureCode: "D2740", - procedureName: "Crown - porcelain/ceramic", - requestDate: new Date(new Date().setDate(new Date().getDate() - 14)), - status: "approved", - approvalDate: new Date(new Date().setDate(new Date().getDate() - 7)), - expirationDate: new Date(new Date().setMonth(new Date().getMonth() + 6)), - }, - { - id: "PA2023-002", - patientId: patientsWithInsurance[0]?.id || 1, - procedureCode: "D3330", - procedureName: "Root Canal - molar", - requestDate: new Date(new Date().setDate(new Date().getDate() - 5)), - status: "pending", - approvalDate: null, - expirationDate: null, - }, - { - id: "PA2023-003", - patientId: patientsWithInsurance[0]?.id || 1, - procedureCode: "D7240", - procedureName: "Removal of impacted tooth", - requestDate: new Date(new Date().setDate(new Date().getDate() - 30)), - status: "denied", - approvalDate: null, - expirationDate: null, - denialReason: "Not medically necessary based on submitted documentation", - } - ]; - - return ( -
- {/* Header */} -
-

Pre-authorizations

-

Manage insurance pre-authorizations for dental procedures

-
- - {/* New Pre-Authorization Request Section */} -
-
-

New Pre-Authorization Request

-
- - - - Recent Patients for Pre-Authorization - - - {isLoadingPatients ? ( -
Loading patients data...
- ) : patientsWithInsurance.length > 0 ? ( -
- {patientsWithInsurance.map((patient) => ( -
{ - setSelectedPatient(Number(patient.id)); - handleNewPreAuth( - patient.id, - dentalProcedures[Math.floor(Math.random() * 3)].name - ); - }} - > -
-

{patient.firstName} {patient.lastName}

-
- Insurance: {patient.insuranceProvider === 'delta' - ? 'Delta Dental' - : patient.insuranceProvider === 'metlife' - ? 'MetLife' - : patient.insuranceProvider === 'cigna' - ? 'Cigna' - : patient.insuranceProvider === 'aetna' - ? 'Aetna' - : patient.insuranceProvider} - - ID: {patient.insuranceId} - - Procedure needed: {dentalProcedures[0].name} -
-
-
- -
-
- ))} -
- ) : ( -
- -

No patients with insurance

-

- Add insurance information to patients to request pre-authorizations -

-
- )} -
-
-
- - {/* Pre-Authorization Submitted Section */} -
-
-

Pre-Authorization Submitted

-
- - - - Pending Pre-Authorization Requests - - - {patientsWithInsurance.length > 0 ? ( -
- {samplePreAuths.filter(auth => auth.status === 'pending').map((preAuth) => { - const patient = patients.find(p => p.id === preAuth.patientId) || - { firstName: "Unknown", lastName: "Patient" }; - - return ( -
toast({ - title: "Pre-Authorization Details", - description: `Viewing details for ${preAuth.id}` - })} - > -
-

{patient.firstName} {patient.lastName} - {preAuth.procedureName}

-
- ID: {preAuth.id} - - Submitted: {format(preAuth.requestDate, 'MMM dd, yyyy')} - - Expected Response: {format(new Date(preAuth.requestDate.getTime() + 7 * 24 * 60 * 60 * 1000), 'MMM dd, yyyy')} -
-
-
- - - - Pending - - -
-
- ); - })} - - {samplePreAuths.filter(auth => auth.status === 'pending').length === 0 && ( -
- -

No pending requests

-

- Submitted pre-authorization requests will appear here -

-
- )} -
- ) : ( -
- -

No pre-authorization history

-

- Submitted pre-authorization requests will appear here -

-
- )} -
-
-
- - {/* Pre-Authorization Results Section */} -
-
-

Pre-Authorization Results

-
- - - - Completed Pre-Authorization Requests - - - {patientsWithInsurance.length > 0 ? ( -
- {samplePreAuths.filter(auth => auth.status !== 'pending').map((preAuth) => { - const patient = patients.find(p => p.id === preAuth.patientId) || - { firstName: "Unknown", lastName: "Patient" }; - - return ( -
toast({ - title: "Pre-Authorization Details", - description: `Viewing details for ${preAuth.id}` - })} - > -
-

{patient.firstName} {patient.lastName} - {preAuth.procedureName}

-
- ID: {preAuth.id} - - Requested: {format(preAuth.requestDate, 'MMM dd, yyyy')} - {preAuth.status === 'approved' && ( - <> - - Expires: {format(preAuth.expirationDate as Date, 'MMM dd, yyyy')} - - )} - {preAuth.status === 'denied' && preAuth.denialReason && ( - <> - - Reason: {preAuth.denialReason} - - )} -
-
-
- - {preAuth.status === 'approved' ? ( - - - Approved - - ) : ( - - - Denied - - )} - -
-
- ); - })} - - {samplePreAuths.filter(auth => auth.status !== 'pending').length === 0 && ( -
- -

No completed requests

-

- Processed pre-authorization results will appear here -

-
- )} -
- ) : ( -
- -

No pre-authorization results

-

- Completed pre-authorization requests will appear here -

-
- )} -
-
-
-
- ); -} \ No newline at end of file diff --git a/apps/Frontend/src/pages/reports-page.tsx b/apps/Frontend/src/pages/reports-page.tsx index d4189d4..58114cd 100644 --- a/apps/Frontend/src/pages/reports-page.tsx +++ b/apps/Frontend/src/pages/reports-page.tsx @@ -1,18 +1,10 @@ +// apps/Frontend/src/pages/reports-page.tsx import { useState } from "react"; import { useQuery } from "@tanstack/react-query"; -import { Input } from "@/components/ui/input"; -import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { - Search, - Edit, - Eye, - ChevronLeft, - ChevronRight, - Settings, -} from "lucide-react"; -import { useAuth } from "@/hooks/use-auth"; -import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; import { Select, SelectContent, @@ -20,285 +12,465 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { Patient } from "@repo/db/types"; -import { formatDateToHumanReadable } from "@/utils/dateUtils"; +import { Badge } from "@/components/ui/badge"; +import { Separator } from "@/components/ui/separator"; +import { + DollarSign, + FileText, + Download, + AlertCircle, + Calendar, + Users, + TrendingUp, +} from "lucide-react"; +import { useAuth } from "@/hooks/use-auth"; +import { apiRequest } from "@/lib/queryClient"; // <<-- your helper +import type { PatientBalanceRow } from "@repo/db/types"; + +type ReportType = + | "patients_with_balance" + | "patients_no_balance" + | "monthly_collections" + | "collections_by_doctor" + | "procedure_codes_by_doctor" + | "payment_methods" + | "insurance_vs_patient_payments" + | "aging_report"; + +interface PatientBalancesResponse { + balances: PatientBalanceRow[]; + totalCount: number; +} export default function ReportsPage() { const { user } = useAuth(); - const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); - const [searchTerm, setSearchTerm] = useState(""); - const [searchField, setSearchField] = useState("all"); - const [currentPage, setCurrentPage] = useState(1); - const itemsPerPage = 5; - // Fetch patients - const { data: patients = [], isLoading: isLoadingPatients } = useQuery< - Patient[] - >({ - queryKey: ["/api/patients"], + // pagination state for patient balances + const [balancesPage, setBalancesPage] = useState(1); + const balancesPerPage = 10; + + // date range state (for dashboard summary) + const [startDate, setStartDate] = useState(() => { + const d = new Date(); + d.setMonth(d.getMonth() - 1); + return d.toISOString().split("T")[0]; + }); + const [endDate, setEndDate] = useState( + () => new Date().toISOString().split("T")[0] + ); + + const [selectedReportType, setSelectedReportType] = useState( + "patients_with_balance" + ); + const [isGenerating, setIsGenerating] = useState(false); + + // --- 1) patient balances (paginated) using apiRequest --- + const { + data: patientBalancesResponse, + isLoading: isLoadingBalances, + isError: isErrorBalances, + } = useQuery({ + queryKey: [ + "/api/payments-reports/patient-balances", + balancesPage, + balancesPerPage, + startDate, + endDate, + selectedReportType, + ], + queryFn: async () => { + const offset = (balancesPage - 1) * balancesPerPage; + const minBalanceFlag = selectedReportType === "patients_with_balance"; + const endpoint = `/api/payments-reports/patient-balances?limit=${balancesPerPage}&offset=${offset}&minBalance=${minBalanceFlag}&from=${encodeURIComponent( + String(startDate) + )}&to=${encodeURIComponent(String(endDate))}`; + const res = await apiRequest("GET", endpoint); + if (!res.ok) { + const body = await res + .json() + .catch(() => ({ message: "Failed to load patient balances" })); + throw new Error(body.message || "Failed to load patient balances"); + } + return res.json(); + }, enabled: !!user, }); - // Filter patients based on search - const filteredPatients = patients.filter((patient) => { - if (!searchTerm) return true; + const patientBalances: PatientBalanceRow[] = + patientBalancesResponse?.balances ?? []; + const patientBalancesTotal = patientBalancesResponse?.totalCount ?? 0; - const searchLower = searchTerm.toLowerCase(); - const fullName = `${patient.firstName} ${patient.lastName}`.toLowerCase(); - const patientId = `PID-${patient?.id?.toString().padStart(4, "0")}`; - - switch (searchField) { - case "name": - return fullName.includes(searchLower); - case "id": - return patientId.toLowerCase().includes(searchLower); - case "phone": - return patient.phone?.toLowerCase().includes(searchLower) || false; - case "all": - default: - return ( - fullName.includes(searchLower) || - patientId.toLowerCase().includes(searchLower) || - patient.phone?.toLowerCase().includes(searchLower) || - patient.email?.toLowerCase().includes(searchLower) || - false - ); - } + // --- 2) dashboard summary (separate route/storage) using apiRequest --- + const { data: dashboardSummary, isLoading: isLoadingSummary } = useQuery({ + queryKey: [ + "/api/payments-reports/summary", + String(startDate), + String(endDate), + ], + queryFn: async () => { + const endpoint = `/api/payments-reports/summary?from=${encodeURIComponent( + String(startDate) + )}&to=${encodeURIComponent(String(endDate))}`; + const res = await apiRequest("GET", endpoint); + if (!res.ok) { + 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(); + }, + enabled: !!user, }); - // Pagination - const totalPages = Math.ceil(filteredPatients.length / itemsPerPage); - const startIndex = (currentPage - 1) * itemsPerPage; - const endIndex = startIndex + itemsPerPage; - const currentPatients = filteredPatients.slice(startIndex, endIndex); - - const toggleMobileMenu = () => { - setIsMobileMenuOpen(!isMobileMenuOpen); + // format currency for numbers in dollars (storage returns decimal numbers like 123.45) + const formatCurrency = (amountDollars: number | undefined | null) => { + const value = Number(amountDollars ?? 0); + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + }).format(value); }; - const getPatientInitials = (firstName: string, lastName: string) => { - return `${firstName.charAt(0)}${lastName.charAt(0)}`.toUpperCase(); + // summary stats: use dashboardSummary for totals (server-driven) and derive other counts from paginated balances + const summaryStats = { + totalPatients: dashboardSummary?.totalPatients ?? 0, + // use the server-provided count of patients with balance inside range + patientsWithBalance: dashboardSummary?.patientsWithBalance ?? 0, + // patientsNoBalance: based on totalCount - patientsWithBalance (note: totalCount is number of patients with payments in range) + patientsNoBalance: Math.max( + 0, + (dashboardSummary?.totalPatients ?? 0) - + (dashboardSummary?.patientsWithBalance ?? 0) + ), + totalOutstanding: + dashboardSummary?.totalOutstanding ?? + patientBalances.reduce((s, b) => s + (b.currentBalance ?? 0), 0), + totalCollected: dashboardSummary?.totalCollected ?? 0, + }; + + const generateReport = async () => { + setIsGenerating(true); + await new Promise((r) => setTimeout(r, 900)); + setIsGenerating(false); + }; + + // -------------------- report rendering (only patients_with_balance wired) -------------------- + // -------------------- report rendering (only patients_with_balance wired) -------------------- + const renderPatientsWithBalance = () => { + // Use patientBalances for the current page list (already minBalance filtered if selectedReportType === 'patients_with_balance') + const patientsWithBalance = patientBalances + .filter((b) => (b.currentBalance ?? 0) > 0) + .map((b) => ({ + patientId: b.patientId, + patientName: `${b.firstName ?? "Unknown"} ${b.lastName ?? ""}`.trim(), + currentBalance: b.currentBalance ?? 0, + totalCharges: b.totalCharges ?? 0, + totalPayments: b.totalPayments ?? 0, + })); + + const totalOutstanding = patientsWithBalance.reduce( + (s, p) => s + p.currentBalance, + 0 + ); + const avgBalance = patientsWithBalance.length + ? totalOutstanding / patientsWithBalance.length + : 0; + + return ( +
+
+ + +
+ {summaryStats.patientsWithBalance} +
+

Patients with Balance

+
+
+ + + +
+ {formatCurrency(summaryStats.totalOutstanding)} +
+

Total Outstanding

+
+
+ + + +
+ {formatCurrency(avgBalance)} +
+

+ Average Balance (visible page) +

+
+
+
+ +
+
+

+ Patients with Outstanding Balances +

+
+ +
+ {patientsWithBalance.length === 0 ? ( +
+ +

No patients have outstanding balances on this page

+
+ ) : ( + patientsWithBalance.map((p) => ( +
+
+
+

+ {p.patientName} +

+

+ Patient ID: {p.patientId} +

+
+ +
+
+ {formatCurrency(p.currentBalance)} +
+
+ Charges: {formatCurrency(p.totalCharges)} +
+
+
+
+ )) + )} +
+
+ + {/* pagination controls for balances */} +
+
+ Showing {(balancesPage - 1) * balancesPerPage + 1} -{" "} + {Math.min(balancesPage * balancesPerPage, patientBalancesTotal)} of{" "} + {patientBalancesTotal} +
+ +
+ + +
+
+
+ ); + }; + + const renderReportContent = () => { + if (isLoadingBalances || isLoadingSummary) { + return ( +
+
+

Loading report data...

+
+ ); + } + + if ((patientBalances?.length ?? 0) === 0) { + return ( +
+ +

+ Financial Data Not Available +

+

+ No patient balance data yet. Add payments/service lines to populate + reports. +

+
+

+ Date range: {startDate} to {endDate} +

+
+
+ ); + } + + switch (selectedReportType) { + case "patients_with_balance": + return renderPatientsWithBalance(); + + default: + return ( +
+ +

+ Report Type Not Implemented +

+

+ The "{selectedReportType}" report is being developed. For now use + "Patients with Outstanding Balance". +

+
+ ); + } }; return (
-
- {/* Header */} -
-

Reports

+
+
+

+ Financial Reports +

- View and manage all patient information + Generate comprehensive financial reports for your practice

- {/* Search and Filters */} - - -
-
- - setSearchTerm(e.target.value)} - className="pl-10" - /> -
-
- - -
-
-
-
- - {/* Patient List */} - - - {isLoadingPatients ? ( -
Loading patients...
- ) : ( - <> - {/* Table Header */} -
-
Patient
-
DOB / Gender
-
Contact
-
Insurance
-
Status
-
Actions
-
- - {/* Table Rows */} - {currentPatients.length === 0 ? ( -
- {searchTerm - ? "No patients found matching your search." - : "No patients available."} -
- ) : ( - currentPatients.map((patient) => ( -
- {/* Patient Info */} -
-
- {getPatientInitials( - patient.firstName, - patient.lastName - )} -
-
-
- {patient.firstName} {patient.lastName} -
-
- PID-{patient.id?.toString().padStart(4, "0")} -
-
-
- - {/* DOB / Gender */} -
-
- {formatDateToHumanReadable(patient.dateOfBirth)} -
-
- {patient.gender} -
-
- - {/* Contact */} -
-
- {patient.phone || "Not provided"} -
-
- {patient.email || "No email"} -
-
- - {/* Insurance */} -
-
- {patient.insuranceProvider - ? `${patient.insuranceProvider.charAt(0).toUpperCase()}${patient.insuranceProvider.slice(1)}` - : "Not specified"} -
-
- ID: {patient.insuranceId || "N/A"} -
-
- - {/* Status */} -
- - {patient.status === "active" ? "Active" : "Inactive"} - -
- - {/* Actions */} -
-
- - -
-
-
- )) - )} - - {/* Pagination */} - {totalPages > 1 && ( -
-
- Showing {startIndex + 1} to{" "} - {Math.min(endIndex, filteredPatients.length)} of{" "} - {filteredPatients.length} results -
-
- - - {/* Page Numbers */} - {Array.from({ length: totalPages }, (_, i) => i + 1).map( - (page) => ( - - ) - )} - - -
-
- )} - - )} -
-
+
+ + + + + + Report Configuration + + + + +
+
+ + setStartDate(e.target.value)} + /> +
+ +
+ + setEndDate(e.target.value)} + /> +
+ +
+ + +
+
+ + + +
+
+
+ {summaryStats.totalPatients} +
+

Total Patients

+
+ +
+
+ {summaryStats.patientsWithBalance} +
+

With Balance

+
+ +
+
+ {summaryStats.patientsNoBalance} +
+

Zero Balance

+
+ +
+
+ {formatCurrency(summaryStats.totalOutstanding)} +
+

Outstanding

+
+ +
+
+ {formatCurrency(summaryStats.totalCollected)} +
+

Collected

+
+
+
+
+ + + + + {selectedReportType === "patients_with_balance" + ? "Patients with Outstanding Balance" + : selectedReportType} + + + + {renderReportContent()} +
); } diff --git a/packages/db/types/index.ts b/packages/db/types/index.ts index 64b16ae..1e40544 100644 --- a/packages/db/types/index.ts +++ b/packages/db/types/index.ts @@ -8,4 +8,5 @@ export * from "./staff-types"; export * from "./user-types"; export * from "./databaseBackup-types"; export * from "./notifications-types"; -export * from "./cloudStorage-types"; \ No newline at end of file +export * from "./cloudStorage-types"; +export * from "./payments-reports-types"; \ No newline at end of file diff --git a/packages/db/types/payments-reports-types.ts b/packages/db/types/payments-reports-types.ts new file mode 100644 index 0000000..f58c762 --- /dev/null +++ b/packages/db/types/payments-reports-types.ts @@ -0,0 +1,11 @@ +export interface PatientBalanceRow { + patientId: number; + firstName: string | null; + lastName: string | null; + totalCharges: number; + totalPayments: number; + totalAdjusted: number; + currentBalance: number; + lastPaymentDate: string | null; + lastAppointmentDate: string | null; +} \ No newline at end of file