feat(patient - financial tabular view) - added
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user