From 64e338ba60279875be7bbd0e68614e5cbfc96ca0 Mon Sep 17 00:00:00 2001 From: Potenz Date: Wed, 8 Oct 2025 05:01:12 +0530 Subject: [PATCH] feat(patient - financial tabular view) - added --- apps/Backend/src/routes/patients.ts | 32 ++ apps/Backend/src/storage/patients-storage.ts | 166 +++++++++- .../patients/patient-financial-modal.tsx | 301 ++++++++++++++++++ .../src/components/patients/patient-table.tsx | 47 ++- apps/Frontend/src/pages/patients-page.tsx | 69 +--- packages/db/types/patient-types.ts | 15 + 6 files changed, 554 insertions(+), 76 deletions(-) create mode 100644 apps/Frontend/src/components/patients/patient-financial-modal.tsx diff --git a/apps/Backend/src/routes/patients.ts b/apps/Backend/src/routes/patients.ts index 027b19b..3cce1bd 100644 --- a/apps/Backend/src/routes/patients.ts +++ b/apps/Backend/src/routes/patients.ts @@ -131,6 +131,38 @@ router.get( } ); +// GET /api/patients/:id/financials?limit=50&offset=0 +router.get( + "/:id/financials", + async (req: Request, res: Response): Promise => { + try { + const patientIdParam = req.params.id; + if (!patientIdParam) + return res.status(400).json({ message: "Patient ID required" }); + + const patientId = parseInt(patientIdParam, 10); + if (isNaN(patientId)) + return res.status(400).json({ message: "Invalid patient ID" }); + + const limit = Math.min(1000, Number(req.query.limit ?? 50)); // cap maximums + const offset = Math.max(0, Number(req.query.offset ?? 0)); + + const { rows, totalCount } = await storage.getPatientFinancialRows( + patientId, + limit, + offset + ); + + return res.json({ rows, totalCount, limit, offset }); + } catch (err) { + console.error("Failed to fetch financial rows:", err); + return res + .status(500) + .json({ message: "Failed to fetch financial rows" }); + } + } +); + // Get a single patient by ID router.get( "/:id", diff --git a/apps/Backend/src/storage/patients-storage.ts b/apps/Backend/src/storage/patients-storage.ts index 685a586..b837163 100644 --- a/apps/Backend/src/storage/patients-storage.ts +++ b/apps/Backend/src/storage/patients-storage.ts @@ -1,4 +1,9 @@ -import { InsertPatient, Patient, UpdatePatient } from "@repo/db/types"; +import { + FinancialRow, + InsertPatient, + Patient, + UpdatePatient, +} from "@repo/db/types"; import { prisma as db } from "@repo/db/client"; export interface IStorage { @@ -30,6 +35,11 @@ export interface IStorage { >; getTotalPatientCount(): Promise; countPatients(filters: any): Promise; // optional but useful + getPatientFinancialRows( + patientId: number, + limit?: number, + offset?: number + ): Promise<{ rows: any[]; totalCount: number }>; } export const patientsStorage: IStorage = { @@ -138,4 +148,158 @@ export const patientsStorage: IStorage = { async countPatients(filters: any) { return db.patient.count({ where: filters }); }, + + async getPatientFinancialRows(patientId: number, limit = 50, offset = 0) { + return getPatientFinancialRowsFn(patientId, limit, offset); + }, +}; + +export const getPatientFinancialRowsFn = async ( + patientId: number, + limit = 50, + offset = 0 +): Promise<{ rows: FinancialRow[]; totalCount: number }> => { + try { + // counts + const [[{ count_claims }], [{ count_payments_without_claim }]] = + (await Promise.all([ + db.$queryRaw`SELECT COUNT(1) AS count_claims FROM "Claim" c WHERE c."patientId" = ${patientId}`, + db.$queryRaw`SELECT COUNT(1) AS count_payments_without_claim FROM "Payment" p WHERE p."patientId" = ${patientId} AND p."claimId" IS NULL`, + ])) as any; + + const totalCount = + Number(count_claims ?? 0) + Number(count_payments_without_claim ?? 0); + + const rawRows = (await db.$queryRaw` + WITH claim_rows AS ( + SELECT + 'CLAIM'::text AS type, + c.id, + COALESCE(c."serviceDate", c."createdAt")::timestamptz AS date, + c."createdAt"::timestamptz AS created_at, + c.status::text AS status, + COALESCE(sum(sl."totalBilled")::numeric::text, '0') AS total_billed, + COALESCE(sum(sl."totalPaid")::numeric::text, '0') AS total_paid, + COALESCE(sum(sl."totalAdjusted")::numeric::text, '0') AS total_adjusted, + COALESCE(sum(sl."totalDue")::numeric::text, '0') AS total_due, + ( + SELECT (pat."firstName" || ' ' || pat."lastName") FROM "Patient" pat WHERE pat.id = c."patientId" LIMIT 1 + ) AS patient_name, + ( + SELECT coalesce(json_agg( + json_build_object( + 'id', sl2.id, + 'procedureCode', sl2."procedureCode", + 'procedureDate', sl2."procedureDate", + 'toothNumber', sl2."toothNumber", + 'toothSurface', sl2."toothSurface", + 'totalBilled', sl2."totalBilled", + 'totalPaid', sl2."totalPaid", + 'totalAdjusted', sl2."totalAdjusted", + 'totalDue', sl2."totalDue", + 'status', sl2.status + ) + ), '[]'::json) + FROM "ServiceLine" sl2 WHERE sl2."claimId" = c.id + ) AS service_lines, + ( + SELECT coalesce(json_agg( + json_build_object( + 'id', p2.id, + 'totalBilled', p2."totalBilled", + 'totalPaid', p2."totalPaid", + 'totalAdjusted', p2."totalAdjusted", + 'totalDue', p2."totalDue", + 'status', p2.status::text, + 'createdAt', p2."createdAt", + 'icn', p2.icn, + 'notes', p2.notes + ) + ), '[]'::json) + FROM "Payment" p2 WHERE p2."claimId" = c.id + ) AS payments + FROM "Claim" c + LEFT JOIN "ServiceLine" sl ON sl."claimId" = c.id + WHERE c."patientId" = ${patientId} + GROUP BY c.id + ), + payment_rows AS ( + SELECT + 'PAYMENT'::text AS type, + p.id, + p."createdAt"::timestamptz AS date, + p."createdAt"::timestamptz AS created_at, + p.status::text AS status, + p."totalBilled"::numeric::text AS total_billed, + p."totalPaid"::numeric::text AS total_paid, + p."totalAdjusted"::numeric::text AS total_adjusted, + p."totalDue"::numeric::text AS total_due, + ( + SELECT (pat."firstName" || ' ' || pat."lastName") FROM "Patient" pat WHERE pat.id = p."patientId" LIMIT 1 + ) AS patient_name, + -- aggregate service lines that belong to this payment (if any) + ( + SELECT coalesce(json_agg( + json_build_object( + 'id', sl3.id, + 'procedureCode', sl3."procedureCode", + 'procedureDate', sl3."procedureDate", + 'toothNumber', sl3."toothNumber", + 'toothSurface', sl3."toothSurface", + 'totalBilled', sl3."totalBilled", + 'totalPaid', sl3."totalPaid", + 'totalAdjusted', sl3."totalAdjusted", + 'totalDue', sl3."totalDue", + 'status', sl3.status + ) + ), '[]'::json) + FROM "ServiceLine" sl3 WHERE sl3."paymentId" = p.id + ) AS service_lines, + json_build_array( + json_build_object( + 'id', p.id, + 'totalBilled', p."totalBilled", + 'totalPaid', p."totalPaid", + 'totalAdjusted', p."totalAdjusted", + 'totalDue', p."totalDue", + 'status', p.status::text, + 'createdAt', p."createdAt", + 'icn', p.icn, + 'notes', p.notes + ) + ) AS payments + FROM "Payment" p + WHERE p."patientId" = ${patientId} AND p."claimId" IS NULL + ) + SELECT type, id, date, created_at, status, total_billed, total_paid, total_adjusted, total_due, patient_name, service_lines, payments + FROM ( + SELECT * FROM claim_rows + UNION ALL + SELECT * FROM payment_rows + ) t + ORDER BY t.created_at DESC + LIMIT ${limit} OFFSET ${offset} + `) as any[]; + + // map to expected JS shape; convert totals to numbers + const rows: FinancialRow[] = rawRows.map((r: any) => ({ + type: r.type, + id: Number(r.id), + date: r.date ? r.date.toString() : null, + createdAt: r.created_at ? r.created_at.toString() : null, + status: r.status ?? null, + total_billed: Number(r.total_billed ?? 0), + total_paid: Number(r.total_paid ?? 0), + total_adjusted: Number(r.total_adjusted ?? 0), + total_due: Number(r.total_due ?? 0), + patient_name: r.patient_name ?? null, + service_lines: r.service_lines ?? [], + payments: r.payments ?? [], + })); + + return { rows, totalCount }; + } catch (err) { + console.error("getPatientFinancialRowsFn error:", err); + throw err; + } }; diff --git a/apps/Frontend/src/components/patients/patient-financial-modal.tsx b/apps/Frontend/src/components/patients/patient-financial-modal.tsx new file mode 100644 index 0000000..76e7a14 --- /dev/null +++ b/apps/Frontend/src/components/patients/patient-financial-modal.tsx @@ -0,0 +1,301 @@ +import React, { useEffect, useMemo, useState } from "react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from "@/components/ui/dialog"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Button } from "@/components/ui/button"; +import { + Pagination, + PaginationContent, + PaginationItem, + PaginationLink, + PaginationNext, + PaginationPrevious, +} from "@/components/ui/pagination"; +import { apiRequest } from "@/lib/queryClient"; +import LoadingScreen from "../ui/LoadingScreen"; +import { useToast } from "@/hooks/use-toast"; +import { useLocation } from "wouter"; +import { FinancialRow } from "@repo/db/types"; + +function getPageNumbers(current: number, total: number): (number | "...")[] { + const delta = 2; + const range: (number | "...")[] = []; + const left = Math.max(2, current - delta); + const right = Math.min(total - 1, current + delta); + + range.push(1); + if (left > 2) range.push("..."); + for (let i = left; i <= right; i++) range.push(i); + if (right < total - 1) range.push("..."); + if (total > 1) range.push(total); + + return range; +} + +export function PatientFinancialsModal({ + patientId, + open, + onOpenChange, +}: { + patientId: number | null; + open: boolean; + onOpenChange: (v: boolean) => void; +}) { + const [rows, setRows] = useState([]); + const [loading, setLoading] = useState(false); + const [limit, setLimit] = useState(50); + const [offset, setOffset] = useState(0); + const [totalCount, setTotalCount] = useState(0); + const [, navigate] = useLocation(); + const { toast } = useToast(); + + // patient summary to show in header + const [patientName, setPatientName] = useState(null); + const [patientPID, setPatientPID] = useState(null); + + useEffect(() => { + if (!open || !patientId) return; + fetchPatient(); + fetchRows(); + }, [open, patientId, limit, offset]); + + async function fetchPatient() { + try { + const res = await apiRequest("GET", `/api/patients/${patientId}`); + if (!res.ok) { + return; + } + const patient = await res.json(); + setPatientName(`${patient.firstName} ${patient.lastName}`); + setPatientPID(patient.id); + } catch (err) { + console.error("Failed to fetch patient", err); + } + } + + async function fetchRows() { + if (!patientId) return; + setLoading(true); + try { + const url = `/api/patients/${patientId}/financials?limit=${limit}&offset=${offset}`; + const res = await apiRequest("GET", url); + if (!res.ok) { + const err = await res.json(); + throw new Error(err.message || "Failed to load"); + } + const data = await res.json(); + setRows(data.rows || []); + setTotalCount(Number(data.totalCount || 0)); + } catch (err: any) { + console.error(err); + toast?.({ + title: "Error", + description: err.message || "Failed to load financials", + variant: "destructive", + }); + } finally { + setLoading(false); + } + } + + function gotoRow(r: FinancialRow) { + if (r.type === "CLAIM") navigate(`/claims/${r.id}`); + else navigate(`/payments/${r.id}`); + onOpenChange(false); + } + + const currentPage = Math.floor(offset / limit) + 1; + const totalPages = Math.max(1, Math.ceil(totalCount / limit)); + + function setPage(page: number) { + if (page < 1) page = 1; + if (page > totalPages) page = totalPages; + setOffset((page - 1) * limit); + } + + const startItem = useMemo(() => Math.min(offset + 1, totalCount || 0), [offset, totalCount]); + const endItem = useMemo(() => Math.min(offset + limit, totalCount || 0), [offset, limit, totalCount]); + + return ( + + +
+
+
+ Financials + + {patientName ? ( + <> + {patientName}{" "} + {patientPID && • PID-{String(patientPID).padStart(4, "0")}} + + ) : ( + "Claims, payments and balances for this patient." + )} + +
+ +
+ +
+
+
+ +
+
+
+ + + + Type + Date + Procedures Codes + Billed + Paid + Adjusted + Total Due + Status + + + + + {loading ? ( + + + + + + ) : rows.length === 0 ? ( + + + No records found. + + + ) : ( + rows.map((r) => { + const billed = Number(r.total_billed ?? 0); + const paid = Number(r.total_paid ?? 0); + const adjusted = Number(r.total_adjusted ?? 0); + const totalDue = Number(r.total_due ?? 0); + + const procedureCodes = + (r.service_lines || []) + .map((sl: any) => sl.procedureCode ?? sl.procedureCode) + .filter(Boolean) + .join(", ") || (r.payments?.length ? "No Codes Given" : "-"); + + return ( + gotoRow(r)} + > + {r.type} + {r.date ? new Date(r.date).toLocaleDateString() : "-"} + {procedureCodes} + {billed.toFixed(2)} + {paid.toFixed(2)} + {adjusted.toFixed(2)} + 0 ? "text-red-600" : "text-green-600"}`}> + {totalDue.toFixed(2)} + + {r.status ?? "-"} + + ); + }) + )} + +
+
+
+
+ +
+
+
+
+ + +
+ +
+ Showing {startItem}{endItem} of {totalCount} +
+
+ +
+ + + + { + e.preventDefault(); + if (currentPage > 1) setPage(currentPage - 1); + }} + className={currentPage === 1 ? "pointer-events-none opacity-50" : ""} + /> + + + {getPageNumbers(currentPage, totalPages).map((page, idx) => ( + + {page === "..." ? ( + + ) : ( + { + e.preventDefault(); + setPage(Number(page)); + }} + isActive={currentPage === page} + > + {page} + + )} + + ))} + + + { + e.preventDefault(); + if (currentPage < totalPages) setPage(currentPage + 1); + }} + className={currentPage === totalPages ? "pointer-events-none opacity-50" : ""} + /> + + + +
+
+
+
+
+ ); +} diff --git a/apps/Frontend/src/components/patients/patient-table.tsx b/apps/Frontend/src/components/patients/patient-table.tsx index 86dfb7a..1b4ec83 100644 --- a/apps/Frontend/src/components/patients/patient-table.tsx +++ b/apps/Frontend/src/components/patients/patient-table.tsx @@ -20,7 +20,7 @@ import { } from "@/components/ui/pagination"; import { apiRequest, queryClient } from "@/lib/queryClient"; import { useMutation, useQuery } from "@tanstack/react-query"; -import LoadingScreen from "../ui/LoadingScreen"; +import LoadingScreen from "@/components/ui/LoadingScreen"; import { useToast } from "@/hooks/use-toast"; import { Dialog, @@ -30,14 +30,15 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { AddPatientModal } from "./add-patient-modal"; -import { DeleteConfirmationDialog } from "../ui/deleteDialog"; +import { DeleteConfirmationDialog } from "@/components/ui/deleteDialog"; import { useAuth } from "@/hooks/use-auth"; import { PatientSearch, SearchCriteria } from "./patient-search"; import { useDebounce } from "use-debounce"; import { cn } from "@/lib/utils"; -import { Checkbox } from "../ui/checkbox"; +import { Checkbox } from "@/components/ui/checkbox"; import { formatDateToHumanReadable } from "@/utils/dateUtils"; import { Patient, UpdatePatient } from "@repo/db/types"; +import { PatientFinancialsModal } from "./patient-financial-modal"; interface PatientApiResponse { patients: Patient[]; @@ -50,6 +51,7 @@ interface PatientTableProps { allowDelete?: boolean; allowCheckbox?: boolean; allowNewClaim?: boolean; + allowFinancial?: boolean; onNewClaim?: (patientId: number) => void; onSelectPatient?: (patient: Patient | null) => void; onPageChange?: (page: number) => void; @@ -68,6 +70,7 @@ export function PatientTable({ allowDelete, allowCheckbox, allowNewClaim, + allowFinancial, onNewClaim, onSelectPatient, onPageChange, @@ -76,13 +79,16 @@ export function PatientTable({ const { toast } = useToast(); const { user } = useAuth(); - const [isAddPatientOpen, setIsAddPatientOpen] = useState(false); - const [isViewPatientOpen, setIsViewPatientOpen] = useState(false); - const [isDeletePatientOpen, setIsDeletePatientOpen] = useState(false); const [currentPatient, setCurrentPatient] = useState( undefined ); + const [isAddPatientOpen, setIsAddPatientOpen] = useState(false); + const [isViewPatientOpen, setIsViewPatientOpen] = useState(false); + const [isDeletePatientOpen, setIsDeletePatientOpen] = useState(false); + const [isFinancialsOpen, setIsFinancialsOpen] = useState(false); + const [modalPatientId, setModalPatientId] = useState(null); + const [currentPage, setCurrentPage] = useState(1); const patientsPerPage = 5; const offset = (currentPage - 1) * patientsPerPage; @@ -457,10 +463,25 @@ export function PatientTable({ aria-label="Delete Staff" variant="ghost" size="icon" + title="Delete Patient" > )} + {allowFinancial && ( + + )} {allowEdit && ( @@ -479,7 +501,7 @@ export function PatientTable({ size="icon" onClick={() => onNewClaim?.(Number(patient.id))} className="text-green-600 hover:text-green-800 hover:bg-green-50" - aria-label="New Claim" + title="New Claim" > @@ -492,6 +514,7 @@ export function PatientTable({ handleViewPatient(patient); }} className="text-gray-600 hover:text-gray-800 hover:bg-gray-50" + title="View Patient Info" > @@ -673,6 +696,16 @@ export function PatientTable({ patient={currentPatient} /> + {/* Financial Modal */} + { + setIsFinancialsOpen(v); + if (!v) setModalPatientId(null); + }} + /> + ( undefined ); - const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); const addPatientModalRef = useRef(null); // File upload states @@ -81,10 +80,6 @@ export default function PatientsPage() { }, }); - const toggleMobileMenu = () => { - setIsMobileMenuOpen(!isMobileMenuOpen); - }; - const handleAddPatient = (patient: InsertPatient) => { if (user) { addPatientMutation.mutate({ @@ -94,69 +89,6 @@ export default function PatientsPage() { } }; - // helper: ensure patient exists (returns patient object) - const ensurePatientExists = async (data: { - name: string; - memberId: string; - dob: string; - }) => { - try { - // 1) try to find by insurance id - const findRes = await apiRequest( - "GET", - `/api/patients/by-insurance-id?insuranceId=${encodeURIComponent( - data.memberId - )}` - ); - if (findRes.ok) { - const found = await findRes.json(); - if (found && found.id) return found; - } else { - // If API returns a non-ok with body, try to parse a possible 404-with-JSON - try { - const body = await findRes.json(); - if (body && body.id) return body; - } catch { - // ignore - } - } - - // 2) not found -> create patient - const [firstName, ...rest] = (data.name || "").trim().split(" "); - const lastName = rest.join(" ") || ""; - const parsedDob = parse(data.dob, "M/d/yyyy", new Date()); // robust for "4/17/1964", "12/1/1975", etc. - - // convert dob to whatever format your API expects. Here we keep as received. - const newPatient: InsertPatient = { - firstName: firstName || "", - lastName: lastName || "", - dateOfBirth: formatLocalDate(parsedDob), - gender: "", - phone: "", - userId: user?.id ?? 1, - status: "active", - insuranceId: data.memberId || "", - }; - - const createRes = await apiRequest("POST", "/api/patients/", newPatient); - if (!createRes.ok) { - // surface error - let body: any = null; - try { - body = await createRes.json(); - } catch {} - throw new Error( - body?.message || - `Failed to create patient (status ${createRes.status})` - ); - } - const created = await createRes.json(); - return created; - } catch (err) { - throw err; - } - }; - const isLoading = addPatientMutation.isPending; // File upload handling @@ -498,6 +430,7 @@ export default function PatientsPage() { allowDelete={true} allowEdit={true} allowView={true} + allowFinancial={true} /> diff --git a/packages/db/types/patient-types.ts b/packages/db/types/patient-types.ts index 5f764a2..decf996 100644 --- a/packages/db/types/patient-types.ts +++ b/packages/db/types/patient-types.ts @@ -55,3 +55,18 @@ export const updatePatientSchema = ( }); export type UpdatePatient = z.infer; + +export type FinancialRow = { + type: "CLAIM" | "PAYMENT"; + id: number; + date: string | null; + createdAt: string | null; + status: string | null; + total_billed: number; + total_paid: number; + total_adjusted: number; + total_due: number; + patient_name: string | null; + service_lines: any[]; + payments: any[]; +}; \ No newline at end of file