diff --git a/apps/Backend/src/routes/payments-reports.ts b/apps/Backend/src/routes/payments-reports.ts index ca28412..d3ba7ed 100644 --- a/apps/Backend/src/routes/payments-reports.ts +++ b/apps/Backend/src/routes/payments-reports.ts @@ -3,26 +3,6 @@ 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) @@ -53,8 +33,7 @@ router.get("/summary", async (req: Request, res: Response): Promise => { * GET /api/payments-reports/patient-balances * query: * - limit (default 25) - * - offset (default 0) - * - minBalance (true|false) + * - cursor (optional base64 cursor token) * - from / to (optional ISO date strings) - filter payments by createdAt in the range (inclusive) */ router.get( @@ -65,9 +44,9 @@ router.get( 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 cursor = + typeof req.query.cursor === "string" ? String(req.query.cursor) : null; const from = req.query.from ? new Date(String(req.query.from)) @@ -81,13 +60,8 @@ router.get( return res.status(400).json({ message: "Invalid 'to' date" }); } - const data = await storage.getPatientBalances( - limit, - offset, - from, - to, - minBalance - ); + const data = await storage.getPatientBalances(limit, cursor, from, to); + // returns { balances, totalCount, nextCursor, hasMore } res.json(data); } catch (err: any) { console.error( @@ -100,22 +74,4 @@ router.get( } ); -// // 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/payments-reports-storage.ts b/apps/Backend/src/storage/payments-reports-storage.ts index 60cf711..a0ec727 100644 --- a/apps/Backend/src/storage/payments-reports-storage.ts +++ b/apps/Backend/src/storage/payments-reports-storage.ts @@ -14,16 +14,29 @@ export interface PatientBalanceRow { currentBalance: number; lastPaymentDate: string | null; lastAppointmentDate: string | null; + patientCreatedAt?: string | null; +} + +export interface GetPatientBalancesResult { + balances: PatientBalanceRow[]; + totalCount: number; + nextCursor: string | null; + 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, - offset: number, + cursorToken?: string | null, from?: Date | null, - to?: Date | null, - minBalanceOnly?: boolean - ): Promise<{ balances: PatientBalanceRow[]; totalCount: number }>; + to?: Date | null + ): Promise; // summary now returns an extra field patientsWithBalance getSummary( @@ -44,13 +57,57 @@ function fmtDateLiteral(d?: Date | null): string | null { return `'${iso}'`; } +/** Cursor helpers — base64(JSON) */ +function encodeCursor(obj: { + lastPaymentDate: string | null; + lastPatientCreatedAt: string; // ISO string + lastPatientId: number; +}) { + return Buffer.from(JSON.stringify(obj)).toString("base64"); +} + +function decodeCursor(token?: string | null): { + lastPaymentDate: string | null; + lastPatientCreatedAt: string; + lastPatientId: number; +} | null { + if (!token) return null; + try { + const parsed = JSON.parse(Buffer.from(token, "base64").toString("utf8")); + if ( + typeof parsed === "object" && + "lastPaymentDate" in parsed && + "lastPatientCreatedAt" in parsed && + "lastPatientId" in parsed + ) { + return { + lastPaymentDate: + parsed.lastPaymentDate === null + ? null + : String(parsed.lastPaymentDate), + lastPatientCreatedAt: String(parsed.lastPatientCreatedAt), + lastPatientId: Number(parsed.lastPatientId), + }; + } + return null; + } catch { + return null; + } +} + export const paymentsReportsStorage: IPaymentsReportsStorage = { + /** + * Returns all patients that currently have an outstanding balance (>0) + * Optionally filtered by date range. + */ + /** + * Cursor-based getPatientBalances + */ async getPatientBalances( limit = 25, - offset = 0, + cursorToken?: string | null, from?: Date | null, - to?: Date | null, - minBalanceOnly = false + to?: Date | null ) { try { type RawRow = { @@ -58,91 +115,92 @@ export const paymentsReportsStorage: IPaymentsReportsStorage = { first_name: string | null; last_name: string | null; total_charges: string; - total_payments: string; + total_paid: string; total_adjusted: string; current_balance: string; last_payment_date: Date | null; last_appointment_date: Date | null; + patient_created_at: Date | null; // we select patient.createdAt for cursor tie-breaker }; const safeLimit = Math.max(1, Math.min(200, Number(limit) || 25)); - const safeOffset = Math.max(0, Number(offset) || 0); + const cursor = decodeCursor(cursorToken); 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 + // Build payment subquery (aggregated payments by patient, filtered by createdAt if provided) + const paymentWhereClause = + hasFrom && hasTo + ? `WHERE pay."createdAt" >= ${fromLit} AND pay."createdAt" <= ${toLit}` + : hasFrom + ? `WHERE pay."createdAt" >= ${fromLit}` + : hasTo + ? `WHERE pay."createdAt" <= ${toLit}` + : ""; + + const 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 + ${paymentWhereClause} + GROUP BY pay."patientId" + ) 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 + ? `'${cursor.lastPaymentDate}'` + : "NULL"; + const lc = `'${cursor.lastPatientCreatedAt}'`; + 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 ( + -- case: both sides have non-null last_payment_date + (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."createdAt" < ${lc}) + OR (pm.last_payment_date = ${lp} AND p."createdAt" = ${lc} AND p.id < ${id}) + )) + -- case: cursor lastPaymentDate IS NULL -> we need rows with last_payment_date IS NULL but earlier createdAt/id + OR (pm.last_payment_date IS NULL AND ${lp} IS NULL AND ( + p."createdAt" < ${lc} + OR (p."createdAt" = ${lc} AND p.id < ${id}) + )) + -- case: cursor had non-null lastPaymentDate but pm.last_payment_date IS NULL: + -- since NULLS LAST, pm.last_payment_date IS NULL are after non-null dates, so they are NOT < cursor -> excluded + ) `; } - const baseQuery = ` + 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_payments, + 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, pm.last_payment_date, - apt.last_appointment_date + apt.last_appointment_date, + p."createdAt" AS patient_created_at FROM "Patient" p LEFT JOIN ${pmSubquery} ON pm.patient_id = p.id LEFT JOIN ( @@ -150,120 +208,64 @@ 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 `; - const balanceWhere = `(COALESCE(pm.total_charges,0) - COALESCE(pm.total_paid,0) - COALESCE(pm.total_adjusted,0)) > 0`; + const orderBy = `ORDER BY pm.last_payment_date DESC NULLS LAST, p."createdAt" DESC, p.id DESC`; + const limitClause = `LIMIT ${safeLimit}`; - 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}`; + const query = ` + ${baseSelect} + ${cursor ? keysetPredicate : ""} + ${orderBy} + ${limitClause}; + `; - // Execute query - const rows = (await prisma.$queryRawUnsafe(finalQuery)) as RawRow[]; + const rows = (await prisma.$queryRawUnsafe(query)) 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 - `; - } + // Build nextCursor from last returned row (if any) + let nextCursor: string | null = null; + + // Explicitly handle empty result set + if (rows.length === 0) { + nextCursor = null; } 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 - `; + // rows.length > 0 here, but do an explicit last-check to make TS happy + const last = rows[rows.length - 1]; + if (!last) { + // defensive — should not happen, but satisfies strict checks + nextCursor = null; } else { - countSql = `SELECT COUNT(DISTINCT "patientId")::int AS cnt FROM "Payment"`; + const lastPaymentDateIso = last.last_payment_date + ? new Date(last.last_payment_date).toISOString() + : null; + + const lastPatientCreatedAtIso = last.patient_created_at + ? new Date(last.patient_created_at).toISOString() + : new Date().toISOString(); + + if (rows.length === safeLimit) { + nextCursor = encodeCursor({ + lastPaymentDate: lastPaymentDateIso, + lastPatientCreatedAt: lastPatientCreatedAtIso, + lastPatientId: Number(last.patient_id), + }); + } else { + nextCursor = null; + } } } - const cntRows = (await prisma.$queryRawUnsafe(countSql)) as { - cnt: number; - }[]; - const totalCount = cntRows?.[0]?.cnt ?? 0; + // Determine hasMore: if we returned exactly limit, there *may* be more. + const hasMore = rows.length === safeLimit; + // Convert rows to PatientBalanceRow 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), + totalPayments: Number(r.total_paid ?? 0), totalAdjusted: Number(r.total_adjusted ?? 0), currentBalance: Number(r.current_balance ?? 0), lastPaymentDate: r.last_payment_date @@ -272,9 +274,35 @@ export const paymentsReportsStorage: IPaymentsReportsStorage = { lastAppointmentDate: r.last_appointment_date ? new Date(r.last_appointment_date).toISOString() : null, + patientCreatedAt: r.patient_created_at + ? new Date(r.patient_created_at).toISOString() + : null, })); - return { balances, totalCount }; + // 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(pay."totalPaid")::numeric(14,2) AS total_paid, + SUM(pay."totalAdjusted")::numeric(14,2) AS total_adjusted + 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; + `; + const cntRows = (await prisma.$queryRawUnsafe(countSql)) as { + cnt: number; + }[]; + const totalCount = cntRows?.[0]?.cnt ?? 0; + + return { + balances, + totalCount, + nextCursor, + hasMore, + }; } catch (err) { console.error("[paymentsReportsStorage.getPatientBalances] error:", err); throw err; diff --git a/apps/Frontend/src/components/reports/collections-by-doctor-report.tsx b/apps/Frontend/src/components/reports/collections-by-doctor-report.tsx new file mode 100644 index 0000000..dc9eee8 --- /dev/null +++ b/apps/Frontend/src/components/reports/collections-by-doctor-report.tsx @@ -0,0 +1,181 @@ +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"; + +type DoctorOption = { id: string; name: string }; +type DoctorCollectionRow = { + doctorId: string; + doctorName: string; + totalCollected?: number; + totalCharges?: number; + totalPayments?: number; + currentBalance?: number; +}; + +type CollectionsResp = { + rows: DoctorCollectionRow[]; + totalCount?: number; + nextCursor?: string | null; + hasMore?: boolean; +}; + +export default function CollectionsByDoctorReport({ + startDate, + endDate, +}: { + startDate: string; + endDate: string; +}) { + const [doctorId, setDoctorId] = 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 + + // load doctors for selector + const { data: doctors } = useQuery({ + queryKey: ["doctors"], + queryFn: async () => { + const res = await apiRequest("GET", "/api/doctors"); + if (!res.ok) { + const b = await res + .json() + .catch(() => ({ message: "Failed to load doctors" })); + throw new Error(b.message || "Failed to load doctors"); + } + return res.json(); + }, + staleTime: 60_000, + }); + + // rows (collections by doctor) - cursor-based request + const { + data: collectionData, + isLoading: isLoadingRows, + isError: isErrorRows, + refetch, + } = useQuery({ + queryKey: [ + "collections-by-doctor-rows", + doctorId, + currentCursor, + perPage, + startDate, + endDate, + ], + queryFn: async () => { + const params = new URLSearchParams(); + params.set("limit", String(perPage)); + if (currentCursor) params.set("cursor", currentCursor); + if (doctorId) params.set("doctorId", doctorId); + 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()}` + ); + if (!res.ok) { + const b = await res + .json() + .catch(() => ({ message: "Failed to load collections" })); + throw new Error(b.message || "Failed to load collections"); + } + return res.json(); + }, + enabled: true, + staleTime: 30_000, + }); + + // derived pagination info + const rows = collectionData?.rows ?? []; + const totalCount = collectionData?.totalCount ?? undefined; + const nextCursor = collectionData?.nextCursor ?? null; + const hasMore = collectionData?.hasMore ?? false; + + // reset cursor when filters change (doctor/date) + 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]); + + const handlePrev = useCallback(() => { + setCursorIndex((i) => Math.max(0, i - 1)); + }, []); + + // Map doctor rows into GenericRow (consistent) + const mapDoctorToGeneric = (r: DoctorCollectionRow): GenericRow => { + const totalCharges = Number(r.totalCharges ?? 0); + const totalPayments = Number(r.totalCollected ?? r.totalPayments ?? 0); + return { + id: r.doctorId, + name: r.doctorName, + currentBalance: 0, + totalCharges, + totalPayments, + }; + }; + + const genericRows: GenericRow[] = rows.map(mapDoctorToGeneric); + + return ( +
+
+
+ + +
+
+ + 0} + hasNext={hasMore} + /> +
+ ); +} diff --git a/apps/Frontend/src/components/reports/pagination-controls.tsx b/apps/Frontend/src/components/reports/pagination-controls.tsx new file mode 100644 index 0000000..5357a7c --- /dev/null +++ b/apps/Frontend/src/components/reports/pagination-controls.tsx @@ -0,0 +1,71 @@ +import React from "react"; +import { + Pagination, + PaginationContent, + PaginationItem, + PaginationNext, + PaginationPrevious, +} from "@/components/ui/pagination"; + +export default function PaginationControls({ + pageIndex, + perPage, + total, + onPrev, + onNext, + hasPrev, + hasNext, +}: { + /** 1-based page index (for display). Pass cursorIndex + 1 from parent. */ + pageIndex: number; + perPage: number; + /** optional totalCount from backend (if provided) */ + total?: number | undefined; + onPrev: () => void; + onNext: () => void; + hasPrev: boolean; + hasNext: boolean; +}) { + const startItem = total === 0 ? 0 : (pageIndex - 1) * perPage + 1; + const endItem = Math.min(pageIndex * perPage, total ?? pageIndex * perPage); + + return ( +
+
+ {typeof total === "number" + ? `Showing ${startItem}-${endItem} of ${total}` + : `Page ${pageIndex}`} +
+ +
+ + + + { + e.preventDefault(); + if (hasPrev) onPrev(); + }} + className={hasPrev ? "" : "pointer-events-none opacity-50"} + /> + + +
+ + + { + e.preventDefault(); + if (hasNext) onNext(); + }} + className={hasNext ? "" : "pointer-events-none opacity-50"} + /> + + + +
+
+ ); +} diff --git a/apps/Frontend/src/components/reports/patients-balances-list.tsx b/apps/Frontend/src/components/reports/patients-balances-list.tsx new file mode 100644 index 0000000..cfebc0e --- /dev/null +++ b/apps/Frontend/src/components/reports/patients-balances-list.tsx @@ -0,0 +1,130 @@ +import React from "react"; +import { DollarSign } from "lucide-react"; +import PaginationControls from "./pagination-controls"; + +export type GenericRow = { + id: string | number; + name: string; + currentBalance: number; + totalCharges: number; + totalPayments: number; +}; + +export default function PatientsBalancesList({ + rows, + reportType, + loading, + error, + emptyMessage, + pageIndex = 1, // 1-based + perPage = 10, + total, // optional totalCount from backend + onPrev, + onNext, + hasPrev, + hasNext, +}: { + rows: GenericRow[]; + reportType?: string | null; + loading?: boolean; + error?: string | boolean; + emptyMessage?: string; + // cursor props (required) + pageIndex?: number; + perPage?: number; + total?: number | undefined; + onPrev: () => void; + onNext: () => void; + hasPrev: boolean; + hasNext: boolean; +}) { + const fmt = (v: number) => + new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + }).format(v); + + const reportTypeTitle = (rt?: string | null) => { + switch (rt) { + case "patients_with_balance": + return "Patients with Outstanding Balances"; + case "patients_no_balance": + return "Patients with Zero Balance"; + case "monthly_collections": + return "Monthly Collections"; + case "collections_by_doctor": + return "Collections by Doctor"; + default: + return "Balances"; + } + }; + + return ( +
+
+
+

+ {reportTypeTitle(reportType)} +

+
+ +
+ {loading ? ( +
+
+
Loading {reportType ?? "data"}…
+
+ ) : error ? ( +
+
Could not fetch data
+
+ {typeof error === "string" + ? error + : "An error occurred while loading the report."} +
+
+ ) : rows.length === 0 ? ( +
+ +

{emptyMessage ?? "No rows for this report."}

+
+ ) : ( + rows.map((r) => ( +
+
+
+

{r.name}

+

ID: {r.id}

+
+ +
+
+ {fmt(r.currentBalance)} +
+
+ Charges: {fmt(r.totalCharges)} · Collected:{" "} + {fmt(r.totalPayments)} +
+
+
+
+ )) + )} +
+ + {/* Cursor pagination footer (cursor-only) */} +
+ +
+
+
+ ); +} diff --git a/apps/Frontend/src/components/reports/patients-with-balance-report.tsx b/apps/Frontend/src/components/reports/patients-with-balance-report.tsx new file mode 100644 index 0000000..e2bec2f --- /dev/null +++ b/apps/Frontend/src/components/reports/patients-with-balance-report.tsx @@ -0,0 +1,125 @@ +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"; + +type Resp = { + balances: PatientBalanceRow[]; + totalCount?: number; // optional + nextCursor?: string | null; + hasMore?: boolean; +}; + +export default function PatientsWithBalanceReport({ + startDate, + endDate, +}: { + startDate: string; + endDate: string; +}) { + const balancesPerPage = 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 { data, isLoading, isError, refetch } = useQuery({ + queryKey: [ + "/api/payments-reports/patient-balances", + currentCursor, + balancesPerPage, + startDate, + endDate, + ], + 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); + const res = await apiRequest( + "GET", + `/api/payments-reports/patient-balances?${params.toString()}` + ); + 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: true, + staleTime: 30_000, + }); + + const balances = data?.balances ?? []; + const totalCount = data?.totalCount ?? undefined; + const nextCursor = data?.nextCursor ?? null; + const hasMore = data?.hasMore ?? false; + + useEffect(() => { + setCursorStack([null]); + setCursorIndex(0); + refetch(); + }, [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]); + + const handlePrev = useCallback(() => { + setCursorIndex((i) => Math.max(0, i - 1)); + }, []); + + const normalized = balances.map((b) => { + const currentBalance = Number(b.currentBalance ?? 0); + const totalCharges = Number(b.totalCharges ?? 0); + const totalPayments = + b.totalPayments != null + ? Number(b.totalPayments) + : Number(totalCharges - currentBalance); + return { + id: b.patientId, + name: `${b.firstName ?? "Unknown"} ${b.lastName ?? ""}`.trim(), + currentBalance, + totalCharges, + totalPayments, + }; + }); + + return ( +
+
+ 0} + hasNext={hasMore} + /> +
+
+ ); +} diff --git a/apps/Frontend/src/components/reports/report-config.tsx b/apps/Frontend/src/components/reports/report-config.tsx new file mode 100644 index 0000000..01e00c8 --- /dev/null +++ b/apps/Frontend/src/components/reports/report-config.tsx @@ -0,0 +1,137 @@ +import React from "react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Calendar } from "lucide-react"; +import { formatLocalDate, parseLocalDate } from "@/utils/dateUtils"; +import { DateInput } from "@/components/ui/dateInput"; + +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"; + +export default function ReportConfig({ + startDate, + endDate, + setStartDate, + setEndDate, + selectedReportType, + setSelectedReportType, +}: { + startDate: string; // "" or "YYYY-MM-DD" + endDate: string; + setStartDate: (s: string) => void; + setEndDate: (s: string) => void; + selectedReportType: ReportType; + setSelectedReportType: (r: ReportType) => void; +}) { + // Convert incoming string -> Date | null using your parseLocalDate utility. + // parseLocalDate can throw for invalid strings, so guard with try/catch. + let startDateObj: Date | null = null; + if (startDate) { + try { + startDateObj = parseLocalDate(startDate); + } catch { + startDateObj = null; + } + } + + let endDateObj: Date | null = null; + if (endDate) { + try { + endDateObj = parseLocalDate(endDate); + } catch { + endDateObj = null; + } + } + + return ( + + + + Report Configuration + + + + +
+ Choose the report type and date range. +
+ +
+
+ { + setStartDate(d ? formatLocalDate(d) : ""); + }} + disableFuture + /> +
+ +
+ { + setEndDate(d ? formatLocalDate(d) : ""); + }} + disableFuture + /> +
+ +
+ + +
+
+
+
+ ); +} diff --git a/apps/Frontend/src/components/reports/summary-cards.tsx b/apps/Frontend/src/components/reports/summary-cards.tsx new file mode 100644 index 0000000..41a5eda --- /dev/null +++ b/apps/Frontend/src/components/reports/summary-cards.tsx @@ -0,0 +1,116 @@ +import React from "react"; +import { Card, CardContent } from "@/components/ui/card"; +import { useQuery } from "@tanstack/react-query"; +import { apiRequest } from "@/lib/queryClient"; + +type SummaryResp = { + totalPatients?: number; + patientsWithBalance?: number; + totalOutstanding?: number; + totalCollected?: number; +}; + +function fmtCurrency(v: number) { + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + }).format(v); +} + +export default function SummaryCards({ + startDate, + endDate, +}: { + startDate: string; + endDate: string; +}) { + // Query the server summary for the given date range + const { data, isLoading, isError } = useQuery({ + queryKey: ["/api/payments-reports/summary", startDate, endDate], + queryFn: async () => { + const params = new URLSearchParams(); + if (startDate) params.set("from", startDate); + if (endDate) params.set("to", endDate); + const endpoint = `/api/payments-reports/summary?${params.toString()}`; + 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: Boolean(startDate && endDate), + staleTime: 30_000, + }); + + 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; + + return ( + + + {/* Heading */} +
+

+ Report summary +

+

+ Data covers the selected time frame +

+
+ + {/* Stats grid */} +
+
+
+ {isLoading ? "—" : totalPatients} +
+

Total Patients

+
+ +
+
+ {isLoading ? "—" : patientsWithBalance} +
+

With Balance

+
+ +
+
+ {isLoading ? "—" : patientsNoBalance} +
+

Zero Balance

+
+ +
+
+ {isLoading ? "—" : fmtCurrency(totalOutstanding)} +
+

Outstanding

+
+ +
+
+ {isLoading ? "—" : fmtCurrency(totalCollected)} +
+

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 58114cd..c8795b2 100644 --- a/apps/Frontend/src/pages/reports-page.tsx +++ b/apps/Frontend/src/pages/reports-page.tsx @@ -1,31 +1,11 @@ -// apps/Frontend/src/pages/reports-page.tsx -import { useState } from "react"; -import { useQuery } from "@tanstack/react-query"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { Badge } from "@/components/ui/badge"; -import { Separator } from "@/components/ui/separator"; -import { - DollarSign, - FileText, - Download, - AlertCircle, - Calendar, - Users, - TrendingUp, -} from "lucide-react"; +import React, { useState } from "react"; +import { Download } from "lucide-react"; import { useAuth } from "@/hooks/use-auth"; -import { apiRequest } from "@/lib/queryClient"; // <<-- your helper -import type { PatientBalanceRow } from "@repo/db/types"; +import ReportConfig from "@/components/reports/report-config"; +import PatientsWithBalanceReport from "@/components/reports/patients-with-balance-report"; +import CollectionsByDoctorReport from "@/components/reports/collections-by-doctor-report"; +import SummaryCards from "@/components/reports/summary-cards"; +import { Button } from "@/components/ui/button"; type ReportType = | "patients_with_balance" @@ -37,26 +17,16 @@ type ReportType = | "insurance_vs_patient_payments" | "aging_report"; -interface PatientBalancesResponse { - balances: PatientBalanceRow[]; - totalCount: number; -} - -export default function ReportsPage() { +export default function ReportPage() { const { user } = useAuth(); - // pagination state for patient balances - const [balancesPage, setBalancesPage] = useState(1); - const balancesPerPage = 10; - - // date range state (for dashboard summary) - const [startDate, setStartDate] = useState(() => { + const [startDate, setStartDate] = useState(() => { const d = new Date(); d.setMonth(d.getMonth() - 1); - return d.toISOString().split("T")[0]; + return d.toISOString().split("T")[0] ?? ""; }); - const [endDate, setEndDate] = useState( - () => new Date().toISOString().split("T")[0] + const [endDate, setEndDate] = useState( + () => new Date().toISOString().split("T")[0] ?? "" ); const [selectedReportType, setSelectedReportType] = useState( @@ -64,413 +34,73 @@ export default function ReportsPage() { ); 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, - }); - - const patientBalances: PatientBalanceRow[] = - patientBalancesResponse?.balances ?? []; - const patientBalancesTotal = patientBalancesResponse?.totalCount ?? 0; - - // --- 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, - }); - - // 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); - }; - - // 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); + try { + // placeholder: implement export per-report endpoint + await new Promise((r) => setTimeout(r, 900)); + } finally { + 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; - + if (!user) { 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} -
- -
- - -
-
-
+
Please sign in to view reports.
); - }; - - 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 Section */} +
-

+

Financial Reports

-

+

Generate comprehensive financial reports for your practice

+ {/* Export Button (Top Right) */}
- - - - - Report Configuration - - +
+ +
- -
-
- - setStartDate(e.target.value)} - /> -
+ {/* SINGLE authoritative SummaryCards instance for the page */} +
+ +
-
- - setEndDate(e.target.value)} - /> -
+
+ {selectedReportType === "patients_with_balance" && ( + + )} -
- - -
-
+ {selectedReportType === "collections_by_doctor" && ( + + )} - - -
-
-
- {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()} - + {/* Add other report components here as needed */} +
); } diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 24c4e38..7424fe6 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -243,6 +243,7 @@ model Payment { @@index([claimId]) @@index([patientId]) + @@index([createdAt]) } model ServiceLineTransaction {