initial commit
This commit is contained in:
1205
apps/Frontend/src/pages/appointments-page.tsx
Executable file
1205
apps/Frontend/src/pages/appointments-page.tsx
Executable file
File diff suppressed because it is too large
Load Diff
311
apps/Frontend/src/pages/auth-page.tsx
Executable file
311
apps/Frontend/src/pages/auth-page.tsx
Executable file
@@ -0,0 +1,311 @@
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { CheckCircle, Torus } from "lucide-react";
|
||||
import { CheckedState } from "@radix-ui/react-checkbox";
|
||||
import LoadingScreen from "@/components/ui/LoadingScreen";
|
||||
import { useLocation } from "wouter";
|
||||
import {
|
||||
LoginFormValues,
|
||||
loginSchema,
|
||||
RegisterFormValues,
|
||||
registerSchema,
|
||||
} from "@repo/db/types";
|
||||
|
||||
export default function AuthPage() {
|
||||
const [activeTab, setActiveTab] = useState<string>("login");
|
||||
const { isLoading, user, loginMutation, registerMutation } = useAuth();
|
||||
const [, navigate] = useLocation();
|
||||
|
||||
const loginForm = useForm<LoginFormValues>({
|
||||
resolver: zodResolver(loginSchema),
|
||||
defaultValues: {
|
||||
username: "",
|
||||
password: "",
|
||||
rememberMe: false,
|
||||
},
|
||||
});
|
||||
|
||||
const registerForm = useForm<RegisterFormValues>({
|
||||
resolver: zodResolver(registerSchema),
|
||||
defaultValues: {
|
||||
username: "",
|
||||
password: "",
|
||||
confirmPassword: "",
|
||||
agreeTerms: false,
|
||||
},
|
||||
});
|
||||
|
||||
const onLoginSubmit = (data: LoginFormValues) => {
|
||||
loginMutation.mutate({ username: data.username, password: data.password });
|
||||
};
|
||||
|
||||
const onRegisterSubmit = (data: RegisterFormValues) => {
|
||||
registerMutation.mutate({
|
||||
username: data.username,
|
||||
password: data.password,
|
||||
});
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingScreen />;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
navigate("/insurance-status");
|
||||
}
|
||||
}, [user, navigate]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100 flex items-center justify-center">
|
||||
<div className="w-full max-w-4xl grid grid-cols-1 md:grid-cols-2 shadow-lg rounded-lg overflow-hidden">
|
||||
{/* Auth Forms */}
|
||||
<Card className="p-6 bg-white">
|
||||
<div className="mb-10 text-center">
|
||||
<h1 className="text-3xl font-medium text-primary mb-2">
|
||||
My Dental Office Management
|
||||
</h1>
|
||||
<p className="text-gray-600">
|
||||
{" "}
|
||||
Comprehensive Practice Management System
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
defaultValue="login"
|
||||
value={activeTab}
|
||||
onValueChange={setActiveTab}
|
||||
>
|
||||
<TabsList className="grid w-full grid-cols-2 mb-6">
|
||||
<TabsTrigger value="login">Login</TabsTrigger>
|
||||
<TabsTrigger value="register">Register</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="login">
|
||||
<Form {...loginForm}>
|
||||
<form
|
||||
onSubmit={loginForm.handleSubmit(onLoginSubmit)}
|
||||
className="space-y-4"
|
||||
>
|
||||
<FormField
|
||||
control={loginForm.control}
|
||||
name="username"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Username</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Enter your username" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={loginForm.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="••••••••"
|
||||
type="password"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<FormField
|
||||
control={loginForm.control}
|
||||
name="rememberMe"
|
||||
render={({ field }) => (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="remember-me"
|
||||
checked={field.value as CheckedState}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
<label
|
||||
htmlFor="remember-me"
|
||||
className="text-sm font-medium text-gray-700"
|
||||
>
|
||||
Remember me
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
// do something if needed
|
||||
}}
|
||||
className="text-sm font-medium text-primary hover:text-primary/80"
|
||||
>
|
||||
Forgot password?
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={loginMutation.isPending}
|
||||
>
|
||||
{loginMutation.isPending ? "Signing in..." : "Sign in"}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="register">
|
||||
<Form {...registerForm}>
|
||||
<form
|
||||
onSubmit={registerForm.handleSubmit(onRegisterSubmit)}
|
||||
className="space-y-4"
|
||||
>
|
||||
<FormField
|
||||
control={registerForm.control}
|
||||
name="username"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Username</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Choose a username" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={registerForm.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="••••••••"
|
||||
type="password"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={registerForm.control}
|
||||
name="confirmPassword"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Confirm Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="••••••••"
|
||||
type="password"
|
||||
{...field}
|
||||
value={
|
||||
typeof field.value === "string" ? field.value : ""
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={registerForm.control}
|
||||
name="agreeTerms"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex space-x-2 items-center">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value as CheckedState}
|
||||
onCheckedChange={field.onChange}
|
||||
className="mt-2.5"
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="">
|
||||
<FormLabel className="text-sm font-bold leading-tight">
|
||||
I agree to the{" "}
|
||||
<a href="#" className="text-primary underline">
|
||||
Terms and Conditions
|
||||
</a>
|
||||
</FormLabel>
|
||||
<FormMessage />
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={registerMutation.isPending}
|
||||
>
|
||||
{registerMutation.isPending
|
||||
? "Creating Account..."
|
||||
: "Create Account"}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</Card>
|
||||
|
||||
{/* Hero Section */}
|
||||
<div className="md:block bg-primary p-8 text-white flex flex-col justify-center">
|
||||
<div className="flex justify-center mb-6">
|
||||
<div className="w-16 h-16 bg-white bg-opacity-10 rounded-full flex items-center justify-center">
|
||||
<Torus className="h-8 w-8" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="mb-6 text-center text-white text-opacity-80">
|
||||
The complete solution for dental practice management. Streamline
|
||||
your patient records, appointments, and more.
|
||||
</p>
|
||||
<ul className="space-y-4">
|
||||
<li className="flex items-center">
|
||||
<CheckCircle className="h-5 w-5 mr-2 text-white text-opacity-80" />
|
||||
<span>Easily manage patient records</span>
|
||||
</li>
|
||||
<li className="flex items-center">
|
||||
<CheckCircle className="h-5 w-5 mr-2 text-white text-opacity-80" />
|
||||
<span>Track patient insurance information</span>
|
||||
</li>
|
||||
<li className="flex items-center">
|
||||
<CheckCircle className="h-5 w-5 mr-2 text-white text-opacity-80" />
|
||||
<span>Secure and compliant data storage</span>
|
||||
</li>
|
||||
<li className="flex items-center">
|
||||
<CheckCircle className="h-5 w-5 mr-2 text-white text-opacity-80" />
|
||||
<span>Simple and intuitive interface</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
578
apps/Frontend/src/pages/claims-page.tsx
Executable file
578
apps/Frontend/src/pages/claims-page.tsx
Executable file
@@ -0,0 +1,578 @@
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
} from "@/components/ui/card";
|
||||
import { ClaimForm } from "@/components/claims/claim-form";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { apiRequest, queryClient } from "@/lib/queryClient";
|
||||
import { useLocation } from "wouter";
|
||||
import { useAppDispatch, useAppSelector } from "@/redux/hooks";
|
||||
import {
|
||||
setTaskStatus,
|
||||
clearTaskStatus,
|
||||
} from "@/redux/slices/seleniumClaimSubmitTaskSlice";
|
||||
import { SeleniumTaskBanner } from "@/components/ui/selenium-task-banner";
|
||||
import ClaimsRecentTable, {
|
||||
QK_CLAIMS_BASE,
|
||||
} from "@/components/claims/claims-recent-table";
|
||||
import ClaimsOfPatientModal from "@/components/claims/claims-of-patient-table";
|
||||
import {
|
||||
Claim,
|
||||
InsertAppointment,
|
||||
UpdateAppointment,
|
||||
UpdatePatient,
|
||||
} from "@repo/db/types";
|
||||
import ClaimDocumentsUploadMultiple from "@/components/claims/claim-document-upload-modal";
|
||||
import { QK_PATIENTS_BASE } from "@/components/patients/patient-table";
|
||||
import { PdfPreviewModal } from "@/components/insurance-status/pdf-preview-modal";
|
||||
|
||||
export default function ClaimsPage() {
|
||||
const [isClaimFormOpen, setIsClaimFormOpen] = useState(false);
|
||||
const [selectedPatientId, setSelectedPatientId] = useState<number | null>(
|
||||
null
|
||||
);
|
||||
// for redirect from appointment page directly, then passing to claimform
|
||||
const [selectedAppointmentId, setSelectedAppointmentId] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
// PDF preview modal state
|
||||
const [previewOpen, setPreviewOpen] = useState(false);
|
||||
const [previewPdfId, setPreviewPdfId] = useState<number | null>(null);
|
||||
const [previewFallbackFilename, setPreviewFallbackFilename] = useState<string | null>(null);
|
||||
const dispatch = useAppDispatch();
|
||||
const { status, message, show } = useAppSelector(
|
||||
(state) => state.seleniumClaimSubmitTask
|
||||
);
|
||||
const { toast } = useToast();
|
||||
const { user } = useAuth();
|
||||
|
||||
// Update patient mutation
|
||||
const updatePatientMutation = useMutation({
|
||||
mutationFn: async ({
|
||||
id,
|
||||
patient,
|
||||
}: {
|
||||
id: number;
|
||||
patient: UpdatePatient;
|
||||
}) => {
|
||||
const res = await apiRequest("PUT", `/api/patients/${id}`, patient);
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: "Success",
|
||||
description: "Patient updated successfully!",
|
||||
variant: "default",
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: QK_CLAIMS_BASE });
|
||||
queryClient.invalidateQueries({ queryKey: QK_PATIENTS_BASE });
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: `Failed to update patient: ${error.message}`,
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Create/upsert appointment mutation
|
||||
const createAppointmentMutation = useMutation({
|
||||
mutationFn: async (appointment: InsertAppointment) => {
|
||||
const res = await apiRequest(
|
||||
"POST",
|
||||
"/api/appointments/upsert",
|
||||
appointment
|
||||
);
|
||||
return await res.json();
|
||||
},
|
||||
onSuccess: (appointment) => {
|
||||
toast({
|
||||
title: "Appointment Scheduled",
|
||||
description: appointment.message || "Appointment created successfully.",
|
||||
});
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: `Failed to create appointment: ${error.message}`,
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// create claim mutation
|
||||
const createClaimMutation = useMutation({
|
||||
mutationFn: async (claimData: any) => {
|
||||
const res = await apiRequest("POST", "/api/claims/", claimData);
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: QK_CLAIMS_BASE });
|
||||
|
||||
toast({
|
||||
title: "Claim created successfully",
|
||||
variant: "default",
|
||||
});
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
title: "Error submitting claim",
|
||||
description: error.message,
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// small helper: remove given query params from the current URL (silent, no reload)
|
||||
const clearUrlParams = (params: string[]) => {
|
||||
try {
|
||||
const url = new URL(window.location.href);
|
||||
let changed = false;
|
||||
for (const p of params) {
|
||||
if (url.searchParams.has(p)) {
|
||||
url.searchParams.delete(p);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
window.history.replaceState({}, document.title, url.toString());
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore (URL API or history.replaceState might throw in very old envs)
|
||||
}
|
||||
};
|
||||
|
||||
// case1: - this params are set by pdf extraction/patient page or either by patient-add-form. then used in claim page here.
|
||||
const [location] = useLocation();
|
||||
|
||||
const { newPatient, mode } = useMemo(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
return {
|
||||
newPatient: params.get("newPatient"),
|
||||
mode: params.get("mode"), // direct | manual | null};
|
||||
};
|
||||
}, [location]);
|
||||
|
||||
const handleNewClaim = (patientId: number, appointmentId?: number) => {
|
||||
setSelectedPatientId(patientId);
|
||||
|
||||
// case: when redirect happerns from appointment page.
|
||||
if (appointmentId) {
|
||||
setSelectedAppointmentId(appointmentId);
|
||||
}
|
||||
setIsClaimFormOpen(true);
|
||||
};
|
||||
|
||||
// if ?newPatient=<id> is present, open claim form directly
|
||||
useEffect(() => {
|
||||
if (!newPatient) return;
|
||||
|
||||
const id = Number(newPatient);
|
||||
if (!Number.isFinite(id) || id <= 0) return;
|
||||
|
||||
handleNewClaim(id);
|
||||
clearUrlParams(["newPatient"]);
|
||||
}, [newPatient]);
|
||||
|
||||
// case2 - redirect from appointment page:
|
||||
// If ?appointmentId= is present (and no ?newPatient param), fetch appointment->patient and open claim form
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const appointmentIdParam = params.get("appointmentId");
|
||||
|
||||
// If newPatient flow is present, prefer it and do nothing here
|
||||
if (newPatient) return;
|
||||
if (!appointmentIdParam) return;
|
||||
|
||||
const appointmentId = Number(appointmentIdParam);
|
||||
if (!Number.isFinite(appointmentId) || appointmentId <= 0) return;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const res = await apiRequest(
|
||||
"GET",
|
||||
`/api/appointments/${appointmentId}`
|
||||
);
|
||||
if (!res.ok) {
|
||||
let body: any = null;
|
||||
try {
|
||||
body = await res.json();
|
||||
} catch {}
|
||||
if (!cancelled) {
|
||||
toast({
|
||||
title: "Failed to load appointment",
|
||||
description:
|
||||
body?.message ??
|
||||
body?.error ??
|
||||
`Could not fetch appointment ${appointmentId}.`,
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const appointment = await res.json();
|
||||
const patientId = appointment?.patientId;
|
||||
|
||||
if (!cancelled && patientId) {
|
||||
handleNewClaim(patientId, appointmentId);
|
||||
clearUrlParams(["appointmentId"]);
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (!cancelled) {
|
||||
console.error("Error fetching patient for appointment:", err);
|
||||
toast({
|
||||
title: "Error",
|
||||
description: err?.message ?? "Failed to fetch patient.",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [location, newPatient]);
|
||||
|
||||
// 1. upsert appointment.
|
||||
const handleAppointmentSubmit = async (
|
||||
appointmentData: InsertAppointment | UpdateAppointment
|
||||
): Promise<number> => {
|
||||
return new Promise<number>((resolve, reject) => {
|
||||
createAppointmentMutation.mutate(
|
||||
{
|
||||
date: appointmentData.date,
|
||||
startTime: "09:00",
|
||||
endTime: "09:30",
|
||||
staffId: appointmentData.staffId,
|
||||
patientId: appointmentData.patientId,
|
||||
userId: user?.id,
|
||||
title: "Scheduled Appointment",
|
||||
type: "checkup",
|
||||
},
|
||||
{
|
||||
onSuccess: (appointment) => {
|
||||
resolve(appointment.id);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Could not schedule appointment",
|
||||
variant: "destructive",
|
||||
});
|
||||
reject(error);
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
// 2. Update Patient ( for insuranceId and Insurance Provider)
|
||||
const handleUpdatePatient = (patient: UpdatePatient & { id?: number }) => {
|
||||
if (patient) {
|
||||
const { id, ...sanitizedPatient } = patient;
|
||||
updatePatientMutation.mutate({
|
||||
id: Number(patient.id),
|
||||
patient: sanitizedPatient,
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Cannot update patient: No patient or user found",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 3. create claim.
|
||||
const handleClaimSubmit = (claimData: any): Promise<Claim> => {
|
||||
return createClaimMutation.mutateAsync(claimData).then((data) => {
|
||||
return data;
|
||||
});
|
||||
};
|
||||
|
||||
// 4. handle selenium sybmiting Mass Health claim
|
||||
const handleMHClaimSubmitSelenium = async (data: any) => {
|
||||
const formData = new FormData();
|
||||
formData.append("data", JSON.stringify(data));
|
||||
const uploadedFiles: File[] = data.uploadedFiles ?? [];
|
||||
|
||||
uploadedFiles.forEach((file: File) => {
|
||||
if (file.type === "application/pdf") {
|
||||
formData.append("pdfs", file);
|
||||
} else if (file.type.startsWith("image/")) {
|
||||
formData.append("images", file);
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
dispatch(
|
||||
setTaskStatus({
|
||||
status: "pending",
|
||||
message: "Submitting claim to Selenium...",
|
||||
})
|
||||
);
|
||||
const response = await apiRequest(
|
||||
"POST",
|
||||
"/api/claims/selenium-claim",
|
||||
formData
|
||||
);
|
||||
const result1 = await response.json();
|
||||
if (result1.error) throw new Error(result1.error);
|
||||
|
||||
if (result1.claimNumber) {
|
||||
await queryClient.invalidateQueries({ queryKey: ["claims-recent"] });
|
||||
}
|
||||
|
||||
dispatch(
|
||||
setTaskStatus({
|
||||
status: "pending",
|
||||
message: "Submitted to Selenium. Awaiting PDF...",
|
||||
})
|
||||
);
|
||||
|
||||
toast({
|
||||
title: "Selenium service notified",
|
||||
description:
|
||||
"Your claim data was successfully sent to Selenium, Waitinig for its response.",
|
||||
variant: "default",
|
||||
});
|
||||
|
||||
const result2 = await handleMHSeleniumPdfDownload(
|
||||
result1,
|
||||
selectedPatientId,
|
||||
"INSURANCE_CLAIM"
|
||||
);
|
||||
return result2;
|
||||
} catch (error: any) {
|
||||
dispatch(
|
||||
setTaskStatus({
|
||||
status: "error",
|
||||
message: error.message || "Selenium submission failed",
|
||||
})
|
||||
);
|
||||
toast({
|
||||
title: "Selenium service error",
|
||||
description: error.message || "An error occurred.",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 5. selenium pdf download handler
|
||||
const handleMHSeleniumPdfDownload = async (
|
||||
data: any,
|
||||
selectedPatientId: number | null,
|
||||
groupTitleKey: "INSURANCE_CLAIM" | "INSURANCE_CLAIM_PREAUTH"
|
||||
) => {
|
||||
try {
|
||||
if (!selectedPatientId) {
|
||||
throw new Error("Missing patientId");
|
||||
}
|
||||
|
||||
dispatch(
|
||||
setTaskStatus({
|
||||
status: "pending",
|
||||
message: "Downloading PDF from Selenium...",
|
||||
})
|
||||
);
|
||||
|
||||
const res = await apiRequest("POST", "/api/claims/selenium/fetchpdf", {
|
||||
patientId: selectedPatientId,
|
||||
pdf_url: data.pdf_url,
|
||||
groupTitleKey,
|
||||
});
|
||||
const result = await res.json();
|
||||
if (result.error) throw new Error(result.error);
|
||||
|
||||
// Invalidate queries to refresh the Documents area
|
||||
queryClient.invalidateQueries({ queryKey: ["groupPdfs"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["pdf-groups"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["claims-recent"] });
|
||||
|
||||
dispatch(
|
||||
setTaskStatus({
|
||||
status: "success",
|
||||
message: "Claim submitted & PDF downloaded successfully.",
|
||||
})
|
||||
);
|
||||
|
||||
toast({
|
||||
title: "Success",
|
||||
description: "Claim submitted successfully! PDF saved to Documents page.",
|
||||
});
|
||||
|
||||
// PDF preview popup removed - PDF is still saved to Documents page
|
||||
|
||||
return result;
|
||||
} catch (error: any) {
|
||||
dispatch(
|
||||
setTaskStatus({
|
||||
status: "error",
|
||||
message: error.message || "Failed to download PDF",
|
||||
})
|
||||
);
|
||||
toast({
|
||||
title: "Error",
|
||||
description: error.message || "Failed to fetch PDF",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 6. close claim
|
||||
const closeClaim = () => {
|
||||
setSelectedPatientId(null);
|
||||
setSelectedAppointmentId(null);
|
||||
setIsClaimFormOpen(false);
|
||||
|
||||
clearUrlParams(["newPatient", "appointmentId"]);
|
||||
};
|
||||
|
||||
// Pre Auth section
|
||||
const handleMHClaimPreAuthSubmitSelenium = async (data: any) => {
|
||||
const formData = new FormData();
|
||||
formData.append("data", JSON.stringify(data));
|
||||
const uploadedFiles: File[] = data.uploadedFiles ?? [];
|
||||
|
||||
uploadedFiles.forEach((file: File) => {
|
||||
if (file.type === "application/pdf") {
|
||||
formData.append("pdfs", file);
|
||||
} else if (file.type.startsWith("image/")) {
|
||||
formData.append("images", file);
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
dispatch(
|
||||
setTaskStatus({
|
||||
status: "pending",
|
||||
message: "Submitting claim pre auth to Selenium...",
|
||||
})
|
||||
);
|
||||
const response = await apiRequest(
|
||||
"POST",
|
||||
"/api/claims/selenium-claim-pre-auth",
|
||||
formData
|
||||
);
|
||||
const result1 = await response.json();
|
||||
if (result1.error) throw new Error(result1.error);
|
||||
|
||||
dispatch(
|
||||
setTaskStatus({
|
||||
status: "pending",
|
||||
message: "Submitted to Selenium. Awaiting PDF...",
|
||||
})
|
||||
);
|
||||
|
||||
toast({
|
||||
title: "Selenium service notified",
|
||||
description:
|
||||
"Your claim pre auth data was successfully sent to Selenium, Waitinig for its response.",
|
||||
variant: "default",
|
||||
});
|
||||
|
||||
const result2 = await handleMHSeleniumPdfDownload(
|
||||
result1,
|
||||
selectedPatientId,
|
||||
"INSURANCE_CLAIM_PREAUTH"
|
||||
);
|
||||
return result2;
|
||||
} catch (error: any) {
|
||||
dispatch(
|
||||
setTaskStatus({
|
||||
status: "error",
|
||||
message: error.message || "Selenium submission failed",
|
||||
})
|
||||
);
|
||||
toast({
|
||||
title: "Selenium service error",
|
||||
description: error.message || "An error occurred.",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div>
|
||||
<SeleniumTaskBanner
|
||||
status={status}
|
||||
message={message}
|
||||
show={show}
|
||||
onClear={() => dispatch(clearTaskStatus())}
|
||||
/>
|
||||
|
||||
<div className="container mx-auto space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">
|
||||
Insurance Claims
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Manage and submit insurance claims for patients
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent Claims by Patients also handles new claims */}
|
||||
<ClaimsOfPatientModal onNewClaim={handleNewClaim} />
|
||||
|
||||
{/* Recent Claims Section */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Recently Submitted Claims</CardTitle>
|
||||
<CardDescription>
|
||||
View and manage all recent claims information
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ClaimsRecentTable
|
||||
allowEdit={true}
|
||||
allowView={true}
|
||||
allowDelete={true}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* File Upload Zone */}
|
||||
<ClaimDocumentsUploadMultiple />
|
||||
|
||||
{/* Claim Form Modal */}
|
||||
{isClaimFormOpen && selectedPatientId !== null && (
|
||||
<ClaimForm
|
||||
patientId={selectedPatientId}
|
||||
appointmentId={selectedAppointmentId ?? undefined}
|
||||
autoSubmit={mode === "direct"}
|
||||
onClose={closeClaim}
|
||||
onSubmit={handleClaimSubmit}
|
||||
onHandleAppointmentSubmit={handleAppointmentSubmit}
|
||||
onHandleUpdatePatient={handleUpdatePatient}
|
||||
onHandleForMHSeleniumClaim={handleMHClaimSubmitSelenium}
|
||||
onHandleForMHSeleniumClaimPreAuth={handleMHClaimPreAuthSubmitSelenium}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* PDF Preview Modal */}
|
||||
<PdfPreviewModal
|
||||
open={previewOpen}
|
||||
onClose={() => {
|
||||
setPreviewOpen(false);
|
||||
setPreviewPdfId(null);
|
||||
setPreviewFallbackFilename(null);
|
||||
}}
|
||||
pdfId={previewPdfId}
|
||||
fallbackFilename={previewFallbackFilename}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
177
apps/Frontend/src/pages/cloud-storage-page.tsx
Executable file
177
apps/Frontend/src/pages/cloud-storage-page.tsx
Executable file
@@ -0,0 +1,177 @@
|
||||
import { useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Folder as FolderIcon, Search as SearchIcon } from "lucide-react";
|
||||
import { apiRequest } from "@/lib/queryClient";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { NewFolderModal } from "@/components/cloud-storage/new-folder-modal";
|
||||
import FolderPanel from "@/components/cloud-storage/folder-panel";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import RecentTopLevelFoldersCard, {
|
||||
recentTopLevelFoldersQueryKey,
|
||||
} from "@/components/cloud-storage/recent-top-level-folder-modal";
|
||||
import CloudSearchBar, {
|
||||
cloudSearchQueryKeyRoot,
|
||||
} from "@/components/cloud-storage/search-bar";
|
||||
import FilePreviewModal from "@/components/cloud-storage/file-preview-modal";
|
||||
import { cloudFilesQueryKeyRoot } from "@/components/cloud-storage/files-section";
|
||||
|
||||
export default function CloudStoragePage() {
|
||||
const { toast } = useToast();
|
||||
const { user } = useAuth();
|
||||
const qc = useQueryClient();
|
||||
|
||||
// panel open + initial folder id to show when opening
|
||||
const [panelOpen, setPanelOpen] = useState(false);
|
||||
const [panelInitialFolderId, setPanelInitialFolderId] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
|
||||
// key to remount recent card to clear its internal selection when needed
|
||||
const [recentKey, setRecentKey] = useState(0);
|
||||
|
||||
// New folder modal
|
||||
const [isNewFolderOpen, setIsNewFolderOpen] = useState(false);
|
||||
|
||||
// searchbar - file preview modal state
|
||||
const [previewFileId, setPreviewFileId] = useState<number | null>(null);
|
||||
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
|
||||
|
||||
// searchbar - handlers
|
||||
function handleOpenFolder(folderId: number | null) {
|
||||
setPanelInitialFolderId(folderId);
|
||||
setPanelOpen(true);
|
||||
}
|
||||
|
||||
function handleSelectFile(fileId: number) {
|
||||
setPreviewFileId(fileId);
|
||||
setIsPreviewOpen(true);
|
||||
}
|
||||
|
||||
// create folder handler (page-level)
|
||||
async function handleCreateFolder(name: string) {
|
||||
try {
|
||||
const userId = user?.id;
|
||||
if (!userId) {
|
||||
toast({
|
||||
title: "Sign in required",
|
||||
description: "Please sign in to create a folder.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await apiRequest("POST", `/api/cloud-storage/folders`, {
|
||||
userId,
|
||||
name,
|
||||
parentId: null,
|
||||
});
|
||||
const json = await res.json();
|
||||
if (!res.ok) throw new Error(json?.message || "Failed to create folder");
|
||||
|
||||
toast({ title: "Folder created" });
|
||||
|
||||
// close modal
|
||||
setIsNewFolderOpen(false);
|
||||
|
||||
// Invalidate recent folders page 1 so RecentFoldersCard will refresh.
|
||||
qc.invalidateQueries({ queryKey: recentTopLevelFoldersQueryKey(1) });
|
||||
} catch (err: any) {
|
||||
toast({ title: "Error", description: err?.message || String(err) });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto space-y-6">
|
||||
{/* Header / actions */}
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Cloud Storage</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Manage Files and Folders in Cloud Storage.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 items-center">
|
||||
<Button onClick={() => setIsNewFolderOpen(true)}>
|
||||
<FolderIcon className="h-4 w-4 mr-2" /> New Folder
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CloudSearchBar
|
||||
onOpenFolder={(folderId) => {
|
||||
handleOpenFolder(folderId);
|
||||
}}
|
||||
onSelectFile={(fileId) => {
|
||||
handleSelectFile(fileId);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Recent folders card (delegated component) */}
|
||||
<RecentTopLevelFoldersCard
|
||||
key={recentKey}
|
||||
pageSize={10}
|
||||
initialPage={1}
|
||||
onSelect={(folderId) => {
|
||||
setPanelInitialFolderId(folderId);
|
||||
setPanelOpen(true);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* FolderPanel lives in page so it can be reused with other UI */}
|
||||
{panelOpen && (
|
||||
<FolderPanel
|
||||
folderId={panelInitialFolderId}
|
||||
onClose={() => setPanelOpen(false)}
|
||||
onViewChange={(viewedId: any) => {
|
||||
// If the panel navigates back to root, clear recent card selection by remounting it
|
||||
if (viewedId === null) {
|
||||
setRecentKey((k) => k + 1);
|
||||
|
||||
// clear the panel initial id and close the panel so child folder/file sections hide
|
||||
setPanelInitialFolderId(null);
|
||||
setPanelOpen(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{/* File preview modal */}
|
||||
<FilePreviewModal
|
||||
fileId={previewFileId}
|
||||
isOpen={isPreviewOpen}
|
||||
onClose={() => {
|
||||
setIsPreviewOpen(false);
|
||||
setPreviewFileId(null);
|
||||
}}
|
||||
onDeleted={() => {
|
||||
// close preview (modal may already close, but be explicit)
|
||||
setIsPreviewOpen(false);
|
||||
setPreviewFileId(null);
|
||||
|
||||
// refresh the recent folders card
|
||||
qc.invalidateQueries({ queryKey: recentTopLevelFoldersQueryKey(1) });
|
||||
|
||||
// invalidate file lists and search results
|
||||
qc.invalidateQueries({
|
||||
queryKey: cloudFilesQueryKeyRoot,
|
||||
exact: false,
|
||||
});
|
||||
qc.invalidateQueries({
|
||||
queryKey: cloudSearchQueryKeyRoot,
|
||||
exact: false,
|
||||
});
|
||||
|
||||
// remount the recent card so it clears internal selection (UX nicety)
|
||||
setRecentKey((k) => k + 1);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* New folder modal (reusable) */}
|
||||
<NewFolderModal
|
||||
isOpen={isNewFolderOpen}
|
||||
onClose={() => setIsNewFolderOpen(false)}
|
||||
onSubmit={handleCreateFolder}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
426
apps/Frontend/src/pages/dashboard.tsx
Executable file
426
apps/Frontend/src/pages/dashboard.tsx
Executable file
@@ -0,0 +1,426 @@
|
||||
import { useState, useRef } from "react";
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import { format, parse, isValid, parseISO } from "date-fns";
|
||||
import { StatCard } from "@/components/ui/stat-card";
|
||||
import { PatientTable } from "@/components/patients/patient-table";
|
||||
import { AddPatientModal } from "@/components/patients/add-patient-modal";
|
||||
import { AddAppointmentModal } from "@/components/appointments/add-appointment-modal";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { apiRequest, queryClient } from "@/lib/queryClient";
|
||||
import { AppointmentsByDay } from "@/components/analytics/appointments-by-day";
|
||||
import { NewPatients } from "@/components/analytics/new-patients";
|
||||
import {
|
||||
Users,
|
||||
Calendar,
|
||||
CheckCircle,
|
||||
CreditCard,
|
||||
Plus,
|
||||
Clock,
|
||||
} from "lucide-react";
|
||||
import { Link } from "wouter";
|
||||
import { formatLocalDate, parseLocalDate } from "@/utils/dateUtils";
|
||||
import {
|
||||
Appointment,
|
||||
InsertAppointment,
|
||||
InsertPatient,
|
||||
Patient,
|
||||
UpdateAppointment,
|
||||
} from "@repo/db/types";
|
||||
import { QK_PATIENTS_BASE } from "@/components/patients/patient-table";
|
||||
|
||||
// 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 Dashboard() {
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
const [isAddPatientOpen, setIsAddPatientOpen] = useState(false);
|
||||
const [isAddAppointmentOpen, setIsAddAppointmentOpen] = useState(false);
|
||||
const [currentPatient, setCurrentPatient] = useState<Patient | undefined>(
|
||||
undefined
|
||||
);
|
||||
const [selectedAppointment, setSelectedAppointment] = useState<
|
||||
Appointment | undefined
|
||||
>(undefined);
|
||||
|
||||
const { toast } = useToast();
|
||||
const { user } = useAuth();
|
||||
const addPatientModalRef = useRef<AddPatientModalRef | null>(null);
|
||||
|
||||
// Fetch patients
|
||||
const { data: patients = [], isLoading: isLoadingPatients } = useQuery<
|
||||
Patient[]
|
||||
>({
|
||||
queryKey: ["/api/patients/"],
|
||||
queryFn: async () => {
|
||||
const res = await apiRequest("GET", "/api/patients/");
|
||||
return res.json();
|
||||
},
|
||||
enabled: !!user,
|
||||
});
|
||||
|
||||
// Fetch appointments
|
||||
const {
|
||||
data: appointments = [] as Appointment[],
|
||||
isLoading: isLoadingAppointments,
|
||||
} = useQuery<Appointment[]>({
|
||||
queryKey: ["/api/appointments/all"],
|
||||
queryFn: async () => {
|
||||
const res = await apiRequest("GET", "/api/appointments/all");
|
||||
return res.json();
|
||||
},
|
||||
enabled: !!user,
|
||||
});
|
||||
|
||||
// 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",
|
||||
});
|
||||
|
||||
if (addPatientModalRef.current?.shouldSchedule) {
|
||||
addPatientModalRef.current.navigateToSchedule(newPatient.id);
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: `Failed to add patient: ${error.message}`,
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const toggleMobileMenu = () => {
|
||||
setIsMobileMenuOpen(!isMobileMenuOpen);
|
||||
};
|
||||
|
||||
const handleAddPatient = (patient: InsertPatient) => {
|
||||
if (user) {
|
||||
addPatientMutation.mutate({
|
||||
...patient,
|
||||
userId: user.id,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const isLoading = isLoadingPatients || addPatientMutation.isPending;
|
||||
|
||||
// Create/upsert appointment mutation
|
||||
const createAppointmentMutation = useMutation({
|
||||
mutationFn: async (appointment: InsertAppointment) => {
|
||||
const res = await apiRequest(
|
||||
"POST",
|
||||
"/api/appointments/upsert",
|
||||
appointment
|
||||
);
|
||||
return await res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
setIsAddAppointmentOpen(false);
|
||||
toast({
|
||||
title: "Success",
|
||||
description: "Appointment created successfully.",
|
||||
});
|
||||
// Invalidate both appointments and patients queries
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/appointments/all"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/patients/"] });
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: `Failed to create appointment: ${error.message}`,
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Update appointment mutation
|
||||
const updateAppointmentMutation = useMutation({
|
||||
mutationFn: async ({
|
||||
id,
|
||||
appointment,
|
||||
}: {
|
||||
id: number;
|
||||
appointment: UpdateAppointment;
|
||||
}) => {
|
||||
const res = await apiRequest(
|
||||
"PUT",
|
||||
`/api/appointments/${id}`,
|
||||
appointment
|
||||
);
|
||||
return await res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
setIsAddAppointmentOpen(false);
|
||||
toast({
|
||||
title: "Success",
|
||||
description: "Appointment updated successfully.",
|
||||
});
|
||||
// Invalidate both appointments and patients queries
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/appointments/all"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/patients/"] });
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: `Failed to update appointment: ${error.message}`,
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Handle appointment submission (create or update)
|
||||
const handleAppointmentSubmit = (
|
||||
appointmentData: InsertAppointment | UpdateAppointment
|
||||
) => {
|
||||
if (selectedAppointment && typeof selectedAppointment.id === "number") {
|
||||
updateAppointmentMutation.mutate({
|
||||
id: selectedAppointment.id,
|
||||
appointment: appointmentData as UpdateAppointment,
|
||||
});
|
||||
} else {
|
||||
if (user) {
|
||||
createAppointmentMutation.mutate({
|
||||
...(appointmentData as InsertAppointment),
|
||||
userId: user.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Since we removed filters, just return all patients
|
||||
const today = formatLocalDate(new Date());
|
||||
const todaysAppointments = appointments.filter((appointment) => {
|
||||
const parsedDate = parseLocalDate(appointment.date);
|
||||
return formatLocalDate(parsedDate) === today;
|
||||
});
|
||||
|
||||
// Count completed appointments today
|
||||
const completedTodayCount = todaysAppointments.filter((appointment) => {
|
||||
return appointment.status === "completed";
|
||||
}).length;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Quick Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||
<StatCard
|
||||
title="Total Patients"
|
||||
value={patients.length}
|
||||
icon={Users}
|
||||
color="blue"
|
||||
/>
|
||||
<StatCard
|
||||
title="Today's Appointments"
|
||||
value={todaysAppointments.length}
|
||||
icon={Calendar}
|
||||
color="secondary"
|
||||
/>
|
||||
<StatCard
|
||||
title="Completed Today"
|
||||
value={completedTodayCount}
|
||||
icon={CheckCircle}
|
||||
color="success"
|
||||
/>
|
||||
<StatCard
|
||||
title="Pending Payments"
|
||||
value={0}
|
||||
icon={CreditCard}
|
||||
color="warning"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Today's Appointments Section */}
|
||||
<div className="flex flex-col space-y-4 mb-6">
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between">
|
||||
<h2 className="text-xl font-medium text-gray-800">
|
||||
Today's Appointments
|
||||
</h2>
|
||||
<Button
|
||||
className="mt-2 md:mt-0"
|
||||
onClick={() => {
|
||||
setSelectedAppointment(undefined);
|
||||
setIsAddAppointmentOpen(true);
|
||||
}}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
New Appointment
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
{todaysAppointments.length > 0 ? (
|
||||
<div className="divide-y">
|
||||
{todaysAppointments.map((appointment) => {
|
||||
const patient = patients.find(
|
||||
(p) => p.id === appointment.patientId
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={appointment.id}
|
||||
className="p-4 flex items-center justify-between"
|
||||
>
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="h-10 w-10 rounded-full bg-opacity-10 text-primary flex items-center justify-center">
|
||||
<Clock className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium">
|
||||
{patient
|
||||
? `${patient.firstName} ${patient.lastName}`
|
||||
: "Unknown Patient"}
|
||||
</h3>
|
||||
<div className="text-sm text-gray-500 flex items-center space-x-2">
|
||||
<span>
|
||||
<span>
|
||||
{`${format(
|
||||
parse(
|
||||
`${format(new Date(appointment.date), "yyyy-MM-dd")} ${appointment.startTime}`,
|
||||
"yyyy-MM-dd HH:mm",
|
||||
new Date()
|
||||
),
|
||||
"hh:mm a"
|
||||
)} - ${format(
|
||||
parse(
|
||||
`${format(new Date(appointment.date), "yyyy-MM-dd")} ${appointment.endTime}`,
|
||||
"yyyy-MM-dd HH:mm",
|
||||
new Date()
|
||||
),
|
||||
"hh:mm a"
|
||||
)}`}
|
||||
</span>
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span>
|
||||
{appointment.type.charAt(0).toUpperCase() +
|
||||
appointment.type.slice(1)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span
|
||||
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium
|
||||
${
|
||||
appointment.status === "completed"
|
||||
? "bg-green-100 text-green-800"
|
||||
: appointment.status === "cancelled"
|
||||
? "bg-red-100 text-red-800"
|
||||
: appointment.status === "confirmed"
|
||||
? "bg-blue-100 text-blue-800"
|
||||
: "bg-yellow-100 text-yellow-800"
|
||||
}`}
|
||||
>
|
||||
{appointment.status
|
||||
? appointment.status.charAt(0).toUpperCase() +
|
||||
appointment.status.slice(1)
|
||||
: "Scheduled"}
|
||||
</span>
|
||||
<Link
|
||||
to="/appointments"
|
||||
className="text-primary hover:text-primary/80 text-sm"
|
||||
>
|
||||
View All
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-6 text-center">
|
||||
<Calendar className="h-12 w-12 mx-auto text-gray-400 mb-2" />
|
||||
<h3 className="text-lg font-medium text-gray-900">
|
||||
No appointments today
|
||||
</h3>
|
||||
<p className="mt-1 text-gray-500">
|
||||
You don't have any appointments scheduled for today.
|
||||
</p>
|
||||
<Button
|
||||
className="mt-4"
|
||||
onClick={() => {
|
||||
setSelectedAppointment(undefined);
|
||||
setIsAddAppointmentOpen(true);
|
||||
}}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Schedule an Appointment
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Analytics Dashboard Section */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
|
||||
<AppointmentsByDay appointments={appointments} />
|
||||
<NewPatients patients={patients} />
|
||||
</div>
|
||||
|
||||
{/* Patient Management Section */}
|
||||
<div className="flex flex-col space-y-4">
|
||||
{/* Patient Header */}
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between">
|
||||
<h2 className="text-xl font-medium text-gray-800">
|
||||
Patient Management
|
||||
</h2>
|
||||
<Button
|
||||
className="mt-2 md:mt-0"
|
||||
onClick={() => {
|
||||
setCurrentPatient(undefined);
|
||||
setIsAddPatientOpen(true);
|
||||
}}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Patient
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Patient Table */}
|
||||
<PatientTable allowDelete={true} allowEdit={true} allowView={true} />
|
||||
</div>
|
||||
|
||||
{/* Add/Edit Patient Modal */}
|
||||
<AddPatientModal
|
||||
ref={addPatientModalRef}
|
||||
open={isAddPatientOpen}
|
||||
onOpenChange={setIsAddPatientOpen}
|
||||
onSubmit={handleAddPatient}
|
||||
isLoading={isLoading}
|
||||
patient={currentPatient}
|
||||
/>
|
||||
|
||||
{/* Add/Edit Appointment Modal */}
|
||||
<AddAppointmentModal
|
||||
open={isAddAppointmentOpen}
|
||||
onOpenChange={setIsAddAppointmentOpen}
|
||||
onSubmit={handleAppointmentSubmit}
|
||||
isLoading={
|
||||
createAppointmentMutation.isPending ||
|
||||
updateAppointmentMutation.isPending
|
||||
}
|
||||
appointment={selectedAppointment}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
263
apps/Frontend/src/pages/database-management-page.tsx
Executable file
263
apps/Frontend/src/pages/database-management-page.tsx
Executable file
@@ -0,0 +1,263 @@
|
||||
import { useState } from "react";
|
||||
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import {
|
||||
Database,
|
||||
FileArchive,
|
||||
HardDrive,
|
||||
Cloud,
|
||||
RefreshCw,
|
||||
} from "lucide-react";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { apiRequest, queryClient } from "@/lib/queryClient";
|
||||
import { formatDateToHumanReadable } from "@/utils/dateUtils";
|
||||
import { BackupDestinationManager } from "@/components/database-management/backup-destination-manager";
|
||||
|
||||
export default function DatabaseManagementPage() {
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
const { toast } = useToast();
|
||||
|
||||
// ----- Database status query -----
|
||||
const { data: dbStatus, isLoading: isLoadingStatus } = useQuery({
|
||||
queryKey: ["/db/status"],
|
||||
queryFn: async () => {
|
||||
const res = await apiRequest("GET", "/api/database-management/status");
|
||||
return res.json();
|
||||
},
|
||||
});
|
||||
|
||||
// Helper: parse Content-Disposition filename if present
|
||||
function filenameFromDisposition(res: Response): string | null {
|
||||
const disposition = res.headers.get("Content-Disposition") || "";
|
||||
const starMatch = disposition.match(/filename\*\s*=\s*([^;]+)/i);
|
||||
if (starMatch && starMatch[1]) {
|
||||
let val = starMatch[1]
|
||||
.trim()
|
||||
.replace(/^UTF-8''/i, "")
|
||||
.replace(/['"]/g, "");
|
||||
try {
|
||||
return decodeURIComponent(val);
|
||||
} catch {
|
||||
return val;
|
||||
}
|
||||
}
|
||||
const fileNameRegex = /filename\s*=\s*"([^"]+)"|filename\s*=\s*([^;]+)/i;
|
||||
const normalMatch = disposition.match(fileNameRegex);
|
||||
if (normalMatch) {
|
||||
const candidate = (normalMatch[1] ?? normalMatch[2] ?? "").trim();
|
||||
if (candidate) return candidate.replace(/['"]/g, "");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Detect file type by reading first bytes (magic numbers)
|
||||
// Returns 'zip' | 'gzip' | 'unknown'
|
||||
async function detectBlobType(b: Blob): Promise<"zip" | "gzip" | "unknown"> {
|
||||
try {
|
||||
const header = await b.slice(0, 8).arrayBuffer();
|
||||
const bytes = new Uint8Array(header);
|
||||
// ZIP: 50 4B 03 04
|
||||
if (
|
||||
bytes[0] === 0x50 &&
|
||||
bytes[1] === 0x4b &&
|
||||
(bytes[2] === 0x03 || bytes[2] === 0x05 || bytes[2] === 0x07)
|
||||
) {
|
||||
return "zip";
|
||||
}
|
||||
// GZIP: 1F 8B
|
||||
if (bytes[0] === 0x1f && bytes[1] === 0x8b) {
|
||||
return "gzip";
|
||||
}
|
||||
return "unknown";
|
||||
} catch (e) {
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
// ----- Backup mutation -----
|
||||
const backupMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await apiRequest("POST", "/api/database-management/backup");
|
||||
|
||||
if (!res.ok) {
|
||||
// Try to parse JSON error
|
||||
let errorBody = {};
|
||||
try {
|
||||
errorBody = await res.json();
|
||||
} catch {}
|
||||
throw new Error((errorBody as any)?.error || "Backup failed");
|
||||
}
|
||||
|
||||
// Convert response to blob (file)
|
||||
const blob = await res.blob();
|
||||
|
||||
// 1) prefer Content-Disposition filename if available
|
||||
let fileName = filenameFromDisposition(res);
|
||||
|
||||
// 2) try to guess from Content-Type if disposition not given
|
||||
if (!fileName) {
|
||||
const ct = (res.headers.get("Content-Type") || "").toLowerCase();
|
||||
const iso = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
if (ct.includes("zip")) fileName = `dental_backup_${iso}.zip`;
|
||||
else if (
|
||||
ct.includes("gzip") ||
|
||||
ct.includes("x-gzip") ||
|
||||
ct.includes("tar")
|
||||
)
|
||||
fileName = `dental_backup_${iso}.tar.gz`;
|
||||
else fileName = `dental_backup_${iso}.dump`;
|
||||
}
|
||||
|
||||
// 3) sanity-check blob contents and correct extension if needed
|
||||
const detected = await detectBlobType(blob);
|
||||
if (detected === "zip" && !fileName.toLowerCase().endsWith(".zip")) {
|
||||
// replace extension with .zip
|
||||
fileName = fileName.replace(/(\.tar\.gz|\.tgz|\.gz|\.dump)?$/i, ".zip");
|
||||
} else if (
|
||||
detected === "gzip" &&
|
||||
!/(\.tar\.gz|\.tgz|\.gz)$/i.test(fileName)
|
||||
) {
|
||||
// prefer .tar.gz for gzipped tar
|
||||
fileName = fileName.replace(/(\.zip|\.dump)?$/i, ".tar.gz");
|
||||
}
|
||||
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = fileName;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
window.URL.revokeObjectURL(url);
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: "Backup Complete",
|
||||
description: "Database backup downloaded successfully",
|
||||
variant: "default",
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ["/db/status"] });
|
||||
},
|
||||
onError: (error: any) => {
|
||||
console.error("Backup failed:", error);
|
||||
toast({
|
||||
title: "Error",
|
||||
description: error.message,
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="container mx-auto space-y-6">
|
||||
{/* Page Header */}
|
||||
<div className="bg-white rounded-lg shadow-sm p-6 border">
|
||||
<h1 className="text-2xl font-bold text-gray-900 flex items-center space-x-3">
|
||||
<Database className="h-8 w-8 text-blue-600" />
|
||||
<span>Database Management</span>
|
||||
</h1>
|
||||
<p className="text-gray-600 mt-2">
|
||||
Manage your dental practice database with backup, export
|
||||
capabilities
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Database Backup Section */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center space-x-2">
|
||||
<HardDrive className="h-5 w-5" />
|
||||
<span>Database Backup</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-gray-600">
|
||||
Create a complete backup of your dental practice database
|
||||
including patients, appointments, claims, and all related data.
|
||||
</p>
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
<Button
|
||||
onClick={() => backupMutation.mutate()}
|
||||
disabled={backupMutation.isPending}
|
||||
className="flex items-center space-x-2"
|
||||
>
|
||||
{backupMutation.isPending ? (
|
||||
<RefreshCw className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<FileArchive className="h-4 w-4" />
|
||||
)}
|
||||
<span>
|
||||
{backupMutation.isPending
|
||||
? "Creating Backup..."
|
||||
: "Create Backup"}
|
||||
</span>
|
||||
</Button>
|
||||
|
||||
<div className="text-sm text-gray-500">
|
||||
Last backup:{" "}
|
||||
{dbStatus?.lastBackup
|
||||
? formatDateToHumanReadable(dbStatus.lastBackup)
|
||||
: "Never"}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Externa Drive automatic backup manager */}
|
||||
<BackupDestinationManager />
|
||||
|
||||
{/* Database Status Section */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center space-x-2">
|
||||
<Database className="h-5 w-5" />
|
||||
<span>Database Status</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoadingStatus ? (
|
||||
<p className="text-gray-500">Loading status...</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="p-4 bg-green-50 rounded-lg">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-3 h-3 bg-green-500 rounded-full"></div>
|
||||
<span className="font-medium text-green-800">Status</span>
|
||||
</div>
|
||||
<p className="text-green-600 mt-1">
|
||||
{dbStatus?.connected ? "Connected" : "Disconnected"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-blue-50 rounded-lg">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Database className="h-4 w-4 text-blue-500" />
|
||||
<span className="font-medium text-blue-800">Size</span>
|
||||
</div>
|
||||
<p className="text-blue-600 mt-1">
|
||||
{dbStatus?.size ?? "Unknown"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-purple-50 rounded-lg">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Cloud className="h-4 w-4 text-purple-500" />
|
||||
<span className="font-medium text-purple-800">Records</span>
|
||||
</div>
|
||||
<p className="text-purple-600 mt-1">
|
||||
{dbStatus?.patients
|
||||
? `${dbStatus.patients} patients`
|
||||
: "N/A"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
785
apps/Frontend/src/pages/documents-page.tsx
Executable file
785
apps/Frontend/src/pages/documents-page.tsx
Executable file
@@ -0,0 +1,785 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { toast } from "@/hooks/use-toast";
|
||||
import { apiRequest, queryClient } from "@/lib/queryClient";
|
||||
import { Eye, Trash, Download, FolderOpen, FileText } from "lucide-react";
|
||||
import { DeleteConfirmationDialog } from "@/components/ui/deleteDialog";
|
||||
import { PatientTable } from "@/components/patients/patient-table";
|
||||
import { Patient, PdfFile } from "@repo/db/types";
|
||||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
} from "@/components/ui/pagination";
|
||||
import DocumentsFilePreviewModal from "@/components/documents/file-preview-modal";
|
||||
import { getPageNumbers } from "@/utils/pageNumberGenerator";
|
||||
import {
|
||||
getPatientDocuments,
|
||||
deleteDocument,
|
||||
viewDocument,
|
||||
downloadDocument,
|
||||
formatFileSize,
|
||||
type PatientDocument
|
||||
} from "@/lib/api/documents";
|
||||
|
||||
export default function DocumentsPage() {
|
||||
const [selectedPatient, setSelectedPatient] = useState<Patient | null>(null);
|
||||
const [expandedGroupId, setExpandedGroupId] = useState<number | null>(null);
|
||||
|
||||
// pagination state for the expanded group
|
||||
// pagination state
|
||||
const [currentPage, setCurrentPage] = useState<number>(1);
|
||||
const [limit, setLimit] = useState<number>(5);
|
||||
const offset = (currentPage - 1) * limit;
|
||||
const [totalForExpandedGroup, setTotalForExpandedGroup] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
|
||||
// Patient documents state
|
||||
const [patientDocuments, setPatientDocuments] = useState<PatientDocument[]>([]);
|
||||
const [patientDocumentsLoading, setPatientDocumentsLoading] = useState(false);
|
||||
const [showPatientDocuments, setShowPatientDocuments] = useState(false);
|
||||
const [documentThumbnails, setDocumentThumbnails] = useState<{ [key: number]: string }>({});
|
||||
|
||||
// Document preview state
|
||||
const [previewDocumentId, setPreviewDocumentId] = useState<number | null>(null);
|
||||
const [isPreviewModalOpen, setIsPreviewModalOpen] = useState(false);
|
||||
|
||||
// Delete document state
|
||||
const [deleteDocumentId, setDeleteDocumentId] = useState<number | null>(null);
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
|
||||
// PDF state
|
||||
const [currentPdf, setCurrentPdf] = useState<PdfFile | null>(null);
|
||||
|
||||
// Delete dialog
|
||||
const [isDeletePdfOpen, setIsDeletePdfOpen] = useState(false);
|
||||
|
||||
// reset UI when patient changes
|
||||
useEffect(() => {
|
||||
setExpandedGroupId(null);
|
||||
setLimit(5);
|
||||
setCurrentPage(1);
|
||||
setTotalForExpandedGroup(null);
|
||||
setShowPatientDocuments(false); // Reset documents toggle
|
||||
|
||||
// close the preview modal
|
||||
setIsPreviewModalOpen(false);
|
||||
setPreviewDocumentId(null);
|
||||
|
||||
// Load patient documents when patient is selected
|
||||
if (selectedPatient?.id) {
|
||||
console.log("Patient selected, loading documents for:", selectedPatient.id);
|
||||
loadPatientDocuments(selectedPatient.id);
|
||||
} else {
|
||||
console.log("No patient selected, clearing documents");
|
||||
setPatientDocuments([]);
|
||||
}
|
||||
}, [selectedPatient]);
|
||||
|
||||
// Load patient documents function
|
||||
const loadPatientDocuments = async (patientId: number) => {
|
||||
try {
|
||||
setPatientDocumentsLoading(true);
|
||||
console.log("Loading documents for patient:", patientId);
|
||||
const response = await getPatientDocuments(patientId);
|
||||
console.log("Loaded documents:", response);
|
||||
if (response.success) {
|
||||
setPatientDocuments(response.documents);
|
||||
// Load thumbnails for image documents
|
||||
loadDocumentThumbnails(response.documents);
|
||||
} else {
|
||||
throw new Error("Failed to load documents");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load patient documents:", error);
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to load patient documents",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setPatientDocumentsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Load thumbnails for image documents
|
||||
const loadDocumentThumbnails = async (documents: PatientDocument[]) => {
|
||||
const thumbnails: { [key: number]: string } = {};
|
||||
|
||||
for (const document of documents) {
|
||||
if (document.mimeType.startsWith('image/')) {
|
||||
try {
|
||||
// Use the document's filePath as the thumbnail URL
|
||||
thumbnails[document.id] = document.filePath;
|
||||
} catch (error) {
|
||||
console.error(`Failed to load thumbnail for document ${document.id}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setDocumentThumbnails(thumbnails);
|
||||
};
|
||||
|
||||
// Refresh patient documents (for after upload)
|
||||
const refreshPatientDocuments = async () => {
|
||||
if (selectedPatient?.id) {
|
||||
await loadPatientDocuments(selectedPatient.id);
|
||||
}
|
||||
};
|
||||
|
||||
// Listen for document upload events
|
||||
useEffect(() => {
|
||||
const handleDocumentUpload = (event: CustomEvent) => {
|
||||
console.log('Document upload event received:', event.detail);
|
||||
refreshPatientDocuments();
|
||||
};
|
||||
|
||||
// Add event listener for document uploads
|
||||
window.addEventListener('documentUploaded', handleDocumentUpload as EventListener);
|
||||
|
||||
// Also listen for storage events (for cross-tab communication)
|
||||
const handleStorageChange = (e: StorageEvent) => {
|
||||
if (e.key === 'documentUploaded' && e.newValue) {
|
||||
refreshPatientDocuments();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('storage', handleStorageChange);
|
||||
|
||||
// Cleanup listeners
|
||||
return () => {
|
||||
window.removeEventListener('documentUploaded', handleDocumentUpload as EventListener);
|
||||
window.removeEventListener('storage', handleStorageChange);
|
||||
};
|
||||
}, [selectedPatient]);
|
||||
|
||||
// Handle document preview
|
||||
const handlePreviewDocument = (documentId: number) => {
|
||||
const document = patientDocuments.find(doc => doc.id === documentId);
|
||||
setPreviewDocumentId(documentId);
|
||||
setIsPreviewModalOpen(true);
|
||||
};
|
||||
|
||||
// Handle document delete
|
||||
const handleDeleteDocument = (documentId: number) => {
|
||||
setDeleteDocumentId(documentId);
|
||||
setIsDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
// Confirm document delete
|
||||
const confirmDeleteDocument = async () => {
|
||||
if (!deleteDocumentId) return;
|
||||
|
||||
try {
|
||||
const response = await deleteDocument(deleteDocumentId);
|
||||
if (response.success) {
|
||||
toast({
|
||||
title: "Success",
|
||||
description: "Document deleted successfully",
|
||||
});
|
||||
// Reload documents for the current patient
|
||||
if (selectedPatient?.id) {
|
||||
await loadPatientDocuments(selectedPatient.id);
|
||||
}
|
||||
} else {
|
||||
throw new Error("Failed to delete document");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to delete document:", error);
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to delete document",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setIsDeleteDialogOpen(false);
|
||||
setDeleteDocumentId(null);
|
||||
}
|
||||
};
|
||||
|
||||
// FETCH GROUPS for patient (includes `category` on each group)
|
||||
const { data: groups = [], isLoading: isLoadingGroups } = useQuery({
|
||||
queryKey: ["groups", selectedPatient?.id],
|
||||
enabled: !!selectedPatient,
|
||||
queryFn: async () => {
|
||||
const res = await apiRequest(
|
||||
"GET",
|
||||
`/api/documents/pdf-groups/patient/${selectedPatient?.id}`
|
||||
);
|
||||
return res.json();
|
||||
},
|
||||
});
|
||||
|
||||
// Group groups by titleKey (use titleKey only for grouping/ordering; don't display it)
|
||||
const groupsByTitleKey = useMemo(() => {
|
||||
const map = new Map<string, any[]>();
|
||||
for (const g of groups as any[]) {
|
||||
const key = String(g.titleKey ?? "OTHER");
|
||||
if (!map.has(key)) map.set(key, []);
|
||||
map.get(key)!.push(g);
|
||||
}
|
||||
|
||||
// Decide on a stable order for titleKey buckets: prefer enum order then any extras
|
||||
const preferredOrder = [
|
||||
"INSURANCE_CLAIM",
|
||||
"INSURANCE_CLAIM_PREAUTH",
|
||||
"ELIGIBILITY_STATUS",
|
||||
"CLAIM_STATUS",
|
||||
"OTHER",
|
||||
];
|
||||
const orderedKeys: string[] = [];
|
||||
|
||||
for (const k of preferredOrder) {
|
||||
if (map.has(k)) orderedKeys.push(k);
|
||||
}
|
||||
// append any keys that weren't in preferredOrder
|
||||
for (const k of map.keys()) {
|
||||
if (!orderedKeys.includes(k)) orderedKeys.push(k);
|
||||
}
|
||||
|
||||
return { map, orderedKeys };
|
||||
}, [groups]);
|
||||
|
||||
// FETCH PDFs for selected group with pagination (limit & offset)
|
||||
const {
|
||||
data: groupPdfsResponse,
|
||||
refetch: refetchGroupPdfs,
|
||||
isFetching: isFetchingPdfs,
|
||||
} = useQuery({
|
||||
queryKey: ["groupPdfs", expandedGroupId, currentPage, limit],
|
||||
enabled: !!expandedGroupId,
|
||||
queryFn: async () => {
|
||||
// API should accept ?limit & ?offset and also return total count
|
||||
const res = await apiRequest(
|
||||
"GET",
|
||||
`/api/documents/recent-pdf-files/group/${expandedGroupId}?limit=${limit}&offset=${offset}`
|
||||
);
|
||||
// expected shape: { data: PdfFile[], total: number }
|
||||
const json = await res.json();
|
||||
setTotalForExpandedGroup(json.total ?? null);
|
||||
return json.data ?? [];
|
||||
},
|
||||
});
|
||||
|
||||
const groupPdfs: PdfFile[] = groupPdfsResponse ?? [];
|
||||
|
||||
// DELETE mutation
|
||||
const deletePdfMutation = useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
await apiRequest("DELETE", `/api/documents/pdf-files/${id}`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
setIsDeletePdfOpen(false);
|
||||
setCurrentPdf(null);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["groupPdfs", expandedGroupId],
|
||||
});
|
||||
toast({ title: "Success", description: "PDF deleted successfully!" });
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: error.message || "Failed to delete PDF",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleConfirmDeletePdf = () => {
|
||||
if (currentPdf) {
|
||||
deletePdfMutation.mutate(Number(currentPdf.id));
|
||||
} else {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "No PDF selected for deletion.",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleViewPdf = (pdfId: number, filename?: string) => {
|
||||
setPreviewDocumentId(pdfId);
|
||||
setIsPreviewModalOpen(true);
|
||||
};
|
||||
|
||||
const handleDownloadPdf = async (pdfId: number, filename: string) => {
|
||||
const res = await apiRequest("GET", `/api/documents/pdf-files/${pdfId}`);
|
||||
const arrayBuffer = await res.arrayBuffer();
|
||||
const blob = new Blob([arrayBuffer], { type: "application/pdf" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
// Expand / collapse a group — when expanding reset pagination
|
||||
const toggleExpandGroup = (groupId: number) => {
|
||||
if (expandedGroupId === groupId) {
|
||||
setExpandedGroupId(null);
|
||||
setCurrentPage(1);
|
||||
setLimit(5);
|
||||
setTotalForExpandedGroup(null);
|
||||
} else {
|
||||
setExpandedGroupId(groupId);
|
||||
setCurrentPage(1);
|
||||
setLimit(5);
|
||||
setTotalForExpandedGroup(null);
|
||||
}
|
||||
};
|
||||
|
||||
// pagintaion helper
|
||||
const totalPages = totalForExpandedGroup
|
||||
? Math.ceil(totalForExpandedGroup / limit)
|
||||
: 1;
|
||||
|
||||
const startItem = totalForExpandedGroup ? offset + 1 : 0;
|
||||
const endItem = totalForExpandedGroup
|
||||
? Math.min(offset + limit, totalForExpandedGroup)
|
||||
: 0;
|
||||
|
||||
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">Documents</h1>
|
||||
<p className="text-muted-foreground">
|
||||
View and manage recent uploaded claim PDFs
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedPatient && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
Document groups of Patient: {selectedPatient.firstName}{" "}
|
||||
{selectedPatient.lastName}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
|
||||
|
||||
{/* Existing Groups Section */}
|
||||
<div>
|
||||
{/* <h4 className="text-lg font-semibold mb-3">Document Groups</h4> */}
|
||||
{isLoadingGroups || patientDocumentsLoading ? (
|
||||
<div>Loading groups…</div>
|
||||
) : (groups as any[]).length === 0 && patientDocuments.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
No groups found.
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
{groupsByTitleKey.orderedKeys.map((key, idx) => {
|
||||
const bucket = groupsByTitleKey.map.get(key) ?? [];
|
||||
return (
|
||||
<div key={key}>
|
||||
{/* subtle divider between buckets (no enum text shown) */}
|
||||
{idx !== 0 && (
|
||||
<hr className="my-3 border-t border-gray-200" />
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
{bucket.map((group: any) => (
|
||||
<div key={group.id} className="border rounded p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<FolderOpen className="w-5 h-5" />
|
||||
<div>
|
||||
{/* Only show the group's title string */}
|
||||
<div className="font-semibold">
|
||||
{group.title}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={
|
||||
expandedGroupId === group.id
|
||||
? "default"
|
||||
: "outline"
|
||||
}
|
||||
onClick={() => toggleExpandGroup(group.id)}
|
||||
>
|
||||
{expandedGroupId === group.id
|
||||
? "Collapse"
|
||||
: "Open"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* expanded content: show paginated PDFs for this group */}
|
||||
{expandedGroupId === group.id && (
|
||||
<div className="mt-3 space-y-3">
|
||||
{isFetchingPdfs ? (
|
||||
<div>Loading PDFs…</div>
|
||||
) : groupPdfs.length === 0 ? (
|
||||
<div className="text-muted-foreground">
|
||||
No PDFs in this group.
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{groupPdfs.map((pdf) => (
|
||||
<div
|
||||
key={pdf.id}
|
||||
className="flex justify-between items-center border rounded p-2"
|
||||
>
|
||||
<div className="text-sm">
|
||||
{pdf.filename}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
handleViewPdf(
|
||||
Number(pdf.id),
|
||||
pdf.filename
|
||||
)
|
||||
}
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
handleDownloadPdf(
|
||||
Number(pdf.id),
|
||||
pdf.filename
|
||||
)
|
||||
}
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setCurrentPdf(pdf);
|
||||
setIsDeletePdfOpen(true);
|
||||
}}
|
||||
>
|
||||
<Trash className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="bg-white px-4 py-3 border-t border-gray-200 rounded">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="text-sm text-muted-foreground mb-2 sm:mb-0 whitespace-nowrap">
|
||||
Showing {startItem}–{endItem} of{" "}
|
||||
{totalForExpandedGroup || 0}{" "}
|
||||
results
|
||||
</div>
|
||||
<Pagination>
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<PaginationPrevious
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (currentPage > 1)
|
||||
setCurrentPage(
|
||||
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();
|
||||
setCurrentPage(
|
||||
page as number
|
||||
);
|
||||
}}
|
||||
isActive={
|
||||
currentPage === page
|
||||
}
|
||||
>
|
||||
{page}
|
||||
</PaginationLink>
|
||||
)}
|
||||
</PaginationItem>
|
||||
))}
|
||||
|
||||
<PaginationItem>
|
||||
<PaginationNext
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (
|
||||
currentPage < totalPages
|
||||
)
|
||||
setCurrentPage(
|
||||
currentPage + 1
|
||||
);
|
||||
}}
|
||||
className={
|
||||
currentPage === totalPages
|
||||
? "pointer-events-none opacity-50"
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Patient Documents Section - moved outside groups mapping */}
|
||||
{patientDocuments.length > 0 && (
|
||||
<div>
|
||||
<div>
|
||||
{(groups as any[]).length > 0 && (
|
||||
<hr className="my-3 border-t border-gray-200" />
|
||||
)}
|
||||
</div>
|
||||
<div className="border rounded p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<FolderOpen className="w-5 h-5" />
|
||||
<div>
|
||||
<div className="font-semibold">
|
||||
Other Documents
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant={showPatientDocuments ? "default" : "outline"}
|
||||
className="flex items-center gap-2"
|
||||
onClick={() => setShowPatientDocuments(!showPatientDocuments)}
|
||||
>
|
||||
{showPatientDocuments ? (
|
||||
<>
|
||||
<span className="">Collapse</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>Open</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showPatientDocuments && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mt-3">
|
||||
{patientDocuments.map((document: PatientDocument) => (
|
||||
<div
|
||||
key={document.id}
|
||||
className="bg-white rounded-lg border border-gray-200 p-4 hover:shadow-md transition-shadow"
|
||||
>
|
||||
<div className="flex items-start space-x-3">
|
||||
{/* Thumbnail */}
|
||||
<div className="flex-shrink-0">
|
||||
{document.mimeType.startsWith('image/') && documentThumbnails[document.id] ? (
|
||||
<img
|
||||
src={documentThumbnails[document.id]}
|
||||
alt={document.originalName}
|
||||
className="w-10 h-10 object-cover rounded-lg border border-gray-200"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-10 h-10 bg-gray-100 rounded-lg border border-gray-200 flex items-center justify-center">
|
||||
{document.mimeType.startsWith('image/') ? (
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600"></div>
|
||||
) : (
|
||||
<FileText className="h-6 w-6 text-gray-400" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Document Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="text-sm font-medium text-gray-900 truncate">
|
||||
{document.originalName}
|
||||
</h4>
|
||||
<div className="flex items-center gap-1 mt-1">
|
||||
<p className="text-xs text-gray-500">
|
||||
{formatFileSize(document.fileSize)}
|
||||
</p>
|
||||
<span className="text-[12px]">|</span>
|
||||
<div className="flex">
|
||||
<span className={`inline-flex items-center text-xs font-medium ${document.mimeType.startsWith('image/')
|
||||
? 'text-green-800'
|
||||
: document.mimeType === 'application/pdf'
|
||||
? 'text-red-800'
|
||||
: 'text-blue-800'
|
||||
}`}>
|
||||
{document.mimeType.startsWith('image/') ? 'Image' :
|
||||
document.mimeType === 'application/pdf' ? 'PDF' :
|
||||
document.mimeType.split('/')[1]?.toUpperCase() || 'File'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center space-x-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handlePreviewDocument(document.id)}
|
||||
className="h-8 w-8 text-blue-600 hover:text-blue-800 hover:bg-blue-50"
|
||||
title="View Document"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={async () => {
|
||||
const doc = patientDocuments.find(doc => doc.id === document.id);
|
||||
if (doc?.filePath) {
|
||||
try {
|
||||
// Fetch the file as a blob to force download
|
||||
const response = await fetch(doc.filePath);
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = window.document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = doc.originalName;
|
||||
window.document.body.appendChild(link);
|
||||
link.click();
|
||||
window.document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
console.error('Download failed:', error);
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="h-8 w-8 text-green-600 hover:text-green-800 hover:bg-green-50"
|
||||
title="Download Document"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleDeleteDocument(document.id)}
|
||||
className="h-8 w-8 text-red-600 hover:text-red-800 hover:bg-red-50"
|
||||
title="Delete Document"
|
||||
>
|
||||
<Trash className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Patient Records</CardTitle>
|
||||
<CardDescription>
|
||||
Select a patient to view document groups
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<PatientTable
|
||||
allowView
|
||||
allowDelete
|
||||
allowCheckbox
|
||||
allowEdit
|
||||
onSelectPatient={setSelectedPatient}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<DeleteConfirmationDialog
|
||||
isOpen={isDeletePdfOpen}
|
||||
onConfirm={handleConfirmDeletePdf}
|
||||
onCancel={() => setIsDeletePdfOpen(false)}
|
||||
entityName={`PDF #${currentPdf?.id}`}
|
||||
/>
|
||||
|
||||
{/* Document Preview Modal */}
|
||||
<DocumentsFilePreviewModal
|
||||
fileId={previewDocumentId}
|
||||
isOpen={isPreviewModalOpen}
|
||||
onClose={() => {
|
||||
setIsPreviewModalOpen(false);
|
||||
setPreviewDocumentId(null);
|
||||
}}
|
||||
isPatientDocument={patientDocuments.some(doc => doc.id === previewDocumentId)}
|
||||
directImageUrl={patientDocuments.find(doc => doc.id === previewDocumentId)?.filePath}
|
||||
initialFileName={patientDocuments.find(doc => doc.id === previewDocumentId)?.originalName}
|
||||
/>
|
||||
|
||||
{/* Document Delete Confirmation Dialog */}
|
||||
<DeleteConfirmationDialog
|
||||
isOpen={isDeleteDialogOpen}
|
||||
onConfirm={confirmDeleteDocument}
|
||||
onCancel={() => {
|
||||
setIsDeleteDialogOpen(false);
|
||||
setDeleteDocumentId(null);
|
||||
}}
|
||||
entityName={patientDocuments.find(doc => doc.id === deleteDocumentId)?.originalName || `Document #${deleteDocumentId}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
657
apps/Frontend/src/pages/insurance-status-page.tsx
Executable file
657
apps/Frontend/src/pages/insurance-status-page.tsx
Executable file
@@ -0,0 +1,657 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { CheckCircle, LoaderCircleIcon } from "lucide-react";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { PatientTable } from "@/components/patients/patient-table";
|
||||
import { apiRequest, queryClient } from "@/lib/queryClient";
|
||||
import { useAppDispatch, useAppSelector } from "@/redux/hooks";
|
||||
import {
|
||||
setTaskStatus,
|
||||
clearTaskStatus,
|
||||
} from "@/redux/slices/seleniumEligibilityCheckTaskSlice";
|
||||
import { SeleniumTaskBanner } from "@/components/ui/selenium-task-banner";
|
||||
import { formatLocalDate, parseLocalDate } from "@/utils/dateUtils";
|
||||
import { InsertPatient, Patient } from "@repo/db/types";
|
||||
import { DateInput } from "@/components/ui/dateInput";
|
||||
import { QK_PATIENTS_BASE } from "@/components/patients/patient-table";
|
||||
import { PdfPreviewModal } from "@/components/insurance-status/pdf-preview-modal";
|
||||
import { useLocation } from "wouter";
|
||||
import { DdmaEligibilityButton } from "@/components/insurance-status/ddma-buton-modal";
|
||||
|
||||
export default function InsuranceStatusPage() {
|
||||
const { user } = useAuth();
|
||||
const { toast } = useToast();
|
||||
const dispatch = useAppDispatch();
|
||||
const { status, message, show } = useAppSelector(
|
||||
(state) => state.seleniumEligibilityCheckTask,
|
||||
);
|
||||
const [selectedPatient, setSelectedPatient] = useState<Patient | null>(null);
|
||||
const [location] = useLocation();
|
||||
|
||||
// Insurance eligibility and claim check form fields
|
||||
const [memberId, setMemberId] = useState("");
|
||||
const [dateOfBirth, setDateOfBirth] = useState<Date | null>(null);
|
||||
const [firstName, setFirstName] = useState("");
|
||||
const [lastName, setLastName] = useState("");
|
||||
const isFormIncomplete = !memberId || !dateOfBirth;
|
||||
const [isCheckingEligibilityStatus, setIsCheckingEligibilityStatus] =
|
||||
useState(false);
|
||||
const [isCheckingClaimStatus, setIsCheckingClaimStatus] = useState(false);
|
||||
|
||||
// PDF preview modal state
|
||||
const [previewOpen, setPreviewOpen] = useState(false);
|
||||
const [previewPdfId, setPreviewPdfId] = useState<number | null>(null);
|
||||
const [previewFallbackFilename, setPreviewFallbackFilename] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
|
||||
// Populate fields from selected patient
|
||||
useEffect(() => {
|
||||
if (selectedPatient) {
|
||||
setMemberId(selectedPatient.insuranceId ?? "");
|
||||
setFirstName(selectedPatient.firstName ?? "");
|
||||
setLastName(selectedPatient.lastName ?? "");
|
||||
|
||||
const dob =
|
||||
typeof selectedPatient.dateOfBirth === "string"
|
||||
? parseLocalDate(selectedPatient.dateOfBirth)
|
||||
: selectedPatient.dateOfBirth;
|
||||
setDateOfBirth(dob);
|
||||
} else {
|
||||
setMemberId("");
|
||||
setFirstName("");
|
||||
setLastName("");
|
||||
setDateOfBirth(null);
|
||||
}
|
||||
}, [selectedPatient]);
|
||||
|
||||
// Add patient mutation
|
||||
const addPatientMutation = useMutation({
|
||||
mutationFn: async (patient: InsertPatient) => {
|
||||
const res = await apiRequest("POST", "/api/patients/", patient);
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: QK_PATIENTS_BASE });
|
||||
toast({
|
||||
title: "Success",
|
||||
description: "Patient added successfully!",
|
||||
variant: "default",
|
||||
});
|
||||
},
|
||||
onError: (error: any) => {
|
||||
const msg = error.message;
|
||||
|
||||
if (msg === "A patient with this insurance ID already exists.") {
|
||||
toast({
|
||||
title: "Patient already exists",
|
||||
description: msg,
|
||||
variant: "destructive",
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: `Failed to add patient: ${msg}`,
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// handle eligibility selenium
|
||||
const handleEligibilityCheckSelenium = async () => {
|
||||
const formattedDob = dateOfBirth ? formatLocalDate(dateOfBirth) : "";
|
||||
|
||||
const data = {
|
||||
memberId,
|
||||
dateOfBirth: formattedDob,
|
||||
insuranceSiteKey: "MH",
|
||||
};
|
||||
try {
|
||||
dispatch(
|
||||
setTaskStatus({
|
||||
status: "pending",
|
||||
message: "Sending Data to Selenium...",
|
||||
}),
|
||||
);
|
||||
const response = await apiRequest(
|
||||
"POST",
|
||||
"/api/insurance-status/eligibility-check",
|
||||
{ data: data },
|
||||
);
|
||||
// { data: JSON.stringify(data) },
|
||||
const result = await response.json();
|
||||
if (result.error) throw new Error(result.error);
|
||||
|
||||
dispatch(
|
||||
setTaskStatus({
|
||||
status: "success",
|
||||
message:
|
||||
"Patient status is updated, and its eligibility pdf is uploaded at Document Page.",
|
||||
}),
|
||||
);
|
||||
|
||||
toast({
|
||||
title: "Selenium service done.",
|
||||
description:
|
||||
"Your Patient Eligibility is fetched and updated, Kindly search through the patient.",
|
||||
variant: "default",
|
||||
});
|
||||
|
||||
setSelectedPatient(null);
|
||||
|
||||
// If server returned pdfFileId: open preview modal
|
||||
if (result.pdfFileId) {
|
||||
setPreviewPdfId(Number(result.pdfFileId));
|
||||
// optional fallback name while header is parsed
|
||||
setPreviewFallbackFilename(
|
||||
result.pdfFilename ?? `eligibility_${memberId}.pdf`,
|
||||
);
|
||||
setPreviewOpen(true);
|
||||
}
|
||||
} catch (error: any) {
|
||||
dispatch(
|
||||
setTaskStatus({
|
||||
status: "error",
|
||||
message: error.message || "Selenium submission failed",
|
||||
}),
|
||||
);
|
||||
toast({
|
||||
title: "Selenium service error",
|
||||
description: error.message || "An error occurred.",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Claim Status Check Selenium
|
||||
const handleStatusCheckSelenium = async () => {
|
||||
const formattedDob = dateOfBirth ? formatLocalDate(dateOfBirth) : "";
|
||||
|
||||
const data = {
|
||||
memberId,
|
||||
dateOfBirth: formattedDob,
|
||||
insuranceSiteKey: "MH",
|
||||
};
|
||||
try {
|
||||
dispatch(
|
||||
setTaskStatus({
|
||||
status: "pending",
|
||||
message: "Sending Data to Selenium...",
|
||||
}),
|
||||
);
|
||||
const response = await apiRequest(
|
||||
"POST",
|
||||
"/api/insurance-status/claim-status-check",
|
||||
{ data: JSON.stringify(data) },
|
||||
);
|
||||
const result = await response.json();
|
||||
if (result.error) throw new Error(result.error);
|
||||
|
||||
dispatch(
|
||||
setTaskStatus({
|
||||
status: "success",
|
||||
message:
|
||||
"Claim status is updated, and its pdf is uploaded at Document Page.",
|
||||
}),
|
||||
);
|
||||
|
||||
toast({
|
||||
title: "Selenium service done.",
|
||||
description:
|
||||
"Your Claim Status is fetched and updated, Kindly search through the patient.",
|
||||
variant: "default",
|
||||
});
|
||||
|
||||
setSelectedPatient(null);
|
||||
|
||||
// If server returned pdfFileId: open preview modal
|
||||
if (result.pdfFileId) {
|
||||
setPreviewPdfId(Number(result.pdfFileId));
|
||||
// optional fallback name while header is parsed
|
||||
setPreviewFallbackFilename(
|
||||
result.pdfFilename ?? `eligibility_${memberId}.pdf`,
|
||||
);
|
||||
setPreviewOpen(true);
|
||||
}
|
||||
} catch (error: any) {
|
||||
dispatch(
|
||||
setTaskStatus({
|
||||
status: "error",
|
||||
message: error.message || "Selenium submission failed",
|
||||
}),
|
||||
);
|
||||
toast({
|
||||
title: "Selenium service error",
|
||||
description: error.message || "An error occurred.",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddPatient = async () => {
|
||||
const newPatient: InsertPatient = {
|
||||
firstName,
|
||||
lastName,
|
||||
dateOfBirth: dateOfBirth,
|
||||
gender: "",
|
||||
phone: "",
|
||||
userId: user?.id ?? 1,
|
||||
insuranceId: memberId,
|
||||
};
|
||||
await addPatientMutation.mutateAsync(newPatient);
|
||||
};
|
||||
|
||||
// Handle insurance provider eligibility button clicks
|
||||
const handleMHEligibilityButton = async () => {
|
||||
// Form Fields check
|
||||
if (!memberId || !dateOfBirth) {
|
||||
toast({
|
||||
title: "Missing Fields",
|
||||
description:
|
||||
"Please fill in all the required fields: Member ID, Date of Birth.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setIsCheckingEligibilityStatus(true);
|
||||
|
||||
try {
|
||||
await handleEligibilityCheckSelenium();
|
||||
|
||||
await queryClient.invalidateQueries({ queryKey: QK_PATIENTS_BASE });
|
||||
} finally {
|
||||
setIsCheckingEligibilityStatus(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle insurance provider Status Check button clicks
|
||||
const handleMHStatusButton = async () => {
|
||||
// Form Fields check
|
||||
if (!memberId || !dateOfBirth || !firstName) {
|
||||
toast({
|
||||
title: "Missing Fields",
|
||||
description:
|
||||
"Please fill in all the required fields: Member ID, Date of Birth, First Name.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setIsCheckingClaimStatus(true);
|
||||
|
||||
// Adding patient if same patient exists then it will skip.
|
||||
try {
|
||||
if (!selectedPatient) {
|
||||
await handleAddPatient();
|
||||
}
|
||||
|
||||
await handleStatusCheckSelenium();
|
||||
|
||||
await queryClient.invalidateQueries({ queryKey: QK_PATIENTS_BASE });
|
||||
} finally {
|
||||
setIsCheckingClaimStatus(false);
|
||||
}
|
||||
};
|
||||
|
||||
// small helper: remove given query params from the current URL (silent, no reload)
|
||||
const clearUrlParams = (params: string[]) => {
|
||||
try {
|
||||
const url = new URL(window.location.href);
|
||||
let changed = false;
|
||||
for (const p of params) {
|
||||
if (url.searchParams.has(p)) {
|
||||
url.searchParams.delete(p);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
window.history.replaceState({}, document.title, url.toString());
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
// handling case-1, when redirect happens from appointment page:
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const appointmentId = params.get("appointmentId");
|
||||
const action = params.get("action"); // 'eligibility' | 'claim'
|
||||
if (!appointmentId) return;
|
||||
const id = Number(appointmentId);
|
||||
if (Number.isNaN(id) || id <= 0) return;
|
||||
if (!action || (action !== "eligibility" && action !== "claim")) return;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const res = await apiRequest("GET", `/api/appointments/${id}/patient`);
|
||||
if (!res.ok) {
|
||||
let body: any = null;
|
||||
try {
|
||||
body = await res.json();
|
||||
} catch {}
|
||||
if (!cancelled) {
|
||||
toast({
|
||||
title: "Failed to load patient",
|
||||
description:
|
||||
body?.message ??
|
||||
body?.error ??
|
||||
`Could not fetch patient for appointment ${id}.`,
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
const patient = data?.patient ?? data;
|
||||
if (!cancelled && patient) {
|
||||
// set selectedPatient as before
|
||||
setSelectedPatient(patient as Patient);
|
||||
|
||||
clearUrlParams(["appointmentId", "action"]);
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (!cancelled) {
|
||||
console.error("Error fetching patient for appointment:", err);
|
||||
toast({
|
||||
title: "Error",
|
||||
description:
|
||||
err?.message ?? "An error occurred while fetching patient.",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [location]);
|
||||
|
||||
// handling case-1, when redirect happens from appointment page:
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const appointmentId = params.get("appointmentId");
|
||||
if (!appointmentId) return;
|
||||
|
||||
const id = Number(appointmentId);
|
||||
if (Number.isNaN(id) || id <= 0) return;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const res = await apiRequest("GET", `/api/appointments/${id}/patient`);
|
||||
if (!res.ok) return;
|
||||
|
||||
const data = await res.json();
|
||||
const patient = data?.patient ?? data;
|
||||
|
||||
if (!cancelled && patient) {
|
||||
// ✅ ONLY prefill patient
|
||||
setSelectedPatient(patient as Patient);
|
||||
|
||||
// ✅ clean URL (no auto selenium)
|
||||
clearUrlParams(["appointmentId", "action"]);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch patient from appointment", err);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [location]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SeleniumTaskBanner
|
||||
status={status}
|
||||
message={message}
|
||||
show={show}
|
||||
onClear={() => dispatch(clearTaskStatus())}
|
||||
/>
|
||||
|
||||
<div className="container mx-auto space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">
|
||||
Insurance Eligibility and Claim Status
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Check insurance eligibility and Claim status.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Insurance Eligibility Check Form */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle>Check Insurance Eligibility and Claim Status</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-4 md:grid-cols-4 gap-4 mb-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="memberId">Member ID</Label>
|
||||
<Input
|
||||
id="memberId"
|
||||
placeholder="Enter member ID"
|
||||
value={memberId}
|
||||
onChange={(e) => setMemberId(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<DateInput
|
||||
label="Date of Birth"
|
||||
value={dateOfBirth}
|
||||
onChange={setDateOfBirth}
|
||||
disableFuture
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="firstName">First Name <span className="text-muted-foreground font-normal">(Optional)</span></Label>
|
||||
<Input
|
||||
id="firstName"
|
||||
placeholder="Enter first name"
|
||||
value={firstName}
|
||||
onChange={(e) => setFirstName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="lastName">Last Name <span className="text-muted-foreground font-normal">(Optional)</span></Label>
|
||||
<Input
|
||||
id="lastName"
|
||||
placeholder="Enter last name"
|
||||
value={lastName}
|
||||
onChange={(e) => setLastName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col-2 gap-4">
|
||||
<Button
|
||||
onClick={() => handleMHEligibilityButton()}
|
||||
className="w-full"
|
||||
disabled={isCheckingEligibilityStatus}
|
||||
>
|
||||
{isCheckingEligibilityStatus ? (
|
||||
<>
|
||||
<LoaderCircleIcon className="h-4 w-4 mr-2 animate-spin" />
|
||||
Processing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle className="h-4 w-4 mr-2" />
|
||||
MH Eligibility
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={() => handleMHStatusButton()}
|
||||
className="w-full"
|
||||
disabled={isCheckingClaimStatus}
|
||||
>
|
||||
{isCheckingClaimStatus ? (
|
||||
<>
|
||||
<LoaderCircleIcon className="h-4 w-4 mr-2 animate-spin" />
|
||||
Processing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle className="h-4 w-4 mr-2" />
|
||||
MH Status
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* TEMP PROVIDER BUTTONS */}
|
||||
<div className="space-y-4 mt-6">
|
||||
<h3 className="text-sm font-medium text-muted-foreground">
|
||||
Other provider checks
|
||||
</h3>
|
||||
|
||||
{/* Row 1 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<DdmaEligibilityButton
|
||||
memberId={memberId}
|
||||
dateOfBirth={dateOfBirth}
|
||||
firstName={firstName}
|
||||
lastName={lastName}
|
||||
isFormIncomplete={isFormIncomplete}
|
||||
onPdfReady={(pdfId, fallbackFilename) => {
|
||||
setPreviewPdfId(pdfId);
|
||||
setPreviewFallbackFilename(
|
||||
fallbackFilename ?? `eligibility_ddma_${memberId}.pdf`,
|
||||
);
|
||||
setPreviewOpen(true);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
variant="outline"
|
||||
disabled={isFormIncomplete}
|
||||
>
|
||||
<CheckCircle className="h-4 w-4 mr-2" />
|
||||
Metlife Dental
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
variant="outline"
|
||||
disabled={isFormIncomplete}
|
||||
>
|
||||
<CheckCircle className="h-4 w-4 mr-2" />
|
||||
CCA
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Row 2 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Button
|
||||
className="w-full"
|
||||
variant="outline"
|
||||
disabled={isFormIncomplete}
|
||||
>
|
||||
<CheckCircle className="h-4 w-4 mr-2" />
|
||||
Tufts SCO/SWH/Navi/Mass Gen
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
variant="outline"
|
||||
disabled={isFormIncomplete}
|
||||
>
|
||||
<CheckCircle className="h-4 w-4 mr-2" />
|
||||
United SCO
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
variant="outline"
|
||||
disabled={isFormIncomplete}
|
||||
>
|
||||
<CheckCircle className="h-4 w-4 mr-2" />
|
||||
United AAPR
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Row 3 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Button
|
||||
className="w-full"
|
||||
variant="outline"
|
||||
disabled={isFormIncomplete}
|
||||
>
|
||||
<CheckCircle className="h-4 w-4 mr-2" />
|
||||
Aetna
|
||||
</Button>
|
||||
<Button
|
||||
className="w-full"
|
||||
variant="outline"
|
||||
disabled={isFormIncomplete}
|
||||
>
|
||||
<CheckCircle className="h-4 w-4 mr-2" />
|
||||
Altus
|
||||
</Button>
|
||||
<div /> {/* filler cell to keep grid shape */}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Patients Table */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Patient Records</CardTitle>
|
||||
<CardDescription>
|
||||
Select Patients and Check Their Eligibility
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<PatientTable
|
||||
allowView={true}
|
||||
allowDelete={true}
|
||||
allowCheckbox={true}
|
||||
allowEdit={true}
|
||||
onSelectPatient={setSelectedPatient}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Pdf preview modal */}
|
||||
<PdfPreviewModal
|
||||
open={previewOpen}
|
||||
onClose={() => {
|
||||
setPreviewOpen(false);
|
||||
setPreviewPdfId(null);
|
||||
setPreviewFallbackFilename(null);
|
||||
}}
|
||||
pdfId={previewPdfId ?? undefined}
|
||||
fallbackFilename={previewFallbackFilename ?? undefined} // optional
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
21
apps/Frontend/src/pages/not-found.tsx
Executable file
21
apps/Frontend/src/pages/not-found.tsx
Executable file
@@ -0,0 +1,21 @@
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div className="min-h-screen w-full flex items-center justify-center bg-gray-50">
|
||||
<Card className="w-full max-w-md mx-4">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex mb-4 gap-2">
|
||||
<AlertCircle className="h-8 w-8 text-red-500" />
|
||||
<h1 className="text-2xl font-bold text-gray-900">404 Page Not Found</h1>
|
||||
</div>
|
||||
|
||||
<p className="mt-4 text-sm text-gray-600">
|
||||
Did you forget to add the page to the router?
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
380
apps/Frontend/src/pages/patient-connection-page.tsx
Executable file
380
apps/Frontend/src/pages/patient-connection-page.tsx
Executable file
@@ -0,0 +1,380 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import { Sidebar } from "@/components/layout/sidebar";
|
||||
import { TopAppBar } from "@/components/layout/top-app-bar";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Phone,
|
||||
PhoneCall,
|
||||
Clock,
|
||||
User,
|
||||
Search,
|
||||
MessageSquare,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { SmsTemplateDialog } from "@/components/patient-connection/sms-template-diaog";
|
||||
import { MessageThread } from "@/components/patient-connection/message-thread";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { apiRequest } from "@/lib/queryClient";
|
||||
import type { Patient } from "@repo/db/types";
|
||||
|
||||
export default function PatientConnectionPage() {
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [selectedPatient, setSelectedPatient] = useState<Patient | null>(null);
|
||||
const [isSmsDialogOpen, setIsSmsDialogOpen] = useState(false);
|
||||
const [showMessaging, setShowMessaging] = useState(false);
|
||||
const { toast } = useToast();
|
||||
|
||||
const toggleMobileMenu = () => {
|
||||
setIsMobileMenuOpen(!isMobileMenuOpen);
|
||||
};
|
||||
|
||||
const makeCallMutation = useMutation({
|
||||
mutationFn: async ({
|
||||
to,
|
||||
patientId,
|
||||
}: {
|
||||
to: string;
|
||||
patientId: number;
|
||||
}) => {
|
||||
return apiRequest("POST", "/api/twilio/make-call", {
|
||||
to,
|
||||
message:
|
||||
"Hello, this is a call from your dental office. We are calling to connect with you.",
|
||||
patientId,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: "Call Initiated",
|
||||
description: "The call has been placed successfully.",
|
||||
});
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
title: "Call Failed",
|
||||
description:
|
||||
error.message || "Unable to place the call. Please try again.",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Fetch all patients from database
|
||||
const { data: patients = [], isLoading } = useQuery<Patient[]>({
|
||||
queryKey: ["/api/patients"],
|
||||
});
|
||||
|
||||
// Filter patients based on search term
|
||||
const filteredPatients = patients.filter((patient) => {
|
||||
if (!searchTerm) return false;
|
||||
const searchLower = searchTerm.toLowerCase();
|
||||
const fullName = `${patient.firstName} ${patient.lastName}`.toLowerCase();
|
||||
const phone = patient.phone?.toLowerCase() || "";
|
||||
const patientId = patient.id?.toString();
|
||||
|
||||
return (
|
||||
fullName.includes(searchLower) ||
|
||||
phone.includes(searchLower) ||
|
||||
patientId?.includes(searchLower)
|
||||
);
|
||||
});
|
||||
|
||||
// Handle calling patient via Twilio
|
||||
const handleCall = (patient: Patient) => {
|
||||
if (patient.phone) {
|
||||
makeCallMutation.mutate({
|
||||
to: patient.phone,
|
||||
patientId: Number(patient.id),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Handle sending SMS
|
||||
const handleSMS = (patient: Patient) => {
|
||||
setSelectedPatient(patient);
|
||||
setIsSmsDialogOpen(true);
|
||||
};
|
||||
|
||||
// Handle opening messaging
|
||||
const handleOpenMessaging = (patient: Patient) => {
|
||||
setSelectedPatient(patient);
|
||||
setShowMessaging(true);
|
||||
};
|
||||
|
||||
// Handle closing messaging
|
||||
const handleCloseMessaging = () => {
|
||||
setShowMessaging(false);
|
||||
setSelectedPatient(null);
|
||||
};
|
||||
|
||||
// Sample call data
|
||||
const recentCalls = [
|
||||
{
|
||||
id: 1,
|
||||
patientName: "John Bill",
|
||||
phoneNumber: "(555) 123-4567",
|
||||
callType: "Appointment Request",
|
||||
status: "Completed",
|
||||
duration: "3:45",
|
||||
time: "2 hours ago",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
patientName: "Emily Brown",
|
||||
phoneNumber: "(555) 987-6543",
|
||||
callType: "Insurance Question",
|
||||
status: "Follow-up Required",
|
||||
duration: "6:12",
|
||||
time: "4 hours ago",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
patientName: "Mike Johnson",
|
||||
phoneNumber: "(555) 456-7890",
|
||||
callType: "Prescription Refill",
|
||||
status: "Completed",
|
||||
duration: "2:30",
|
||||
time: "6 hours ago",
|
||||
},
|
||||
];
|
||||
|
||||
const callStats = [
|
||||
{
|
||||
label: "Total Calls Today",
|
||||
value: "23",
|
||||
icon: <Phone className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
label: "Answered Calls",
|
||||
value: "21",
|
||||
icon: <PhoneCall className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
label: "Average Call Time",
|
||||
value: "4:32",
|
||||
icon: <Clock className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
label: "Active Patients",
|
||||
value: patients.length.toString(),
|
||||
icon: <User className="h-4 w-4" />,
|
||||
},
|
||||
];
|
||||
|
||||
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">
|
||||
Patient Connection
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Search and communicate with patients
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Call Statistics */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||
{callStats.map((stat, index) => (
|
||||
<Card key={index}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
{stat.label}
|
||||
</p>
|
||||
<p className="text-2xl font-bold">{stat.value}</p>
|
||||
</div>
|
||||
<div className="text-primary">{stat.icon}</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Search and Actions */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle>Search Patients</CardTitle>
|
||||
<CardDescription>
|
||||
Search by name, phone number, or patient ID
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex gap-4 mb-4">
|
||||
<div className="flex-1 relative">
|
||||
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search patient by name, phone, or ID..."
|
||||
className="pl-8"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
data-testid="input-patient-search"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search Results */}
|
||||
{searchTerm && (
|
||||
<div className="mt-4">
|
||||
{isLoading ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Loading patients...
|
||||
</p>
|
||||
) : filteredPatients.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium mb-2">
|
||||
Search Results ({filteredPatients.length})
|
||||
</p>
|
||||
{filteredPatients.map((patient) => (
|
||||
<div
|
||||
key={patient.id}
|
||||
className="flex items-center justify-between p-3 border rounded-lg hover:bg-gray-50 transition-colors"
|
||||
data-testid={`patient-result-${patient.id}`}
|
||||
>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">
|
||||
{patient.firstName} {patient.lastName}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{patient.phone || "No phone number"} • ID:{" "}
|
||||
{patient.id}
|
||||
</p>
|
||||
{patient.email && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{patient.email}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleCall(patient)}
|
||||
disabled={!patient.phone}
|
||||
data-testid={`button-call-${patient.id}`}
|
||||
>
|
||||
<Phone className="h-4 w-4 mr-1" />
|
||||
Call
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleOpenMessaging(patient)}
|
||||
disabled={!patient.phone}
|
||||
data-testid={`button-chat-${patient.id}`}
|
||||
>
|
||||
<MessageSquare className="h-4 w-4 mr-1" />
|
||||
Chat
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No patients found matching "{searchTerm}"
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Recent Calls */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Recent Calls</CardTitle>
|
||||
<CardDescription>
|
||||
View and manage recent patient calls
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{recentCalls.map((call) => (
|
||||
<div
|
||||
key={call.id}
|
||||
className="flex items-center justify-between p-4 border rounded-lg"
|
||||
>
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex-shrink-0">
|
||||
<Phone className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">{call.patientName}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{call.phoneNumber}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">{call.callType}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Duration: {call.duration}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<Badge
|
||||
variant={
|
||||
call.status === "Completed" ? "default" : "secondary"
|
||||
}
|
||||
>
|
||||
{call.status}
|
||||
</Badge>
|
||||
<p className="text-xs text-muted-foreground">{call.time}</p>
|
||||
<Button variant="outline" size="sm">
|
||||
<PhoneCall className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* SMS Template Dialog */}
|
||||
<SmsTemplateDialog
|
||||
open={isSmsDialogOpen}
|
||||
onOpenChange={setIsSmsDialogOpen}
|
||||
patient={selectedPatient}
|
||||
/>
|
||||
|
||||
{/* Messaging Interface */}
|
||||
{showMessaging && selectedPatient && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-4xl h-[80vh] bg-white rounded-lg shadow-xl relative">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute top-4 right-4 z-10"
|
||||
onClick={handleCloseMessaging}
|
||||
data-testid="button-close-messaging"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</Button>
|
||||
<div className="h-full">
|
||||
<MessageThread
|
||||
patient={selectedPatient}
|
||||
onBack={handleCloseMessaging}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
498
apps/Frontend/src/pages/patients-page.tsx
Executable file
498
apps/Frontend/src/pages/patients-page.tsx
Executable file
@@ -0,0 +1,498 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
254
apps/Frontend/src/pages/payments-page.tsx
Executable file
254
apps/Frontend/src/pages/payments-page.tsx
Executable file
@@ -0,0 +1,254 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
} from "@/components/ui/card";
|
||||
import { DollarSign } from "lucide-react";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import PaymentsRecentTable from "@/components/payments/payments-recent-table";
|
||||
import PaymentsOfPatientModal from "@/components/payments/payments-of-patient-table";
|
||||
import PaymentOCRBlock from "@/components/payments/payment-ocr-block";
|
||||
import { useLocation } from "wouter";
|
||||
import { Patient, PaymentWithExtras } from "@repo/db/types";
|
||||
import { apiRequest } from "@/lib/queryClient";
|
||||
import { toast } from "@/hooks/use-toast";
|
||||
import PaymentEditModal from "@/components/payments/payment-edit-modal";
|
||||
|
||||
export default function PaymentsPage() {
|
||||
const [paymentPeriod, setPaymentPeriod] = useState<string>("all-time");
|
||||
|
||||
// for auto-open from appointment redirect
|
||||
const [location] = useLocation();
|
||||
const [initialPatientForModal, setInitialPatientForModal] =
|
||||
useState<Patient | null>(null);
|
||||
const [openPatientModalFromAppointment, setOpenPatientModalFromAppointment] =
|
||||
useState(false);
|
||||
|
||||
// Payment edit modal state (opens directly when ?paymentId=)
|
||||
const [paymentIdToEdit, setPaymentIdToEdit] = useState<number | null>(null);
|
||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
||||
|
||||
// small helper: remove query params silently
|
||||
const clearUrlParams = (params: string[]) => {
|
||||
try {
|
||||
const url = new URL(window.location.href);
|
||||
let changed = false;
|
||||
for (const p of params) {
|
||||
if (url.searchParams.has(p)) {
|
||||
url.searchParams.delete(p);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed)
|
||||
window.history.replaceState({}, document.title, url.toString());
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
// case1: If payments page is opened via appointment-page, /payments?appointmentId=123 -> fetch patient and open modal
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const appointmentIdParam = params.get("appointmentId");
|
||||
if (!appointmentIdParam) return;
|
||||
const appointmentId = Number(appointmentIdParam);
|
||||
if (!Number.isFinite(appointmentId) || appointmentId <= 0) return;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const res = await apiRequest(
|
||||
"GET",
|
||||
`/api/appointments/${appointmentId}/patient`
|
||||
);
|
||||
if (!res.ok) {
|
||||
let body: any = null;
|
||||
try {
|
||||
body = await res.json();
|
||||
} catch {}
|
||||
if (!cancelled) {
|
||||
toast({
|
||||
title: "Failed to load patient",
|
||||
description:
|
||||
body?.message ??
|
||||
body?.error ??
|
||||
`Could not fetch patient for appointment ${appointmentId}.`,
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
const patient = data?.patient ?? data;
|
||||
if (!cancelled && patient && patient.id) {
|
||||
setInitialPatientForModal(patient as Patient);
|
||||
setOpenPatientModalFromAppointment(true);
|
||||
|
||||
// remove query param so reload won't re-open
|
||||
clearUrlParams(["appointmentId"]);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
console.error("Error fetching patient for appointment:", err);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [location]);
|
||||
|
||||
// NEW: detect paymentId query param -> open edit modal (modal will fetch by id)
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const paymentIdParam = params.get("paymentId");
|
||||
if (!paymentIdParam) return;
|
||||
const paymentId = Number(paymentIdParam);
|
||||
if (!Number.isFinite(paymentId) || paymentId <= 0) return;
|
||||
|
||||
// Open modal with paymentId and clear params
|
||||
setPaymentIdToEdit(paymentId);
|
||||
setIsEditModalOpen(true);
|
||||
clearUrlParams(["paymentId", "patientId"]);
|
||||
}, [location]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Header */}
|
||||
<div className="container mx-auto space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Payments</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Manage patient payments and outstanding balances
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between mt-4 mb-6">
|
||||
<div className="mt-4 md:mt-0 flex items-center space-x-2">
|
||||
<Select
|
||||
defaultValue="all-time"
|
||||
onValueChange={(value) => setPaymentPeriod(value)}
|
||||
>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="Select period" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all-time">All Time</SelectItem>
|
||||
<SelectItem value="this-month">This Month</SelectItem>
|
||||
<SelectItem value="last-month">Last Month</SelectItem>
|
||||
<SelectItem value="last-90-days">Last 90 Days</SelectItem>
|
||||
<SelectItem value="this-year">This Year</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Payment Summary Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-gray-500">
|
||||
Outstanding Balance
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center">
|
||||
<DollarSign className="h-5 w-5 text-yellow-500 mr-2" />
|
||||
<div className="text-2xl font-bold">$0</div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
From 0 outstanding invoices
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-gray-500">
|
||||
Payments Collected
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center">
|
||||
<DollarSign className="h-5 w-5 text-green-500 mr-2" />
|
||||
<div className="text-2xl font-bold">${0}</div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
From 0 completed payments
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-gray-500">
|
||||
Pending Payments
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center">
|
||||
<DollarSign className="h-5 w-5 text-blue-500 mr-2" />
|
||||
<div className="text-2xl font-bold">$0</div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
From 0 pending transactions
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* OCR Image Upload Section*/}
|
||||
<PaymentOCRBlock />
|
||||
|
||||
{/* Recent Payments table */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Payment's Records</CardTitle>
|
||||
<CardDescription>
|
||||
View and manage all recents patient's claims payments
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<PaymentsRecentTable allowEdit allowDelete />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Recent Payments by Patients*/}
|
||||
<PaymentsOfPatientModal
|
||||
initialPatient={initialPatientForModal}
|
||||
openInitially={openPatientModalFromAppointment}
|
||||
onClose={() => {
|
||||
// reset the local flags when modal is closed
|
||||
setOpenPatientModalFromAppointment(false);
|
||||
setInitialPatientForModal(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Payment Edit Modal — modal will fetch payment by id and handle its own save/update */}
|
||||
<PaymentEditModal
|
||||
isOpen={isEditModalOpen}
|
||||
onOpenChange={(v) => setIsEditModalOpen(v)}
|
||||
onClose={() => {
|
||||
setIsEditModalOpen(false);
|
||||
setPaymentIdToEdit(null);
|
||||
}}
|
||||
paymentId={paymentIdToEdit}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
83
apps/Frontend/src/pages/reports-page.tsx
Executable file
83
apps/Frontend/src/pages/reports-page.tsx
Executable file
@@ -0,0 +1,83 @@
|
||||
import React, { useState } from "react";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import ReportConfig from "@/components/reports/report-config";
|
||||
import PatientsWithBalanceReport from "@/components/reports/patients-with-balance-report";
|
||||
import CollectionsByDoctorReport from "@/components/reports/collections-by-doctor-report";
|
||||
import SummaryCards from "@/components/reports/summary-cards";
|
||||
|
||||
type ReportType =
|
||||
| "patients_with_balance"
|
||||
| "patients_no_balance"
|
||||
| "monthly_collections"
|
||||
| "collections_by_doctor"
|
||||
| "procedure_codes_by_doctor"
|
||||
| "payment_methods"
|
||||
| "insurance_vs_patient_payments"
|
||||
| "aging_report";
|
||||
|
||||
export default function ReportPage() {
|
||||
const { user } = useAuth();
|
||||
|
||||
const [startDate, setStartDate] = useState<string>(() => {
|
||||
const d = new Date();
|
||||
d.setMonth(d.getMonth() - 1);
|
||||
return d.toISOString().split("T")[0] ?? "";
|
||||
});
|
||||
const [endDate, setEndDate] = useState<string>(
|
||||
() => new Date().toISOString().split("T")[0] ?? ""
|
||||
);
|
||||
|
||||
const [selectedReportType, setSelectedReportType] = useState<ReportType>(
|
||||
"patients_with_balance"
|
||||
);
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="text-center py-8">Please sign in to view reports.</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto space-y-6">
|
||||
{/* Header Section */}
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">
|
||||
Financial Reports
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Generate comprehensive financial reports for your practice
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<ReportConfig
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
setStartDate={setStartDate}
|
||||
setEndDate={setEndDate}
|
||||
selectedReportType={selectedReportType}
|
||||
setSelectedReportType={setSelectedReportType}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* SINGLE authoritative SummaryCards instance for the page */}
|
||||
<div className="mb-4">
|
||||
<SummaryCards startDate={startDate} endDate={endDate} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{selectedReportType === "patients_with_balance" && (
|
||||
<PatientsWithBalanceReport startDate={startDate} endDate={endDate} />
|
||||
)}
|
||||
|
||||
{selectedReportType === "collections_by_doctor" && (
|
||||
<CollectionsByDoctorReport startDate={startDate} endDate={endDate} />
|
||||
)}
|
||||
|
||||
{/* Add other report components here as needed */}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
370
apps/Frontend/src/pages/settings-page.tsx
Executable file
370
apps/Frontend/src/pages/settings-page.tsx
Executable file
@@ -0,0 +1,370 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import { StaffTable } from "@/components/staffs/staff-table";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { apiRequest, queryClient } from "@/lib/queryClient";
|
||||
import { StaffForm } from "@/components/staffs/staff-form";
|
||||
import { DeleteConfirmationDialog } from "@/components/ui/deleteDialog";
|
||||
import { CredentialTable } from "@/components/settings/insuranceCredTable";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { Staff } from "@repo/db/types";
|
||||
import { NpiProviderTable } from "@/components/settings/npiProviderTable";
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { toast } = useToast();
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
|
||||
// Modal and editing staff state
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [credentialModalOpen, setCredentialModalOpen] = useState(false);
|
||||
const [editingStaff, setEditingStaff] = useState<Staff | null>(null);
|
||||
|
||||
const toggleMobileMenu = () => setIsMobileMenuOpen((prev) => !prev);
|
||||
|
||||
// Fetch staff data
|
||||
const {
|
||||
data: staff = [],
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
} = useQuery<Staff[]>({
|
||||
queryKey: ["/api/staffs/"],
|
||||
queryFn: async () => {
|
||||
const res = await apiRequest("GET", "/api/staffs/");
|
||||
if (!res.ok) {
|
||||
throw new Error("Failed to fetch staff");
|
||||
}
|
||||
return res.json();
|
||||
},
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes cache
|
||||
});
|
||||
|
||||
// Add Staff mutation
|
||||
const addStaffMutate = useMutation<
|
||||
Staff, // Return type
|
||||
Error, // Error type
|
||||
Omit<Staff, "id" | "createdAt"> // Variables
|
||||
>({
|
||||
mutationFn: async (newStaff: Omit<Staff, "id" | "createdAt">) => {
|
||||
const res = await apiRequest("POST", "/api/staffs/", newStaff);
|
||||
if (!res.ok) {
|
||||
const errorData = await res.json().catch(() => null);
|
||||
throw new Error(errorData?.message || "Failed to add staff");
|
||||
}
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/staffs/"] });
|
||||
toast({
|
||||
title: "Staff Added",
|
||||
description: "Staff member added successfully.",
|
||||
variant: "default",
|
||||
});
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: error?.message || "Failed to add staff",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Update Staff mutation
|
||||
const updateStaffMutate = useMutation<
|
||||
Staff,
|
||||
Error,
|
||||
{ id: number; updatedFields: Partial<Staff> }
|
||||
>({
|
||||
mutationFn: async ({
|
||||
id,
|
||||
updatedFields,
|
||||
}: {
|
||||
id: number;
|
||||
updatedFields: Partial<Staff>;
|
||||
}) => {
|
||||
const res = await apiRequest("PUT", `/api/staffs/${id}`, updatedFields);
|
||||
if (!res.ok) {
|
||||
const errorData = await res.json().catch(() => null);
|
||||
throw new Error(errorData?.message || "Failed to update staff");
|
||||
}
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/staffs/"] });
|
||||
toast({
|
||||
title: "Staff Updated",
|
||||
description: "Staff member updated successfully.",
|
||||
variant: "default",
|
||||
});
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: error?.message || "Failed to update staff",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Delete Staff mutation
|
||||
const deleteStaffMutation = useMutation<number, Error, number>({
|
||||
mutationFn: async (id: number) => {
|
||||
const res = await apiRequest("DELETE", `/api/staffs/${id}`);
|
||||
if (!res.ok) {
|
||||
const errorData = await res.json().catch(() => null);
|
||||
throw new Error(errorData?.message || "Failed to delete staff");
|
||||
}
|
||||
return id;
|
||||
},
|
||||
onSuccess: () => {
|
||||
setIsDeleteStaffOpen(false);
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/staffs/"] });
|
||||
toast({
|
||||
title: "Staff Removed",
|
||||
description: "Staff member deleted.",
|
||||
variant: "default",
|
||||
});
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: error?.message || "Failed to delete staff",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Extract mutation states for modal control and loading
|
||||
|
||||
const isAdding = addStaffMutate.status === "pending";
|
||||
const isAddSuccess = addStaffMutate.status === "success";
|
||||
|
||||
const isUpdating = updateStaffMutate.status === "pending";
|
||||
const isUpdateSuccess = updateStaffMutate.status === "success";
|
||||
|
||||
// Open Add modal
|
||||
const openAddStaffModal = () => {
|
||||
setEditingStaff(null);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
// Open Edit modal
|
||||
const openEditStaffModal = (staff: Staff) => {
|
||||
setEditingStaff(staff);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
// Handle form submit for Add or Edit
|
||||
const handleFormSubmit = (formData: Omit<Staff, "id" | "createdAt">) => {
|
||||
if (editingStaff) {
|
||||
// Editing existing staff
|
||||
if (editingStaff.id === undefined) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Staff ID is missing",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
updateStaffMutate.mutate({
|
||||
id: editingStaff.id,
|
||||
updatedFields: formData,
|
||||
});
|
||||
} else {
|
||||
addStaffMutate.mutate(formData);
|
||||
}
|
||||
};
|
||||
|
||||
const handleModalCancel = () => {
|
||||
setModalOpen(false);
|
||||
};
|
||||
|
||||
// Close modal on successful add/update
|
||||
useEffect(() => {
|
||||
if (isAddSuccess || isUpdateSuccess) {
|
||||
setModalOpen(false);
|
||||
}
|
||||
}, [isAddSuccess, isUpdateSuccess]);
|
||||
|
||||
const [isDeleteStaffOpen, setIsDeleteStaffOpen] = useState(false);
|
||||
const [currentStaff, setCurrentStaff] = useState<Staff | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
const handleDeleteStaff = (staff: Staff) => {
|
||||
setCurrentStaff(staff);
|
||||
setIsDeleteStaffOpen(true);
|
||||
};
|
||||
|
||||
const handleConfirmDeleteStaff = async () => {
|
||||
if (currentStaff?.id) {
|
||||
deleteStaffMutation.mutate(currentStaff.id);
|
||||
} else {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "No Staff selected for deletion.",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleViewStaff = (staff: Staff) =>
|
||||
alert(
|
||||
`Viewing staff member:\n${staff.name} (${staff.email || "No email"})`,
|
||||
);
|
||||
|
||||
// MANAGE USER
|
||||
const [usernameUser, setUsernameUser] = useState("");
|
||||
|
||||
//fetch user
|
||||
const { user } = useAuth();
|
||||
useEffect(() => {
|
||||
if (user?.username) {
|
||||
setUsernameUser(user.username);
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
//update user mutation
|
||||
const updateUserMutate = useMutation({
|
||||
mutationFn: async (
|
||||
updates: Partial<{ username: string; password: string }>,
|
||||
) => {
|
||||
if (!user?.id) throw new Error("User not loaded");
|
||||
const res = await apiRequest("PUT", `/api/users/${user.id}`, updates);
|
||||
if (!res.ok) {
|
||||
const errorData = await res.json().catch(() => null);
|
||||
throw new Error(errorData?.error || "Failed to update user");
|
||||
}
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/users/"] });
|
||||
toast({
|
||||
title: "Updated",
|
||||
description: "Your profile has been updated.",
|
||||
variant: "default",
|
||||
});
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: err?.message || "Failed to update user",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<div className="mt-8">
|
||||
<StaffTable
|
||||
staff={staff}
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
onAdd={openAddStaffModal}
|
||||
onEdit={openEditStaffModal}
|
||||
onDelete={handleDeleteStaff}
|
||||
onView={handleViewStaff}
|
||||
/>
|
||||
{isError && (
|
||||
<p className="mt-4 text-red-600">
|
||||
{(error as Error)?.message || "Failed to load staff data."}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<DeleteConfirmationDialog
|
||||
isOpen={isDeleteStaffOpen}
|
||||
onConfirm={handleConfirmDeleteStaff}
|
||||
onCancel={() => setIsDeleteStaffOpen(false)}
|
||||
entityName={currentStaff?.name}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Modal Overlay */}
|
||||
{modalOpen && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex justify-center items-center z-50">
|
||||
<div className="bg-white rounded-lg p-6 w-full max-w-md shadow-lg">
|
||||
<h2 className="text-lg font-bold mb-4">
|
||||
{editingStaff ? "Edit Staff" : "Add Staff"}
|
||||
</h2>
|
||||
<StaffForm
|
||||
initialData={editingStaff || undefined}
|
||||
onSubmit={handleFormSubmit}
|
||||
onCancel={handleModalCancel}
|
||||
isLoading={isAdding || isUpdating}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* User Setting section */}
|
||||
<Card className="mt-6">
|
||||
<CardContent className="space-y-4 py-6">
|
||||
<h3 className="text-lg font-semibold">User Settings</h3>
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.currentTarget);
|
||||
const password =
|
||||
formData.get("password")?.toString().trim() || undefined;
|
||||
|
||||
updateUserMutate.mutate({
|
||||
username: usernameUser?.trim() || undefined,
|
||||
password: password || undefined,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<label className="block text-sm font-medium">Username</label>
|
||||
<input
|
||||
type="text"
|
||||
name="username"
|
||||
value={usernameUser}
|
||||
onChange={(e) => setUsernameUser(e.target.value)}
|
||||
className="mt-1 p-2 border rounded w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium">New Password</label>
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
className="mt-1 p-2 border rounded w-full"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Leave blank to keep current password.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"
|
||||
disabled={updateUserMutate.isPending}
|
||||
>
|
||||
{updateUserMutate.isPending ? "Saving..." : "Save Changes"}
|
||||
</button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Credential Section */}
|
||||
<div className="mt-6">
|
||||
<CredentialTable />
|
||||
</div>
|
||||
|
||||
{/* NpiProvider Section */}
|
||||
<div className="mt-6">
|
||||
<NpiProviderTable />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user