feat(patient - financial tabular view) - added

This commit is contained in:
2025-10-08 05:01:12 +05:30
parent 7e53dd0454
commit 64e338ba60
6 changed files with 554 additions and 76 deletions

View File

@@ -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<any> => {
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",

View File

@@ -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<number>;
countPatients(filters: any): Promise<number>; // 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;
}
};

View File

@@ -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<FinancialRow[]>([]);
const [loading, setLoading] = useState(false);
const [limit, setLimit] = useState<number>(50);
const [offset, setOffset] = useState<number>(0);
const [totalCount, setTotalCount] = useState<number>(0);
const [, navigate] = useLocation();
const { toast } = useToast();
// patient summary to show in header
const [patientName, setPatientName] = useState<string | null>(null);
const [patientPID, setPatientPID] = useState<number | null>(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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-6xl w-[95%] p-0 overflow-hidden">
<div className="border-b px-6 py-4">
<div className="flex items-start justify-between gap-4">
<div>
<DialogTitle className="text-lg">Financials</DialogTitle>
<DialogDescription className="text-sm text-muted-foreground">
{patientName ? (
<>
<span className="font-medium">{patientName}</span>{" "}
{patientPID && <span className="text-muted-foreground"> PID-{String(patientPID).padStart(4, "0")}</span>}
</>
) : (
"Claims, payments and balances for this patient."
)}
</DialogDescription>
</div>
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm" onClick={() => onOpenChange(false)}>
Close
</Button>
</div>
</div>
</div>
<div className="px-6 py-4">
<div className="border rounded-md overflow-hidden">
<div className="max-h-[56vh] overflow-auto">
<Table className="min-w-full">
<TableHeader className="sticky top-0 bg-white z-10">
<TableRow>
<TableHead className="w-24">Type</TableHead>
<TableHead className="w-36">Date</TableHead>
<TableHead>Procedures Codes</TableHead>
<TableHead className="text-right w-28">Billed</TableHead>
<TableHead className="text-right w-28">Paid</TableHead>
<TableHead className="text-right w-28">Adjusted</TableHead>
<TableHead className="text-right w-28">Total Due</TableHead>
<TableHead className="w-28">Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={8} className="text-center py-12">
<LoadingScreen />
</TableCell>
</TableRow>
) : rows.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className="text-center py-8 text-muted-foreground">
No records found.
</TableCell>
</TableRow>
) : (
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 (
<TableRow
key={`${r.type}-${r.id}`}
className="cursor-pointer hover:bg-gray-50"
onClick={() => gotoRow(r)}
>
<TableCell className="font-medium">{r.type}</TableCell>
<TableCell>{r.date ? new Date(r.date).toLocaleDateString() : "-"}</TableCell>
<TableCell className="text-sm text-muted-foreground">{procedureCodes}</TableCell>
<TableCell className="text-right">{billed.toFixed(2)}</TableCell>
<TableCell className="text-right">{paid.toFixed(2)}</TableCell>
<TableCell className="text-right">{adjusted.toFixed(2)}</TableCell>
<TableCell className={`text-right ${totalDue > 0 ? "text-red-600" : "text-green-600"}`}>
{totalDue.toFixed(2)}
</TableCell>
<TableCell>{r.status ?? "-"}</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</div>
</div>
</div>
<div className="border-t px-6 py-3 bg-white">
<div className="flex flex-col md:flex-row items-center md:items-center justify-between gap-3">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<label className="text-sm text-muted-foreground">Rows:</label>
<select
value={limit}
onChange={(e) => {
setLimit(Number(e.target.value));
setOffset(0);
}}
className="border rounded px-2 py-1 text-sm"
>
<option value={50}>50</option>
<option value={100}>100</option>
</select>
</div>
<div className="text-sm text-muted-foreground">
Showing <span className="font-medium">{startItem}</span><span className="font-medium">{endItem}</span> of <span className="font-medium">{totalCount}</span>
</div>
</div>
<div className="flex items-center gap-4">
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
href="#"
onClick={(e) => {
e.preventDefault();
if (currentPage > 1) setPage(currentPage - 1);
}}
className={currentPage === 1 ? "pointer-events-none opacity-50" : ""}
/>
</PaginationItem>
{getPageNumbers(currentPage, totalPages).map((page, idx) => (
<PaginationItem key={idx}>
{page === "..." ? (
<span className="px-2 text-gray-500"></span>
) : (
<PaginationLink
href="#"
onClick={(e) => {
e.preventDefault();
setPage(Number(page));
}}
isActive={currentPage === page}
>
{page}
</PaginationLink>
)}
</PaginationItem>
))}
<PaginationItem>
<PaginationNext
href="#"
onClick={(e) => {
e.preventDefault();
if (currentPage < totalPages) setPage(currentPage + 1);
}}
className={currentPage === totalPages ? "pointer-events-none opacity-50" : ""}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -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<Patient | undefined>(
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<number | null>(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"
>
<Delete />
</Button>
)}
{allowFinancial && (
<Button
variant="ghost"
size="icon"
onClick={() => {
setModalPatientId(Number(patient.id));
setIsFinancialsOpen(true);
}}
className="text-indigo-600 hover:text-indigo-800 hover:bg-indigo-50"
title="View financials"
>
<FileCheck className="h-5 w-5" />
</Button>
)}
{allowEdit && (
<Button
variant="ghost"
@@ -469,6 +490,7 @@ export function PatientTable({
handleEditPatient(patient);
}}
className="text-blue-600 hover:text-blue-800 hover:bg-blue-50"
title="Edit Patient"
>
<Edit className="h-4 w-4" />
</Button>
@@ -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"
>
<FileCheck className="h-5 w-5" />
</Button>
@@ -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"
>
<Eye className="h-4 w-4" />
</Button>
@@ -673,6 +696,16 @@ export function PatientTable({
patient={currentPatient}
/>
{/* Financial Modal */}
<PatientFinancialsModal
patientId={modalPatientId}
open={isFinancialsOpen}
onOpenChange={(v) => {
setIsFinancialsOpen(v);
if (!v) setModalPatientId(null);
}}
/>
<DeleteConfirmationDialog
isOpen={isDeletePatientOpen}
onConfirm={handleConfirmDeletePatient}

View File

@@ -37,7 +37,6 @@ export default function PatientsPage() {
const [currentPatient, setCurrentPatient] = useState<Patient | undefined>(
undefined
);
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const addPatientModalRef = useRef<AddPatientModalRef | null>(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}
/>
</CardContent>
</Card>