feat(Pattient data extracts button added)
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useRef } from "react";
|
import { useState, useRef, useCallback } from "react";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import { PatientTable } from "@/components/patients/patient-table";
|
import { PatientTable } from "@/components/patients/patient-table";
|
||||||
import { AddPatientModal } from "@/components/patients/add-patient-modal";
|
import { AddPatientModal } from "@/components/patients/add-patient-modal";
|
||||||
@@ -19,6 +19,8 @@ import useExtractPdfData from "@/hooks/use-extractPdfData";
|
|||||||
import { useLocation } from "wouter";
|
import { useLocation } from "wouter";
|
||||||
import { InsertPatient, Patient } from "@repo/db/types";
|
import { InsertPatient, Patient } from "@repo/db/types";
|
||||||
import { QK_PATIENTS_BASE } from "@/components/patients/patient-table";
|
import { QK_PATIENTS_BASE } from "@/components/patients/patient-table";
|
||||||
|
import { parse } from "date-fns";
|
||||||
|
import { formatLocalDate } from "@/utils/dateUtils";
|
||||||
|
|
||||||
// Type for the ref to access modal methods
|
// Type for the ref to access modal methods
|
||||||
type AddPatientModalRef = {
|
type AddPatientModalRef = {
|
||||||
@@ -92,6 +94,69 @@ 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
|
||||||
@@ -108,38 +173,218 @@ export default function PatientsPage() {
|
|||||||
setIsUploading(false);
|
setIsUploading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
// data extraction
|
/**
|
||||||
const handleExtract = () => {
|
* Central helper:
|
||||||
setIsExtracting(true);
|
* - 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 extractAndEnsurePatient =
|
||||||
|
useCallback(async (): Promise<Patient | null> => {
|
||||||
|
if (!uploadedFile) {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: "Please upload a PDF",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
if (!uploadedFile) {
|
setIsExtracting(true);
|
||||||
return toast({
|
try {
|
||||||
title: "Error",
|
// wrap the extractPdf mutate in a promise so we can await it
|
||||||
description: "Please upload a PDF",
|
const data: { name: string; memberId: string; dob: string } =
|
||||||
variant: "destructive",
|
await new Promise((resolve, reject) => {
|
||||||
});
|
try {
|
||||||
}
|
extractPdf(uploadedFile, {
|
||||||
extractPdf(uploadedFile, {
|
onSuccess: (d) => resolve(d as any),
|
||||||
onSuccess: (data) => {
|
onError: (err: any) => reject(err),
|
||||||
setIsExtracting(false);
|
});
|
||||||
|
} 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({
|
toast({
|
||||||
title: "Success Pdf Data Extracted",
|
title: "Success Pdf Data Extracted",
|
||||||
description: `Name: ${data.name}, Member ID: ${data.memberId}, DOB: ${data.dob}`,
|
description: `Name: ${data.name}, Member ID: ${data.memberId}, DOB: ${data.dob}`,
|
||||||
variant: "default",
|
variant: "default",
|
||||||
});
|
});
|
||||||
|
|
||||||
const params = new URLSearchParams({
|
// 1) try to find patient by insurance/memberId
|
||||||
name: data.name,
|
let findRes: Response | null = null;
|
||||||
memberId: data.memberId,
|
try {
|
||||||
dob: data.dob,
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
navigate(
|
// 1a) handle fetch response
|
||||||
`/claims?name=${encodeURIComponent(data.name)}&memberId=${data.memberId}&dob=${data.dob}`
|
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,
|
||||||
|
status: "active",
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}, [uploadedFile, extractPdf]);
|
||||||
|
|
||||||
|
// handlers are now minimal and don't repeat error/toast logic
|
||||||
|
const handleExtractAndClaim = async () => {
|
||||||
|
const patient = await extractAndEnsurePatient();
|
||||||
|
if (!patient) return; // error already shown in helper
|
||||||
|
navigate(`/claims?newPatient=${patient.id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExtractAndAppointment = async () => {
|
||||||
|
const patient = await extractAndEnsurePatient();
|
||||||
|
if (!patient) return;
|
||||||
|
navigate(`/appointments?newPatient=${patient.id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExtractAndSave = async () => {
|
||||||
|
await extractAndEnsurePatient();
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -183,11 +428,45 @@ export default function PatientsPage() {
|
|||||||
acceptedFileTypes="application/pdf"
|
acceptedFileTypes="application/pdf"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="mt-4">
|
<div className="flex flex-col-2 gap-2 mt-4">
|
||||||
<Button
|
<Button
|
||||||
className="w-full h-12 gap-2"
|
className="w-full h-12 gap-2"
|
||||||
disabled={!uploadedFile || isExtracting}
|
disabled={!uploadedFile || isExtracting}
|
||||||
onClick={handleExtract}
|
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={!uploadedFile || 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={!uploadedFile || isExtracting}
|
||||||
|
onClick={handleExtractAndClaim}
|
||||||
>
|
>
|
||||||
{isExtracting ? (
|
{isExtracting ? (
|
||||||
<>
|
<>
|
||||||
|
|||||||
Reference in New Issue
Block a user