Files
DentalManagement2025/apps/Frontend/src/pages/patients-page.tsx

499 lines
16 KiB
TypeScript

import { useState, useRef, useCallback } from "react";
import { useMutation } from "@tanstack/react-query";
import { PatientTable } from "@/components/patients/patient-table";
import { AddPatientModal } from "@/components/patients/add-patient-modal";
import { Button } from "@/components/ui/button";
import { Plus, RefreshCw, FilePlus } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { apiRequest, queryClient } from "@/lib/queryClient";
import { useAuth } from "@/hooks/use-auth";
import useExtractPdfData from "@/hooks/use-extractPdfData";
import { useLocation } from "wouter";
import { InsertPatient, Patient } from "@repo/db/types";
import { QK_PATIENTS_BASE } from "@/components/patients/patient-table";
import { parse } from "date-fns";
import { formatLocalDate } from "@/utils/dateUtils";
import {
MultipleFileUploadZone,
MultipleFileUploadZoneHandle,
} from "@/components/file-upload/multiple-file-upload-zone";
// Type for the ref to access modal methods
type AddPatientModalRef = {
shouldSchedule: boolean;
shouldClaim: boolean;
navigateToSchedule: (patientId: number) => void;
navigateToClaim: (patientId: number) => void;
};
export default function PatientsPage() {
const { toast } = useToast();
const { user } = useAuth();
const [isAddPatientOpen, setIsAddPatientOpen] = useState(false);
const [currentPatient, setCurrentPatient] = useState<Patient | undefined>(
undefined
);
const addPatientModalRef = useRef<AddPatientModalRef | null>(null);
// File upload states
const uploadRef = useRef<MultipleFileUploadZoneHandle | null>(null);
const [uploadedFiles, setUploadedFiles] = useState<File[]>([]);
const [isUploading, setIsUploading] = useState(false);
// extraction state (single boolean for whole process)
const [isExtracting, setIsExtracting] = useState(false);
const { mutate: extractPdf } = useExtractPdfData();
const [location, navigate] = useLocation();
// Add patient mutation
const addPatientMutation = useMutation({
mutationFn: async (patient: InsertPatient) => {
const res = await apiRequest("POST", "/api/patients/", patient);
return res.json();
},
onSuccess: (newPatient) => {
setIsAddPatientOpen(false);
queryClient.invalidateQueries({ queryKey: QK_PATIENTS_BASE });
toast({
title: "Success",
description: "Patient added successfully!",
variant: "default",
});
// ✅ Check claim first, then schedule
if (addPatientModalRef.current?.shouldClaim) {
addPatientModalRef.current.navigateToClaim(newPatient.id);
return;
}
if (addPatientModalRef.current?.shouldSchedule) {
addPatientModalRef.current.navigateToSchedule(newPatient.id);
return;
}
},
onError: (error) => {
toast({
title: "Error",
description: `Failed to add patient: ${error.message}`,
variant: "destructive",
});
},
});
const handleAddPatient = (patient: InsertPatient) => {
if (user) {
addPatientMutation.mutate({
...patient,
userId: user.id,
});
}
};
const isLoading = addPatientMutation.isPending;
// Hook up file-change coming from MultipleFileUploadZone
const handleFilesChange = (files: File[]) => {
// ensure we only keep PDFs (defensive)
const pdfs = files.filter((f) => f.type === "application/pdf");
if (pdfs.length !== files.length) {
toast({
title: "Non-PDF ignored",
description:
"Only PDF files are accepted — other file types were ignored.",
variant: "destructive",
});
}
setUploadedFiles(pdfs);
};
/**
* Central helper:
* - extracts PDF (awaits the mutate via a Promise wrapper),
* - shows success toast,
* - ensures patient exists (find by insuranceId, create if missing),
* - shows toasts for errors,
* - returns Patient on success or null on error.
*/
const extractAndEnsurePatientForFile = useCallback(
async (file: File): Promise<Patient | null> => {
if (!file) {
toast({
title: "Error",
description: "Please upload a PDF",
variant: "destructive",
});
return null;
}
setIsExtracting(true);
try {
// wrap the extractPdf mutate in a promise so we can await it
const data: { name: string; memberId: string; dob: string } =
await new Promise((resolve, reject) => {
try {
extractPdf(file, {
onSuccess: (d) => resolve(d as any),
onError: (err: any) => reject(err),
});
} catch (err) {
reject(err);
}
});
// 2) basic validation of extracted data — require memberId + name (adjust if needed)
if (!data?.memberId) {
toast({
title: "Extraction result invalid",
description:
"No memberId found in PDF — cannot find/create patient.",
variant: "destructive",
});
return null;
}
if (!data?.name) {
toast({
title: "Extraction result invalid",
description:
"No patient name found in PDF — cannot create patient.",
variant: "destructive",
});
return null;
}
if (!data.dob) {
toast({
title: "Extraction result invalid",
description: "No DOB extracted — cannot create patient",
variant: "default",
});
}
// success toast for extraction
toast({
title: "Success Pdf Data Extracted",
description: `Name: ${data.name}, Member ID: ${data.memberId}, DOB: ${data.dob}`,
variant: "default",
});
// 1) try to find patient by insurance/memberId
let findRes: Response | null = null;
try {
findRes = await apiRequest(
"GET",
`/api/patients/by-insurance-id?insuranceId=${encodeURIComponent(
data.memberId
)}`
);
} catch (err: any) {
// Network / fetch error — do NOT attempt create; surface error and abort.
toast({
title: "Network error",
description:
err?.message ||
"Unable to verify patient by insurance ID due to a network error. Try again.",
variant: "destructive",
});
return null;
}
// 1a) handle fetch response
if (findRes.ok) {
try {
const found = await findRes.json();
if (found && found.id) {
return found as Patient;
}
// If the API returned 200 but empty body / null, we'll treat as "not found" and go to create.
} catch (err: any) {
toast({
title: "Error parsing response",
description:
err?.message ||
"Unable to parse server response when checking existing patient.",
variant: "destructive",
});
return null;
}
} else {
// findRes is not ok
if (findRes.status === 404) {
// Not found -> proceed to create
} else {
// Other non-OK -> parse body if possible and abort (don't create)
let body: any = null;
try {
body = await findRes.json();
} catch {}
toast({
title: "Error checking patient",
description:
body?.message ||
`Failed to check patient (status ${findRes.status}). Aborting.`,
variant: "destructive",
});
return null;
}
}
// 2) not found: create patient
try {
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.
const newPatient: InsertPatient = {
firstName: firstName || "",
lastName: lastName || "",
dateOfBirth: formatLocalDate(parsedDob),
gender: "",
phone: "",
userId: user?.id ?? 1,
insuranceId: data.memberId || "",
};
const createRes = await apiRequest(
"POST",
"/api/patients/",
newPatient
);
if (!createRes.ok) {
let body: any = null;
try {
body = await createRes.json();
} catch {}
const msg =
body?.message ||
`Failed to create patient (status ${createRes.status})`;
toast({
title: "Error creating patient",
description: msg,
variant: "destructive",
});
return null;
}
const created = await createRes.json();
// success toast already shown by addPatientMutation.onSuccess elsewhere — but we can also show a brief success here
queryClient.invalidateQueries({ queryKey: QK_PATIENTS_BASE });
toast({
title: "Patient created",
description: `${created.firstName || ""} ${created.lastName || ""} created.`,
variant: "default",
});
return created as Patient;
} catch (err: any) {
toast({
title: "Error creating patient",
description: err?.message || "Failed to create patient.",
variant: "destructive",
});
return null;
}
} catch (err: any) {
// extraction error
toast({
title: "Extraction failed",
description: err?.message ?? "Failed to extract data from PDF",
variant: "destructive",
});
return null;
} finally {
setIsExtracting(false);
}
},
[extractPdf]
);
// These two operate only when exactly one file selected
const handleExtractAndClaim = async () => {
if (uploadedFiles.length !== 1) return;
const patient = await extractAndEnsurePatientForFile(uploadedFiles[0]!);
if (!patient) return;
navigate(`/claims?newPatient=${patient.id}`);
};
const handleExtractAndAppointment = async () => {
if (uploadedFiles.length !== 1) return;
const patient = await extractAndEnsurePatientForFile(uploadedFiles[0]!);
if (!patient) return;
navigate(`/appointments?newPatient=${patient.id}`);
};
// Batch: iterate files one-by-one and call extractAndEnsurePatientForFile
const handleExtractAndSave = async () => {
if (uploadedFiles.length === 0) {
toast({
title: "No files",
description: "Please upload one or more PDF files first.",
variant: "destructive",
});
return;
}
setIsExtracting(true);
// iterate serially so server isn't hit all at once and order is predictable
for (let i = 0; i < uploadedFiles.length; i++) {
const file = uploadedFiles[i]!;
toast({
title: `Processing file ${i + 1} of ${uploadedFiles.length}`,
description: file.name,
variant: "default",
});
// await each file
/* eslint-disable no-await-in-loop */
await extractAndEnsurePatientForFile(file);
/* eslint-enable no-await-in-loop */
}
setIsExtracting(false);
// optionally clear files after a successful batch run:
setUploadedFiles([]);
if (uploadRef.current) uploadRef.current.reset?.();
toast({
title: "Batch complete",
description: `Processed ${uploadedFiles.length} file(s).`,
variant: "default",
});
};
return (
<div>
<div className="container mx-auto space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold tracking-tight">Patients</h1>
<p className="text-muted-foreground">
Manage patient records and information
</p>
</div>
<div className="flex space-x-2">
<Button
onClick={() => {
setCurrentPatient(undefined);
setIsAddPatientOpen(true);
}}
className="gap-1"
disabled={isLoading}
>
<Plus className="h-4 w-4" />
New Patient
</Button>
</div>
</div>
{/* File Upload Zone */}
<div className="space-y-8 py-8">
<Card>
<CardHeader>
<CardTitle>Upload Patient Document</CardTitle>
<CardDescription>
You can upload 1 file. Allowed types: PDF
</CardDescription>
</CardHeader>
<CardContent>
<MultipleFileUploadZone
ref={uploadRef}
onFilesChange={handleFilesChange}
isUploading={isUploading}
acceptedFileTypes="application/pdf"
maxFiles={20}
maxFileSizeMB={20}
/>
<div className="flex flex-col-2 gap-2 mt-4">
<Button
className="w-full h-12 gap-2"
disabled={uploadedFiles.length === 0 || isExtracting}
onClick={handleExtractAndSave}
>
{isExtracting ? (
<>
<RefreshCw className="h-4 w-4 animate-spin" />
Processing...
</>
) : (
<>
<FilePlus className="h-4 w-4" />
Extract Info & Save
</>
)}
</Button>
<Button
className="w-full h-12 gap-2"
disabled={uploadedFiles.length !== 1 || isExtracting}
onClick={handleExtractAndAppointment}
>
{isExtracting ? (
<>
<RefreshCw className="h-4 w-4 animate-spin" />
Processing...
</>
) : (
<>
<FilePlus className="h-4 w-4" />
Extract Info & Appointment
</>
)}
</Button>
<Button
className="w-full h-12 gap-2"
disabled={uploadedFiles.length !== 1 || isExtracting}
onClick={handleExtractAndClaim}
>
{isExtracting ? (
<>
<RefreshCw className="h-4 w-4 animate-spin" />
Processing...
</>
) : (
<>
<FilePlus className="h-4 w-4" />
Extract Info & Claim/PreAuth
</>
)}
</Button>
</div>
</CardContent>
</Card>
</div>
{/* Patients Table */}
<Card>
<CardHeader>
<CardTitle>Patient Records</CardTitle>
<CardDescription>
View and manage all patient information
</CardDescription>
</CardHeader>
<CardContent>
<PatientTable
allowDelete={true}
allowEdit={true}
allowView={true}
allowFinancial={true}
/>
</CardContent>
</Card>
{/* Add/Edit Patient Modal */}
<AddPatientModal
ref={addPatientModalRef}
open={isAddPatientOpen}
onOpenChange={setIsAddPatientOpen}
onSubmit={handleAddPatient}
isLoading={isLoading}
patient={currentPatient}
/>
</div>
</div>
);
}