feat: add new frontend components, MH batch worker, and gitignore rules
- Add all new Frontend source files (pages, components, hooks, utils) - Add selenium_MHBatchPaymentCheckWorker.py and MHSinglePaymentCheckWorker.py - Add install-steps-5-13.sh setup script - Update .gitignore to exclude runtime/sensitive data (backups, uploads, chat-history, keys, downloads, generated .d.ts files) while keeping folders - Add .gitkeep to preserve empty runtime folders in git Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,438 @@
|
||||
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, CalendarPlus, History, HeartPulse, FolderOpen, Scan, Camera, NotebookPen, FileCheck2, } 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 { QK_PATIENTS_BASE } from "@/components/patients/patient-table";
|
||||
import { parse } from "date-fns";
|
||||
import { formatLocalDate } from "@/utils/dateUtils";
|
||||
import { MultipleFileUploadZone, } from "@/components/file-upload/multiple-file-upload-zone";
|
||||
export default function PatientsPage() {
|
||||
const { toast } = useToast();
|
||||
const { user } = useAuth();
|
||||
const [isAddPatientOpen, setIsAddPatientOpen] = useState(false);
|
||||
const [currentPatient, setCurrentPatient] = useState(undefined);
|
||||
const addPatientModalRef = useRef(null);
|
||||
// File upload states
|
||||
const uploadRef = useRef(null);
|
||||
const [uploadedFiles, setUploadedFiles] = useState([]);
|
||||
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) => {
|
||||
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) => {
|
||||
if (user) {
|
||||
addPatientMutation.mutate({
|
||||
...patient,
|
||||
userId: user.id,
|
||||
});
|
||||
}
|
||||
};
|
||||
const isLoading = addPatientMutation.isPending;
|
||||
// Hook up file-change coming from MultipleFileUploadZone
|
||||
const handleFilesChange = (files) => {
|
||||
// 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) => {
|
||||
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 = await new Promise((resolve, reject) => {
|
||||
try {
|
||||
extractPdf(file, {
|
||||
onSuccess: (d) => resolve(d),
|
||||
onError: (err) => 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 = null;
|
||||
try {
|
||||
findRes = await apiRequest("GET", `/api/patients/by-insurance-id?insuranceId=${encodeURIComponent(data.memberId)}`);
|
||||
}
|
||||
catch (err) {
|
||||
// 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;
|
||||
}
|
||||
// If the API returned 200 but empty body / null, we'll treat as "not found" and go to create.
|
||||
}
|
||||
catch (err) {
|
||||
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 = 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 = {
|
||||
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 = 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;
|
||||
}
|
||||
catch (err) {
|
||||
toast({
|
||||
title: "Error creating patient",
|
||||
description: err?.message || "Failed to create patient.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
// 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>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* 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-wrap 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>
|
||||
|
||||
{/* Patient action buttons */}
|
||||
<div className="mt-5 pt-4 border-t border-gray-200">
|
||||
<p className="text-xs font-medium text-gray-500 uppercase tracking-wider mb-3">
|
||||
Patient Actions
|
||||
</p>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
|
||||
<Button variant="outline" className="h-11 gap-2 text-sm justify-start" disabled>
|
||||
<CalendarPlus className="h-4 w-4 text-blue-500 flex-shrink-0"/>
|
||||
New Appointment
|
||||
</Button>
|
||||
<Button variant="outline" className="h-11 gap-2 text-sm justify-start" disabled>
|
||||
<History className="h-4 w-4 text-indigo-500 flex-shrink-0"/>
|
||||
Appointment History
|
||||
</Button>
|
||||
<Button variant="outline" className="h-11 gap-2 text-sm justify-start" disabled>
|
||||
<HeartPulse className="h-4 w-4 text-rose-500 flex-shrink-0"/>
|
||||
Medical/Dental History
|
||||
</Button>
|
||||
<Button variant="outline" className="h-11 gap-2 text-sm justify-start" disabled>
|
||||
<FolderOpen className="h-4 w-4 text-yellow-500 flex-shrink-0"/>
|
||||
Documents
|
||||
</Button>
|
||||
<Button variant="outline" className="h-11 gap-2 text-sm justify-start" disabled>
|
||||
<Scan className="h-4 w-4 text-teal-500 flex-shrink-0"/>
|
||||
View Radiographs
|
||||
</Button>
|
||||
<Button variant="outline" className="h-11 gap-2 text-sm justify-start" disabled>
|
||||
<Camera className="h-4 w-4 text-cyan-500 flex-shrink-0"/>
|
||||
Take New Radiographs
|
||||
</Button>
|
||||
<Button variant="outline" className="h-11 gap-2 text-sm justify-start" disabled>
|
||||
<NotebookPen className="h-4 w-4 text-violet-500 flex-shrink-0"/>
|
||||
Clinic Notes
|
||||
</Button>
|
||||
<Button variant="outline" className="h-11 gap-2 text-sm justify-start" disabled>
|
||||
<FileCheck2 className="h-4 w-4 text-orange-500 flex-shrink-0"/>
|
||||
New Claims
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Add/Edit Patient Modal */}
|
||||
<AddPatientModal ref={addPatientModalRef} open={isAddPatientOpen} onOpenChange={setIsAddPatientOpen} onSubmit={handleAddPatient} isLoading={isLoading} patient={currentPatient}/>
|
||||
</div>
|
||||
</div>);
|
||||
}
|
||||
Reference in New Issue
Block a user