initial commit

This commit is contained in:
2026-04-04 22:13:55 -04:00
commit 5d77e207c9
10181 changed files with 522212 additions and 0 deletions

File diff suppressed because it is too large Load Diff

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}