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
|
||||
router.get(
|
||||
"/: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";
|
||||
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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