feat(patient - financial tabular view) - added
This commit is contained in:
@@ -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
|
// Get a single patient by ID
|
||||||
router.get(
|
router.get(
|
||||||
"/:id",
|
"/:id",
|
||||||
|
|||||||
@@ -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";
|
import { prisma as db } from "@repo/db/client";
|
||||||
|
|
||||||
export interface IStorage {
|
export interface IStorage {
|
||||||
@@ -30,6 +35,11 @@ export interface IStorage {
|
|||||||
>;
|
>;
|
||||||
getTotalPatientCount(): Promise<number>;
|
getTotalPatientCount(): Promise<number>;
|
||||||
countPatients(filters: any): Promise<number>; // optional but useful
|
countPatients(filters: any): Promise<number>; // optional but useful
|
||||||
|
getPatientFinancialRows(
|
||||||
|
patientId: number,
|
||||||
|
limit?: number,
|
||||||
|
offset?: number
|
||||||
|
): Promise<{ rows: any[]; totalCount: number }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const patientsStorage: IStorage = {
|
export const patientsStorage: IStorage = {
|
||||||
@@ -138,4 +148,158 @@ export const patientsStorage: IStorage = {
|
|||||||
async countPatients(filters: any) {
|
async countPatients(filters: any) {
|
||||||
return db.patient.count({ where: filters });
|
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;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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";
|
} from "@/components/ui/pagination";
|
||||||
import { apiRequest, queryClient } from "@/lib/queryClient";
|
import { apiRequest, queryClient } from "@/lib/queryClient";
|
||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
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 { useToast } from "@/hooks/use-toast";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -30,14 +30,15 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { AddPatientModal } from "./add-patient-modal";
|
import { AddPatientModal } from "./add-patient-modal";
|
||||||
import { DeleteConfirmationDialog } from "../ui/deleteDialog";
|
import { DeleteConfirmationDialog } from "@/components/ui/deleteDialog";
|
||||||
import { useAuth } from "@/hooks/use-auth";
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
import { PatientSearch, SearchCriteria } from "./patient-search";
|
import { PatientSearch, SearchCriteria } from "./patient-search";
|
||||||
import { useDebounce } from "use-debounce";
|
import { useDebounce } from "use-debounce";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Checkbox } from "../ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { formatDateToHumanReadable } from "@/utils/dateUtils";
|
import { formatDateToHumanReadable } from "@/utils/dateUtils";
|
||||||
import { Patient, UpdatePatient } from "@repo/db/types";
|
import { Patient, UpdatePatient } from "@repo/db/types";
|
||||||
|
import { PatientFinancialsModal } from "./patient-financial-modal";
|
||||||
|
|
||||||
interface PatientApiResponse {
|
interface PatientApiResponse {
|
||||||
patients: Patient[];
|
patients: Patient[];
|
||||||
@@ -50,6 +51,7 @@ interface PatientTableProps {
|
|||||||
allowDelete?: boolean;
|
allowDelete?: boolean;
|
||||||
allowCheckbox?: boolean;
|
allowCheckbox?: boolean;
|
||||||
allowNewClaim?: boolean;
|
allowNewClaim?: boolean;
|
||||||
|
allowFinancial?: boolean;
|
||||||
onNewClaim?: (patientId: number) => void;
|
onNewClaim?: (patientId: number) => void;
|
||||||
onSelectPatient?: (patient: Patient | null) => void;
|
onSelectPatient?: (patient: Patient | null) => void;
|
||||||
onPageChange?: (page: number) => void;
|
onPageChange?: (page: number) => void;
|
||||||
@@ -68,6 +70,7 @@ export function PatientTable({
|
|||||||
allowDelete,
|
allowDelete,
|
||||||
allowCheckbox,
|
allowCheckbox,
|
||||||
allowNewClaim,
|
allowNewClaim,
|
||||||
|
allowFinancial,
|
||||||
onNewClaim,
|
onNewClaim,
|
||||||
onSelectPatient,
|
onSelectPatient,
|
||||||
onPageChange,
|
onPageChange,
|
||||||
@@ -76,13 +79,16 @@ export function PatientTable({
|
|||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
|
||||||
const [isAddPatientOpen, setIsAddPatientOpen] = useState(false);
|
|
||||||
const [isViewPatientOpen, setIsViewPatientOpen] = useState(false);
|
|
||||||
const [isDeletePatientOpen, setIsDeletePatientOpen] = useState(false);
|
|
||||||
const [currentPatient, setCurrentPatient] = useState<Patient | undefined>(
|
const [currentPatient, setCurrentPatient] = useState<Patient | undefined>(
|
||||||
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 [currentPage, setCurrentPage] = useState(1);
|
||||||
const patientsPerPage = 5;
|
const patientsPerPage = 5;
|
||||||
const offset = (currentPage - 1) * patientsPerPage;
|
const offset = (currentPage - 1) * patientsPerPage;
|
||||||
@@ -457,10 +463,25 @@ export function PatientTable({
|
|||||||
aria-label="Delete Staff"
|
aria-label="Delete Staff"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
|
title="Delete Patient"
|
||||||
>
|
>
|
||||||
<Delete />
|
<Delete />
|
||||||
</Button>
|
</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 && (
|
{allowEdit && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -469,6 +490,7 @@ export function PatientTable({
|
|||||||
handleEditPatient(patient);
|
handleEditPatient(patient);
|
||||||
}}
|
}}
|
||||||
className="text-blue-600 hover:text-blue-800 hover:bg-blue-50"
|
className="text-blue-600 hover:text-blue-800 hover:bg-blue-50"
|
||||||
|
title="Edit Patient"
|
||||||
>
|
>
|
||||||
<Edit className="h-4 w-4" />
|
<Edit className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -479,7 +501,7 @@ export function PatientTable({
|
|||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => onNewClaim?.(Number(patient.id))}
|
onClick={() => onNewClaim?.(Number(patient.id))}
|
||||||
className="text-green-600 hover:text-green-800 hover:bg-green-50"
|
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" />
|
<FileCheck className="h-5 w-5" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -492,6 +514,7 @@ export function PatientTable({
|
|||||||
handleViewPatient(patient);
|
handleViewPatient(patient);
|
||||||
}}
|
}}
|
||||||
className="text-gray-600 hover:text-gray-800 hover:bg-gray-50"
|
className="text-gray-600 hover:text-gray-800 hover:bg-gray-50"
|
||||||
|
title="View Patient Info"
|
||||||
>
|
>
|
||||||
<Eye className="h-4 w-4" />
|
<Eye className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -673,6 +696,16 @@ export function PatientTable({
|
|||||||
patient={currentPatient}
|
patient={currentPatient}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Financial Modal */}
|
||||||
|
<PatientFinancialsModal
|
||||||
|
patientId={modalPatientId}
|
||||||
|
open={isFinancialsOpen}
|
||||||
|
onOpenChange={(v) => {
|
||||||
|
setIsFinancialsOpen(v);
|
||||||
|
if (!v) setModalPatientId(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<DeleteConfirmationDialog
|
<DeleteConfirmationDialog
|
||||||
isOpen={isDeletePatientOpen}
|
isOpen={isDeletePatientOpen}
|
||||||
onConfirm={handleConfirmDeletePatient}
|
onConfirm={handleConfirmDeletePatient}
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ export default function PatientsPage() {
|
|||||||
const [currentPatient, setCurrentPatient] = useState<Patient | undefined>(
|
const [currentPatient, setCurrentPatient] = useState<Patient | undefined>(
|
||||||
undefined
|
undefined
|
||||||
);
|
);
|
||||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
|
||||||
const addPatientModalRef = useRef<AddPatientModalRef | null>(null);
|
const addPatientModalRef = useRef<AddPatientModalRef | null>(null);
|
||||||
|
|
||||||
// File upload states
|
// File upload states
|
||||||
@@ -81,10 +80,6 @@ export default function PatientsPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const toggleMobileMenu = () => {
|
|
||||||
setIsMobileMenuOpen(!isMobileMenuOpen);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAddPatient = (patient: InsertPatient) => {
|
const handleAddPatient = (patient: InsertPatient) => {
|
||||||
if (user) {
|
if (user) {
|
||||||
addPatientMutation.mutate({
|
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;
|
const isLoading = addPatientMutation.isPending;
|
||||||
|
|
||||||
// File upload handling
|
// File upload handling
|
||||||
@@ -498,6 +430,7 @@ export default function PatientsPage() {
|
|||||||
allowDelete={true}
|
allowDelete={true}
|
||||||
allowEdit={true}
|
allowEdit={true}
|
||||||
allowView={true}
|
allowView={true}
|
||||||
|
allowFinancial={true}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -55,3 +55,18 @@ export const updatePatientSchema = (
|
|||||||
});
|
});
|
||||||
|
|
||||||
export type UpdatePatient = z.infer<typeof updatePatientSchema>;
|
export type UpdatePatient = z.infer<typeof updatePatientSchema>;
|
||||||
|
|
||||||
|
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[];
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user