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

@@ -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>