feat: add new frontend components, MH batch worker, and gitignore rules

- Add all new Frontend source files (pages, components, hooks, utils)
- Add selenium_MHBatchPaymentCheckWorker.py and MHSinglePaymentCheckWorker.py
- Add install-steps-5-13.sh setup script
- Update .gitignore to exclude runtime/sensitive data (backups, uploads,
  chat-history, keys, downloads, generated .d.ts files) while keeping folders
- Add .gitkeep to preserve empty runtime folders in git

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-26 00:23:43 -04:00
co-authored by Claude Sonnet 4.6
parent b7e06adf2f
commit 1edf73fdc8
173 changed files with 33469 additions and 0 deletions
+144
View File
@@ -0,0 +1,144 @@
import { useState } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import { apiRequest, queryClient } from "@/lib/queryClient";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useToast } from "@/hooks/use-toast";
import { KeyRound, CheckCircle2, XCircle, AlertCircle, Shield, ShieldOff } from "lucide-react";
import { cn } from "@/lib/utils";
export default function ActivationPage() {
const { toast } = useToast();
const [keyInput, setKeyInput] = useState("");
const { data: status, isLoading } = useQuery({
queryKey: ["/api/license/status"],
queryFn: async () => {
const res = await apiRequest("GET", "/api/license/status");
return res.json();
},
});
const activateMutation = useMutation({
mutationFn: async (key) => {
const res = await apiRequest("POST", "/api/license/activate", { key });
if (!res.ok) {
const body = await res.json();
throw new Error(body.error || "Activation failed");
}
return res.json();
},
onSuccess: () => {
toast({ title: "License activated successfully" });
setKeyInput("");
queryClient.invalidateQueries({ queryKey: ["/api/license/status"] });
},
onError: (err) => {
toast({ title: err.message, variant: "destructive" });
},
});
const formatExpiry = (expiry) => {
return new Date(expiry).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
});
};
const isActive = status?.activated && !status?.expired;
const isExpired = status?.activated && status?.expired;
const isWarning = isActive && status?.daysLeft !== null && status.daysLeft <= 30;
return (<div className="p-6 max-w-2xl mx-auto space-y-6">
<div className="flex items-center space-x-3">
<KeyRound className="h-6 w-6 text-gray-600"/>
<h1 className="text-2xl font-semibold text-gray-800">License Activation</h1>
</div>
{/* Status Card */}
<Card>
<CardHeader>
<CardTitle className="text-base">License Status</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (<p className="text-gray-500 text-sm">Checking license...</p>) : isActive ? (<div className="space-y-2">
<div className="flex items-center space-x-2">
<CheckCircle2 className="h-5 w-5 text-green-500"/>
<span className="font-medium text-green-700">Active</span>
</div>
<p className="text-sm text-gray-600">
Expires: <span className="font-medium">{formatExpiry(status.expiry)}</span>
</p>
{isWarning && (<div className="flex items-center space-x-2 mt-2 p-2 bg-yellow-50 rounded-md border border-yellow-200">
<AlertCircle className="h-4 w-4 text-yellow-600 flex-shrink-0"/>
<p className="text-sm text-yellow-700">
Expires in {status.daysLeft} day{status.daysLeft !== 1 ? "s" : ""}. Please renew soon.
</p>
</div>)}
</div>) : isExpired ? (<div className="flex items-center space-x-2">
<XCircle className="h-5 w-5 text-red-500"/>
<div>
<span className="font-medium text-red-700">Expired</span>
<p className="text-sm text-gray-500">
Expired on {formatExpiry(status.expiry)}. Enter a new key to reactivate.
</p>
</div>
</div>) : (<div className="flex items-center space-x-2">
<XCircle className="h-5 w-5 text-gray-400"/>
<span className="text-gray-500">Not activated</span>
</div>)}
</CardContent>
</Card>
{/* Activate Card */}
<Card>
<CardHeader>
<CardTitle className="text-base">Enter License Key</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<Input placeholder="DENTAL-XXXXXXXXXXXXXXXXXXXXXXXX-YYYY-MM-DD" value={keyInput} onChange={(e) => setKeyInput(e.target.value)} className="font-mono text-sm"/>
<Button onClick={() => activateMutation.mutate(keyInput)} disabled={!keyInput.trim() || activateMutation.isPending} className="w-full">
{activateMutation.isPending ? "Activating..." : "Activate"}
</Button>
</CardContent>
</Card>
{/* Features Card */}
<Card>
<CardHeader>
<CardTitle className="text-base">Features</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-gray-400 mb-2">Free</p>
<div className="space-y-1">
{[
"MassHealth Eligibility",
"MassHealth Claim",
"Documents",
"Payments",
"Database Backups",
"Reports",
].map((f) => (<div key={f} className="flex items-center space-x-2">
<CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0"/>
<span className="text-sm text-gray-700">{f}</span>
</div>))}
</div>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-gray-400 mb-2">Premium</p>
<div className="space-y-1">
{[
"CCA, DDMA, United, Tufts Eligibility",
"CCA, DDMA, United, Tufts Claims",
"Pre-Authorizations",
"AI SMS",
].map((f) => (<div key={f} className="flex items-center space-x-2">
{isActive ? (<Shield className="h-4 w-4 text-blue-500 flex-shrink-0"/>) : (<ShieldOff className="h-4 w-4 text-gray-300 flex-shrink-0"/>)}
<span className={cn("text-sm", isActive ? "text-gray-700" : "text-gray-400")}>
{f}
</span>
</div>))}
</div>
</div>
</CardContent>
</Card>
</div>);
}
@@ -0,0 +1,120 @@
import { Bot, Keyboard, FileCheck, CreditCard, Shield, Zap, ArrowRight } from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
const SOFTWARE_TARGETS = [
{ name: "Open Dental", color: "bg-blue-100 text-blue-700 border-blue-200" },
{ name: "Eaglesoft", color: "bg-emerald-100 text-emerald-700 border-emerald-200" },
{ name: "Dentrix", color: "bg-violet-100 text-violet-700 border-violet-200" },
{ name: "Curve Dental", color: "bg-orange-100 text-orange-700 border-orange-200" },
];
const CAPABILITIES = [
{
icon: <Shield className="h-5 w-5 text-teal-600"/>,
title: "Eligibility Results",
description: "Automatically type insurance eligibility information — coverage status, plan details, deductibles, and co-pays — directly into the patient chart in your dental software.",
},
{
icon: <FileCheck className="h-5 w-5 text-blue-600"/>,
title: "Claim Information",
description: "Transfer claim numbers, claim status updates, and denial reasons from the insurance portal results into your claims module without manual re-entry.",
},
{
icon: <CreditCard className="h-5 w-5 text-indigo-600"/>,
title: "Insurance Payments",
description: "Post ERA / EOB payment details — amounts, adjustments, patient responsibility — directly into your payment ledger by typing them into the active software window.",
},
];
export default function AiInputAgentPage() {
return (<div className="max-w-3xl mx-auto px-4 py-10 space-y-10">
{/* Hero */}
<div className="text-center space-y-3">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-violet-100 mb-2">
<Bot className="h-8 w-8 text-violet-600"/>
</div>
<h1 className="text-3xl font-bold tracking-tight">AI Input Agent</h1>
<p className="text-muted-foreground text-base max-w-xl mx-auto">
An AI-powered agent that reads data from this app and types it directly into your
current dental management software — no copy-paste, no manual re-entry.
</p>
<span className="inline-block bg-amber-100 text-amber-700 text-xs font-semibold px-3 py-1 rounded-full border border-amber-200">
Coming Soon
</span>
</div>
{/* How it works */}
<Card>
<CardContent className="py-6 space-y-4">
<div className="flex items-center gap-2">
<Keyboard className="h-5 w-5 text-violet-500"/>
<h2 className="text-base font-semibold">How it works</h2>
</div>
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-3 text-sm">
{[
"Retrieve data in this app\n(eligibility, claim, payment)",
"Agent identifies the active\nfield in your dental software",
"Types the value directly\nvia keyboard automation",
].map((step, i) => (<div key={i} className="flex items-center gap-3 flex-1">
<div className="flex-shrink-0 w-7 h-7 rounded-full bg-violet-100 text-violet-700 text-xs font-bold flex items-center justify-center">
{i + 1}
</div>
<p className="whitespace-pre-line text-muted-foreground leading-snug">{step}</p>
{i < 2 && <ArrowRight className="h-4 w-4 text-muted-foreground/40 hidden sm:block flex-shrink-0"/>}
</div>))}
</div>
<p className="text-xs text-muted-foreground pt-1 border-t">
The agent runs as a lightweight local process on your workstation. It receives
instructions from this app and uses keyboard automation to type data into whatever
window is currently focused in your dental software — no clipboard involved.
</p>
</CardContent>
</Card>
{/* What it will type */}
<div className="space-y-3">
<h2 className="text-base font-semibold">What it will type</h2>
<div className="grid gap-4 sm:grid-cols-3">
{CAPABILITIES.map((cap) => (<Card key={cap.title}>
<CardContent className="py-5 space-y-2">
<div className="flex items-center gap-2">
{cap.icon}
<span className="text-sm font-medium">{cap.title}</span>
</div>
<p className="text-xs text-muted-foreground leading-relaxed">
{cap.description}
</p>
</CardContent>
</Card>))}
</div>
</div>
{/* Supported software */}
<div className="space-y-3">
<h2 className="text-base font-semibold">Planned software support</h2>
<div className="flex flex-wrap gap-2">
{SOFTWARE_TARGETS.map((s) => (<span key={s.name} className={`text-sm font-medium px-3 py-1.5 rounded-lg border ${s.color}`}>
{s.name}
</span>))}
</div>
<p className="text-xs text-muted-foreground">
Each software has its own field layout. The agent will include pre-built templates
for common workflows in each platform.
</p>
</div>
{/* Why */}
<Card className="border-violet-200 bg-violet-50/50">
<CardContent className="py-5 flex items-start gap-3">
<Zap className="h-5 w-5 text-violet-500 mt-0.5 flex-shrink-0"/>
<div className="space-y-1">
<p className="text-sm font-medium text-violet-900">Why this matters</p>
<p className="text-sm text-violet-700 leading-relaxed">
Staff currently check eligibility or look up a claim here, then manually retype
every value into Open Dental or Eaglesoft. This agent eliminates that step entirely —
one click sends the data straight into the right field.
</p>
</div>
</CardContent>
</Card>
</div>);
}
File diff suppressed because it is too large Load Diff
+164
View File
@@ -0,0 +1,164 @@
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 { Checkbox } from "@/components/ui/checkbox";
import { Card } from "@/components/ui/card";
import { Sparkles } from "lucide-react";
import LoadingScreen from "@/components/ui/LoadingScreen";
import { useLocation } from "wouter";
import { loginSchema } from "@repo/db/types";
const DAILY_PALETTES = [
{ bg: "#f8fafc", blobs: ["#a78bfa", "#38bdf8", "#f472b6", "#fb923c"], textColor: "#1e293b" },
{ bg: "#faf5ff", blobs: ["#c084fc", "#22d3ee", "#fb7185", "#facc15"], textColor: "#1e1b4b" },
{ bg: "#f0f9ff", blobs: ["#60a5fa", "#a78bfa", "#34d399", "#f97316"], textColor: "#0c4a6e" },
{ bg: "#fdf4ff", blobs: ["#e879f9", "#38bdf8", "#fbbf24", "#4ade80"], textColor: "#4a044e" },
{ bg: "#f0fdf4", blobs: ["#4ade80", "#818cf8", "#fb923c", "#f472b6"], textColor: "#052e16" },
{ bg: "#fffbeb", blobs: ["#fbbf24", "#f87171", "#a78bfa", "#22d3ee"], textColor: "#78350f" },
{ bg: "#f8fafc", blobs: ["#38bdf8", "#e879f9", "#34d399", "#fb923c"], textColor: "#0f172a" },
{ bg: "#fef2f2", blobs: ["#fb7185", "#a78bfa", "#38bdf8", "#4ade80"], textColor: "#7f1d1d" },
{ bg: "#ecfdf5", blobs: ["#34d399", "#818cf8", "#f472b6", "#facc15"], textColor: "#064e3b" },
{ bg: "#f5f3ff", blobs: ["#8b5cf6", "#f472b6", "#22d3ee", "#fb923c"], textColor: "#2e1065" },
{ bg: "#fff7ed", blobs: ["#fb923c", "#a78bfa", "#22d3ee", "#4ade80"], textColor: "#7c2d12" },
{ bg: "#f0f9ff", blobs: ["#22d3ee", "#c084fc", "#f97316", "#34d399"], textColor: "#0c4a6e" },
{ bg: "#fdf2f8", blobs: ["#f472b6", "#60a5fa", "#facc15", "#34d399"], textColor: "#831843" },
{ bg: "#f8fafc", blobs: ["#818cf8", "#f97316", "#22d3ee", "#f472b6"], textColor: "#1e293b" },
];
function getDailyPalette() {
const now = new Date();
const start = new Date(now.getFullYear(), 0, 0);
const dayOfYear = Math.floor((now.getTime() - start.getTime()) / 86400000);
return DAILY_PALETTES[dayOfYear % DAILY_PALETTES.length];
}
export default function AuthPage() {
const { isLoading, user, loginMutation } = useAuth();
const [, navigate] = useLocation();
const [greeting, setGreeting] = useState("How can I help you today?");
const palette = getDailyPalette();
useEffect(() => {
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL_BACKEND ?? "";
fetch(`${API_BASE_URL}/api/greeting`)
.then((r) => r.json())
.then((data) => { if (data.greeting)
setGreeting(data.greeting); })
.catch(() => { });
}, []);
const loginForm = useForm({
resolver: zodResolver(loginSchema),
defaultValues: {
username: "",
password: "",
rememberMe: false,
},
});
const onLoginSubmit = (data) => {
loginMutation.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" style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
<div className="w-full max-w-4xl grid grid-cols-1 md:grid-cols-2 shadow-xl rounded-2xl overflow-hidden">
{/* Auth Form */}
<Card className="p-8 bg-white">
<div className="mb-10 text-center">
<h1 className="text-3xl font-bold text-primary mb-3 tracking-tight">
My Dental Office Management
</h1>
<p className="text-sm font-semibold tracking-[0.15em] bg-clip-text text-transparent animate-gradient-flash" style={{ backgroundImage: "linear-gradient(90deg, #7c3aed, #d946ef, #06b6d4, #7c3aed)", backgroundSize: "200% 100%" }}>
Driven By Multiple AI Agents
</p>
</div>
<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} onCheckedChange={field.onChange}/>
<label htmlFor="remember-me" className="text-sm font-medium text-gray-700">
Remember me
</label>
</div>)}/>
</div>
<Button type="submit" className="w-full" disabled={loginMutation.isPending}>
{loginMutation.isPending ? "Signing in..." : "Sign in"}
</Button>
</form>
</Form>
</Card>
{/* Hero Section — Stripe-style vibrant gradient mesh */}
<div className="relative overflow-hidden md:flex p-8 flex flex-col justify-center items-center min-h-[320px]" style={{ background: palette.bg }}>
{/* Animated blobs */}
<div className="absolute inset-0 login-blob-container">
<div className="absolute opacity-70 login-blob" style={{
width: 300, height: 300,
top: "-15%", left: "-10%",
background: palette.blobs[0],
filter: "blur(70px)",
}}/>
<div className="absolute opacity-70 login-blob animation-delay-2000" style={{
width: 350, height: 350,
top: "50%", right: "-15%",
background: palette.blobs[1],
filter: "blur(70px)",
}}/>
<div className="absolute opacity-70 login-blob animation-delay-4000" style={{
width: 280, height: 280,
bottom: "-15%", left: "15%",
background: palette.blobs[2],
filter: "blur(70px)",
}}/>
</div>
{/* 4th accent blob */}
<div className="absolute opacity-50 login-blob animation-delay-2000" style={{
width: 220, height: 220,
top: "15%", right: "20%",
background: palette.blobs[3],
filter: "blur(60px)",
}}/>
{/* Content */}
<div className="relative z-10 flex flex-col items-center">
<div className="flex justify-center mb-8">
<div className="w-20 h-20 backdrop-blur-md rounded-full flex items-center justify-center border" style={{
background: "rgba(255,255,255,0.25)",
borderColor: "rgba(255,255,255,0.4)",
}}>
<Sparkles className="h-10 w-10" style={{ color: palette.textColor }}/>
</div>
</div>
<p className="text-center text-2xl leading-relaxed font-bold tracking-tight" style={{ color: palette.textColor }}>
{greeting}
</p>
</div>
</div>
</div>
</div>);
}
+99
View File
@@ -0,0 +1,99 @@
import { useLocation } from "wouter";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { TeethChart } from "@/components/chart/teeth-chart";
import { TreatmentPlanTab } from "@/components/chart/treatment-plan-tab";
import { PrescriptionTab } from "@/components/chart/prescription-tab";
import { LabManagementTab } from "@/components/chart/lab-management-tab";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { LayoutGrid, ListChecks, Pill, Microscope } from "lucide-react";
const SECTIONS = [
{ value: "charting", label: "Charting Map", icon: LayoutGrid },
{ value: "treatment-plan", label: "Treatment Plan", icon: ListChecks },
{ value: "prescription", label: "Prescription", icon: Pill },
{ value: "lab-management", label: "Lab Management", icon: Microscope },
];
function getSection(location) {
for (const s of SECTIONS) {
if (location.includes(s.value))
return s.value;
}
return "charting";
}
export default function ChartPage() {
const [location, navigate] = useLocation();
const activeTab = getSection(location);
return (<div className="p-4 md:p-6 space-y-4">
<div>
<h1 className="text-xl font-semibold text-gray-900">Patient Chart</h1>
<p className="text-sm text-gray-500 mt-0.5">
Charting, treatment planning, prescriptions, and lab orders
</p>
</div>
<Tabs value={activeTab} onValueChange={(v) => navigate(`/chart/${v}`)}>
<TabsList className="grid w-full grid-cols-2 md:grid-cols-4 h-auto gap-1 bg-gray-100 p-1 rounded-lg">
{SECTIONS.map(({ value, label, icon: Icon }) => (<TabsTrigger key={value} value={value} className="flex items-center gap-1.5 text-xs sm:text-sm py-2">
<Icon className="h-3.5 w-3.5 flex-shrink-0"/>
<span className="hidden sm:inline">{label}</span>
<span className="sm:hidden">{label.split(" ")[0]}</span>
</TabsTrigger>))}
</TabsList>
<TabsContent value="charting" className="mt-4">
<Card>
<CardHeader className="pb-3 pt-4 px-4">
<CardTitle className="text-base font-medium flex items-center gap-2">
<LayoutGrid className="h-4 w-4 text-primary"/>
Charting Map
</CardTitle>
</CardHeader>
<CardContent className="px-4 pb-4">
<TeethChart />
</CardContent>
</Card>
</TabsContent>
<TabsContent value="treatment-plan" className="mt-4">
<Card>
<CardHeader className="pb-3 pt-4 px-4">
<CardTitle className="text-base font-medium flex items-center gap-2">
<ListChecks className="h-4 w-4 text-primary"/>
Treatment Plan
</CardTitle>
</CardHeader>
<CardContent className="px-4 pb-4">
<TreatmentPlanTab />
</CardContent>
</Card>
</TabsContent>
<TabsContent value="prescription" className="mt-4">
<Card>
<CardHeader className="pb-3 pt-4 px-4">
<CardTitle className="text-base font-medium flex items-center gap-2">
<Pill className="h-4 w-4 text-primary"/>
Prescription
</CardTitle>
</CardHeader>
<CardContent className="px-4 pb-4">
<PrescriptionTab />
</CardContent>
</Card>
</TabsContent>
<TabsContent value="lab-management" className="mt-4">
<Card>
<CardHeader className="pb-3 pt-4 px-4">
<CardTitle className="text-base font-medium flex items-center gap-2">
<Microscope className="h-4 w-4 text-primary"/>
Lab Management
</CardTitle>
</CardHeader>
<CardContent className="px-4 pb-4">
<LabManagementTab />
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,147 @@
import { useEffect, useRef, useState } from "react";
import { useSearch } from "wouter";
import { useQueryClient } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import { Folder as FolderIcon } 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();
const search = useSearch();
// panel open + initial folder id to show when opening
const [panelOpen, setPanelOpen] = useState(false);
const [panelInitialFolderId, setPanelInitialFolderId] = useState(null);
const panelRef = useRef(null);
// Deep-link: if navigated here with ?folderId=XXX, open that folder automatically
useEffect(() => {
const params = new URLSearchParams(search);
const id = Number(params.get("folderId"));
if (id > 0) {
setPanelInitialFolderId(id);
setPanelOpen(true);
}
}, [search]);
// 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(null);
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
// searchbar - handlers
function handleOpenFolder(folderId) {
setPanelInitialFolderId(folderId);
setPanelOpen(true);
setTimeout(() => {
panelRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
}, 50);
}
function handleSelectFile(fileId) {
setPreviewFileId(fileId);
setIsPreviewOpen(true);
}
// create folder handler (page-level)
async function handleCreateFolder(name) {
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) {
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 */}
<div ref={panelRef}/>
{panelOpen && (<FolderPanel folderId={panelInitialFolderId} onClose={() => setPanelOpen(false)} onViewChange={(viewedId) => {
// 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>);
}
+286
View File
@@ -0,0 +1,286 @@
import { useState, useRef } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import { format, parse } 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 { QK_PATIENTS_BASE } from "@/components/patients/patient-table";
export default function Dashboard() {
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const [isAddPatientOpen, setIsAddPatientOpen] = useState(false);
const [isAddAppointmentOpen, setIsAddAppointmentOpen] = useState(false);
const [currentPatient, setCurrentPatient] = useState(undefined);
const [selectedAppointment, setSelectedAppointment] = useState(undefined);
const { toast } = useToast();
const { user } = useAuth();
const addPatientModalRef = useRef(null);
// Fetch patients
const { data: patients = [], isLoading: isLoadingPatients } = useQuery({
queryKey: ["/api/patients/"],
queryFn: async () => {
const res = await apiRequest("GET", "/api/patients/");
return res.json();
},
enabled: !!user,
});
// Fetch appointments
const { data: appointments = [], isLoading: isLoadingAppointments, } = useQuery({
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) => {
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) => {
if (user) {
addPatientMutation.mutate({
...patient,
userId: user.id,
});
}
};
const isLoading = isLoadingPatients || addPatientMutation.isPending;
// Create/upsert appointment mutation
const createAppointmentMutation = useMutation({
mutationFn: async (appointment) => {
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) => {
toast({
title: "Error",
description: `Failed to create appointment: ${error.message}`,
variant: "destructive",
});
},
});
// Update appointment mutation
const updateAppointmentMutation = useMutation({
mutationFn: async ({ id, appointment, }) => {
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) => {
toast({
title: "Error",
description: `Failed to update appointment: ${error.message}`,
variant: "destructive",
});
},
});
// Handle appointment submission (create or update)
const handleAppointmentSubmit = (appointmentData) => {
if (selectedAppointment && typeof selectedAppointment.id === "number") {
updateAppointmentMutation.mutate({
id: selectedAppointment.id,
appointment: appointmentData,
});
}
else {
if (user) {
createAppointmentMutation.mutate({
...appointmentData,
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>);
}
@@ -0,0 +1,284 @@
import { useState } from "react";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
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";
import { NetworkBackupManager } from "@/components/database-management/network-backup-manager";
import { ImportDatabaseSection } from "@/components/database-management/import-database-section";
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) {
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) {
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";
}
}
// ----- Auto backup setting query -----
const { data: autoBackupData } = useQuery({
queryKey: ["/db/auto-backup-setting"],
queryFn: async () => {
const res = await apiRequest("GET", "/api/database-management/auto-backup-setting");
return res.json();
},
});
const autoBackupEnabled = autoBackupData?.autoBackupEnabled ?? true;
const autoBackupHour = autoBackupData?.autoBackupHour ?? 20;
const autoBackupMutation = useMutation({
mutationFn: async (patch) => {
const res = await apiRequest("PUT", "/api/database-management/auto-backup-setting", patch);
return res.json();
},
onSuccess: (data) => {
queryClient.setQueryData(["/db/auto-backup-setting"], data);
toast({ title: "Setting Saved" });
},
onError: () => {
toast({
title: "Error",
description: "Failed to update automatic backup setting.",
variant: "destructive",
});
},
});
// ----- 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?.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) => {
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 flex-wrap items-center gap-3">
<Switch id="auto-backup-toggle" checked={autoBackupEnabled} onCheckedChange={(checked) => autoBackupMutation.mutate({ autoBackupEnabled: checked })} disabled={autoBackupMutation.isPending}/>
<label htmlFor="auto-backup-toggle" className="text-sm font-medium text-gray-700 cursor-pointer select-none">
Automatic Backup
</label>
<div className="flex items-center gap-2">
<label className="text-sm text-gray-600">at</label>
<select className="border rounded px-2 py-1 text-sm text-gray-700 bg-white" value={autoBackupHour} onChange={(e) => autoBackupMutation.mutate({ autoBackupHour: Number(e.target.value) })}>
{Array.from({ length: 24 }, (_, h) => {
const label = h === 0 ? "12:00 AM" : h < 12 ? `${h}:00 AM` : h === 12 ? "12:00 PM" : `${h - 12}:00 PM`;
return <option key={h} value={h}>{label}</option>;
})}
</select>
</div>
</div>
<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 />
{/* Network Backup (PC-to-PC sync) */}
<NetworkBackupManager />
{/* Import / Restore Database */}
<ImportDatabaseSection />
{/* 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>);
}
@@ -0,0 +1,208 @@
import { useState, useEffect } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "@/lib/queryClient";
import { useAuth } from "@/hooks/use-auth";
import { useToast } from "@/hooks/use-toast";
import { Button } from "@/components/ui/button";
import { DeleteConfirmationDialog } from "@/components/ui/deleteDialog";
import { KeyRound, Plus, Pencil, Trash2, Eye, EyeOff, ExternalLink } from "lucide-react";
const QUERY_KEY = ["/api/shopping-vendors/"];
// ─── Modal ─────────────────────────────────────────────────────────────────
function VendorModal({ userId, defaultValues, onClose, }) {
const { toast } = useToast();
const qc = useQueryClient();
const [vendorName, setVendorName] = useState(defaultValues?.vendorName ?? "");
const [websiteUrl, setWebsiteUrl] = useState(defaultValues?.websiteUrl ?? "");
const [loginUsername, setLoginUsername] = useState(defaultValues?.loginUsername ?? "");
const [loginPassword, setLoginPassword] = useState(defaultValues?.loginPassword ?? "");
const [showPassword, setShowPassword] = useState(false);
useEffect(() => {
setVendorName(defaultValues?.vendorName ?? "");
setWebsiteUrl(defaultValues?.websiteUrl ?? "");
setLoginUsername(defaultValues?.loginUsername ?? "");
setLoginPassword(defaultValues?.loginPassword ?? "");
}, [defaultValues]);
const mutation = useMutation({
mutationFn: async () => {
const payload = { vendorName: vendorName.trim(), websiteUrl: websiteUrl.trim(), loginUsername: loginUsername.trim(), loginPassword: loginPassword.trim(), userId };
const url = defaultValues?.id ? `/api/shopping-vendors/${defaultValues.id}` : "/api/shopping-vendors/";
const method = defaultValues?.id ? "PUT" : "POST";
const res = await apiRequest(method, url, payload);
if (!res.ok) {
const err = await res.json().catch(() => null);
throw new Error(err?.message || "Failed to save vendor");
}
return res.json();
},
onSuccess: () => {
toast({ title: `Vendor ${defaultValues?.id ? "updated" : "added"} successfully.` });
qc.invalidateQueries({ queryKey: QUERY_KEY });
onClose();
},
onError: (err) => {
toast({ title: "Error", description: err.message, variant: "destructive" });
},
});
const handleSubmit = (e) => {
e.preventDefault();
if (!vendorName || !websiteUrl || !loginUsername || !loginPassword) {
toast({ title: "All fields are required.", variant: "destructive" });
return;
}
mutation.mutate();
};
return (<div className="fixed inset-0 bg-black/50 flex justify-center items-center z-50">
<div className="bg-white rounded-xl p-6 w-full max-w-md shadow-xl">
<h2 className="text-lg font-semibold mb-5">
{defaultValues?.id ? "Edit Vendor" : "Add Vendor"}
</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Vendor Name</label>
<input type="text" placeholder="e.g. Net32" value={vendorName} onChange={(e) => setVendorName(e.target.value)} className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-cyan-500"/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Website URL</label>
<input type="url" placeholder="https://www.net32.com" value={websiteUrl} onChange={(e) => setWebsiteUrl(e.target.value)} className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-cyan-500"/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Username / Email</label>
<input type="text" placeholder="your@email.com" value={loginUsername} onChange={(e) => setLoginUsername(e.target.value)} className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-cyan-500"/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Password</label>
<div className="relative">
<input type={showPassword ? "text" : "password"} placeholder="••••••••" value={loginPassword} onChange={(e) => setLoginPassword(e.target.value)} className="w-full border rounded-lg px-3 py-2 pr-10 text-sm focus:outline-none focus:ring-2 focus:ring-cyan-500"/>
<button type="button" tabIndex={-1} onClick={() => setShowPassword((p) => !p)} className="absolute inset-y-0 right-3 flex items-center text-gray-400 hover:text-gray-600">
{showPassword ? <EyeOff className="h-4 w-4"/> : <Eye className="h-4 w-4"/>}
</button>
</div>
</div>
<div className="flex justify-end gap-3 pt-2">
<button type="button" onClick={onClose} disabled={mutation.isPending} className="text-sm text-gray-500 hover:underline">
Cancel
</button>
<button type="submit" disabled={mutation.isPending} className="bg-cyan-600 text-white text-sm px-5 py-2 rounded-lg hover:bg-cyan-700 disabled:opacity-50">
{mutation.isPending ? "Saving..." : defaultValues?.id ? "Update" : "Add Vendor"}
</button>
</div>
</form>
</div>
</div>);
}
// ─── Page ──────────────────────────────────────────────────────────────────
export default function DentalShoppingLoginInfoPage() {
const { user } = useAuth();
const { toast } = useToast();
const qc = useQueryClient();
const [modalOpen, setModalOpen] = useState(false);
const [editingVendor, setEditingVendor] = useState(null);
const [deleteTarget, setDeleteTarget] = useState(null);
const [revealedIds, setRevealedIds] = useState(new Set());
const { data: vendors = [], isLoading } = useQuery({
queryKey: QUERY_KEY,
queryFn: async () => {
const res = await apiRequest("GET", "/api/shopping-vendors/");
if (!res.ok)
throw new Error("Failed to fetch vendors");
return res.json();
},
});
const deleteMutation = useMutation({
mutationFn: async (id) => {
const res = await apiRequest("DELETE", `/api/shopping-vendors/${id}`);
if (!res.ok)
throw new Error("Failed to delete vendor");
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: QUERY_KEY });
setDeleteTarget(null);
toast({ title: "Vendor deleted." });
},
onError: (err) => {
toast({ title: "Error", description: err.message, variant: "destructive" });
},
});
const toggleReveal = (id) => {
setRevealedIds((prev) => {
const next = new Set(prev);
if (next.has(id))
next.delete(id);
else
next.add(id);
return next;
});
};
return (<div className="p-6 max-w-4xl mx-auto">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-cyan-50">
<KeyRound className="h-6 w-6 text-cyan-600"/>
</div>
<div>
<h1 className="text-2xl font-semibold text-gray-800">Login Info</h1>
<p className="text-sm text-gray-500">Manage login credentials for dental shopping websites</p>
</div>
</div>
<Button onClick={() => { setEditingVendor(null); setModalOpen(true); }} className="bg-cyan-600 hover:bg-cyan-700 text-white">
<Plus className="h-4 w-4 mr-2"/> Add Vendor
</Button>
</div>
{/* Table */}
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
{isLoading ? (<div className="py-16 text-center text-gray-400 text-sm">Loading...</div>) : vendors.length === 0 ? (<div className="py-16 flex flex-col items-center gap-3 text-center">
<KeyRound className="h-10 w-10 text-gray-300"/>
<p className="text-gray-400 text-sm">No vendors saved yet. Click "Add Vendor" to get started.</p>
</div>) : (<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-5 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Vendor</th>
<th className="px-5 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Website</th>
<th className="px-5 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Username / Email</th>
<th className="px-5 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Password</th>
<th className="px-5 py-3"/>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{vendors.map((v) => (<tr key={v.id} className="hover:bg-gray-50 transition-colors">
<td className="px-5 py-3 font-medium text-gray-800 text-sm">{v.vendorName}</td>
<td className="px-5 py-3 text-sm">
<a href={v.websiteUrl} target="_blank" rel="noopener noreferrer" className="text-cyan-600 hover:underline flex items-center gap-1">
{v.websiteUrl.replace(/^https?:\/\//, "")}
<ExternalLink className="h-3 w-3 flex-shrink-0"/>
</a>
</td>
<td className="px-5 py-3 text-sm text-gray-600">{v.loginUsername}</td>
<td className="px-5 py-3 text-sm text-gray-600">
<div className="flex items-center gap-2">
<span className="font-mono">
{revealedIds.has(v.id) ? v.loginPassword : "••••••••"}
</span>
<button onClick={() => toggleReveal(v.id)} className="text-gray-400 hover:text-gray-600" title={revealedIds.has(v.id) ? "Hide password" : "Show password"}>
{revealedIds.has(v.id) ? <EyeOff className="h-3.5 w-3.5"/> : <Eye className="h-3.5 w-3.5"/>}
</button>
</div>
</td>
<td className="px-5 py-3 text-right">
<Button variant="ghost" size="sm" onClick={() => { setEditingVendor(v); setModalOpen(true); }}>
<Pencil className="h-4 w-4 text-gray-500"/>
</Button>
<Button variant="ghost" size="sm" onClick={() => setDeleteTarget(v)}>
<Trash2 className="h-4 w-4 text-red-500"/>
</Button>
</td>
</tr>))}
</tbody>
</table>)}
</div>
{/* Add/Edit Modal */}
{modalOpen && user?.id != null && (<VendorModal userId={user.id} defaultValues={editingVendor ?? undefined} onClose={() => { setModalOpen(false); setEditingVendor(null); }}/>)}
{/* Delete Confirm */}
<DeleteConfirmationDialog isOpen={!!deleteTarget} onConfirm={() => deleteTarget && deleteMutation.mutate(deleteTarget.id)} onCancel={() => setDeleteTarget(null)} entityName={deleteTarget?.vendorName}/>
</div>);
}
@@ -0,0 +1,24 @@
import { ShoppingBag, Tag, Search } from "lucide-react";
export default function DentalShoppingSearchTagPage() {
return (<div className="p-6">
<div className="flex items-center gap-3 mb-6">
<div className="p-2 rounded-lg bg-cyan-50">
<Search className="h-6 w-6 text-cyan-600"/>
</div>
<div>
<h1 className="text-2xl font-semibold text-gray-800">Search / Tag</h1>
<p className="text-sm text-gray-500">Compare, tag, and remember dental products</p>
</div>
</div>
<div className="rounded-xl border border-dashed border-gray-300 bg-gray-50 flex flex-col items-center justify-center py-24 text-center">
<div className="flex gap-3 mb-4">
<ShoppingBag className="h-10 w-10 text-cyan-300"/>
<Tag className="h-10 w-10 text-cyan-300"/>
</div>
<p className="text-gray-400 text-sm">
Search, compare, tag, and save dental products — coming soon.
</p>
</div>
</div>);
}
+550
View File
@@ -0,0 +1,550 @@
import { useEffect, useMemo, useState } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import { useLocation } from "wouter";
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, HardDrive } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { DeleteConfirmationDialog } from "@/components/ui/deleteDialog";
import { PatientTable } from "@/components/patients/patient-table";
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, formatFileSize } from "@/lib/api/documents";
export default function DocumentsPage() {
const { user } = useAuth();
const [, setLocation] = useLocation();
const [selectedPatient, setSelectedPatient] = useState(null);
const [expandedGroupId, setExpandedGroupId] = useState(null);
// pagination state for the expanded group
const [currentPage, setCurrentPage] = useState(1);
const [limit, setLimit] = useState(5);
const offset = (currentPage - 1) * limit;
const [totalForExpandedGroup, setTotalForExpandedGroup] = useState(null);
const [showPatientDocuments, setShowPatientDocuments] = useState(false);
// Document preview state
const [previewDocumentId, setPreviewDocumentId] = useState(null);
const [isPreviewModalOpen, setIsPreviewModalOpen] = useState(false);
// Delete document state
const [deleteDocumentId, setDeleteDocumentId] = useState(null);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
// PDF state
const [currentPdf, setCurrentPdf] = useState(null);
// Delete dialog
const [isDeletePdfOpen, setIsDeletePdfOpen] = useState(false);
// reset UI when patient changes
useEffect(() => {
setExpandedGroupId(null);
setLimit(5);
setCurrentPage(1);
setTotalForExpandedGroup(null);
setShowPatientDocuments(false);
setIsPreviewModalOpen(false);
setPreviewDocumentId(null);
}, [selectedPatient]);
// Patient documents — React Query for caching (re-selecting same patient shows instantly)
const { data: patientDocuments = [], isLoading: patientDocumentsLoading } = useQuery({
queryKey: ["patientDocuments", selectedPatient?.id],
enabled: !!selectedPatient?.id,
staleTime: 2 * 60 * 1000,
queryFn: async () => {
const response = await getPatientDocuments(selectedPatient.id);
if (!response.success)
throw new Error("Failed to load documents");
return response.documents;
},
});
// Derive thumbnails synchronously — no extra async round-trip
const documentThumbnails = useMemo(() => {
const result = {};
for (const doc of patientDocuments) {
if (doc.mimeType.startsWith("image/"))
result[doc.id] = doc.filePath;
}
return result;
}, [patientDocuments]);
// Listen for document upload events — invalidate React Query cache instead of manual refetch
useEffect(() => {
const refresh = () => {
if (selectedPatient?.id) {
queryClient.invalidateQueries({ queryKey: ["patientDocuments", selectedPatient.id] });
}
};
window.addEventListener("documentUploaded", refresh);
const handleStorageChange = (e) => {
if (e.key === "documentUploaded" && e.newValue)
refresh();
};
window.addEventListener("storage", handleStorageChange);
return () => {
window.removeEventListener("documentUploaded", refresh);
window.removeEventListener("storage", handleStorageChange);
};
}, [selectedPatient]);
// Handle document preview
const handlePreviewDocument = (documentId) => {
const document = patientDocuments.find(doc => doc.id === documentId);
setPreviewDocumentId(documentId);
setIsPreviewModalOpen(true);
};
// Handle document delete
const handleDeleteDocument = (documentId) => {
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,
staleTime: 2 * 60 * 1000,
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();
for (const g of groups) {
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 = [];
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 patient's dedicated local (cloud storage) folder
const { data: patientCloudFolder, isLoading: isLoadingCloudFolder } = useQuery({
queryKey: ["patientCloudFolder", selectedPatient?.id, user?.id],
enabled: !!selectedPatient?.id && !!user?.id,
staleTime: 5 * 60 * 1000,
queryFn: async () => {
const res = await apiRequest("GET", `/api/cloud-storage/patient-folder/${selectedPatient.id}?userId=${user.id}`);
const json = await res.json();
if (json.error)
throw new Error(json.message);
return json.data;
},
});
// 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 = groupPdfsResponse ?? [];
// DELETE mutation
const deletePdfMutation = useMutation({
mutationFn: async (id) => {
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) => {
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, filename) => {
setPreviewDocumentId(pdfId);
setIsPreviewModalOpen(true);
};
const handleDownloadPdf = async (pdfId, filename) => {
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) => {
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">
{/* Local Folder (Cloud Storage) */}
<div className="border rounded p-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<HardDrive className="w-5 h-5 text-blue-500"/>
<div>
<div className="font-semibold">
{isLoadingCloudFolder
? "Loading…"
: patientCloudFolder?.name ?? "Local Folder"}
</div>
<div className="text-xs text-muted-foreground">Local disk storage</div>
</div>
</div>
<Button size="sm" variant="outline" disabled={!patientCloudFolder} onClick={() => setLocation(`/cloud-storage?folderId=${patientCloudFolder.id}`)}>
Open in Cloud Storage
</Button>
</div>
</div>
{/* Existing Groups Section */}
<div>
{/* <h4 className="text-lg font-semibold mb-3">Document Groups</h4> */}
{isLoadingGroups ? (<div>Loading groups…</div>) : groups.length === 0 && patientDocuments.length === 0 && !patientDocumentsLoading ? (<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) => (<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);
}} 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.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) => (<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>);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,251 @@
import { useQuery } from "@tanstack/react-query";
import { apiRequest } from "@/lib/queryClient";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Activity, AlertTriangle, CheckCircle2, Clock, RefreshCw, ServerCrash, SkipForward, Wifi, WifiOff, } from "lucide-react";
import { formatDistanceToNow, format } from "date-fns";
// ─── Helpers ──────────────────────────────────────────────────────────────────
const JOB_LABELS = {
"local-backup": "Local Backup (8 PM)",
"usb-backup": "USB Backup (9 PM)",
};
function jobLabel(name) {
return JOB_LABELS[name] ?? name;
}
function StatusBadge({ status }) {
if (status === "success")
return (<Badge className="bg-green-100 text-green-700 border-green-200 gap-1">
<CheckCircle2 className="h-3 w-3"/> Success
</Badge>);
if (status === "failed")
return (<Badge className="bg-red-100 text-red-700 border-red-200 gap-1">
<ServerCrash className="h-3 w-3"/> Failed
</Badge>);
if (status === "skipped")
return (<Badge className="bg-gray-100 text-gray-600 border-gray-200 gap-1">
<SkipForward className="h-3 w-3"/> Skipped
</Badge>);
return (<Badge className="bg-blue-100 text-blue-700 border-blue-200 gap-1">
<Activity className="h-3 w-3 animate-pulse"/> Running
</Badge>);
}
function formatDuration(ms) {
if (ms === null)
return "—";
if (ms < 1000)
return `${ms}ms`;
return `${(ms / 1000).toFixed(1)}s`;
}
function formatDate(iso) {
if (!iso)
return "—";
return format(new Date(iso), "MMM d, yyyy HH:mm:ss");
}
function timeAgo(iso) {
if (!iso)
return "—";
return formatDistanceToNow(new Date(iso), { addSuffix: true });
}
// ─── Page ─────────────────────────────────────────────────────────────────────
export default function JobMonitorPage() {
const { data: summary, isLoading: loadingSummary, refetch: refetchSummary, dataUpdatedAt: summaryUpdated, } = useQuery({
queryKey: ["/job-monitor/summary"],
queryFn: async () => {
const res = await apiRequest("GET", "/api/job-monitor/summary");
return res.json();
},
refetchInterval: 30_000,
});
const { data: failed, isLoading: loadingFailed, refetch: refetchFailed, } = useQuery({
queryKey: ["/job-monitor/failed"],
queryFn: async () => {
const res = await apiRequest("GET", "/api/job-monitor/failed");
return res.json();
},
refetchInterval: 30_000,
});
const { data: seleniumStatus, isLoading: loadingSelenium, refetch: refetchSelenium, } = useQuery({
queryKey: ["/job-monitor/selenium-status"],
queryFn: async () => {
const res = await apiRequest("GET", "/api/job-monitor/selenium-status");
return res.json();
},
refetchInterval: 10_000,
});
function refreshAll() {
refetchSummary();
refetchFailed();
refetchSelenium();
}
const lastUpdated = summaryUpdated
? format(new Date(summaryUpdated), "HH:mm:ss")
: null;
return (<div className="p-6 max-w-5xl mx-auto space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold text-gray-900">Job Monitor</h1>
<p className="text-sm text-gray-500 mt-0.5">
Background job health and queue status
{lastUpdated && (<span className="ml-2 text-gray-400">· last updated {lastUpdated}</span>)}
</p>
</div>
<Button variant="outline" size="sm" onClick={refreshAll} className="gap-1.5">
<RefreshCw className="h-4 w-4"/>
Refresh
</Button>
</div>
{/* ── Cron Jobs ── */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<Clock className="h-4 w-4 text-gray-500"/>
Scheduled Cron Jobs
</CardTitle>
</CardHeader>
<CardContent>
{loadingSummary ? (<p className="text-sm text-gray-400">Loading…</p>) : !summary?.lastRuns.length ? (<p className="text-sm text-gray-500">
No job runs recorded yet. Jobs are scheduled at 8 PM (local backup) and 9 PM (USB backup).
</p>) : (<div className="space-y-3">
{summary.lastRuns.map((log) => (<div key={log.id} className="flex items-start justify-between rounded-lg border border-gray-100 bg-gray-50 px-4 py-3">
<div className="space-y-0.5">
<p className="text-sm font-medium text-gray-800">
{jobLabel(log.jobName)}
</p>
<p className="text-xs text-gray-500">
Last run: {formatDate(log.startedAt)}{" "}
<span className="text-gray-400">({timeAgo(log.startedAt)})</span>
</p>
{log.status === "failed" && log.errorMessage && (<p className="text-xs text-red-600 mt-1 flex items-center gap-1">
<AlertTriangle className="h-3 w-3 shrink-0"/>
{log.errorMessage}
</p>)}
</div>
<div className="flex flex-col items-end gap-1">
<StatusBadge status={log.status}/>
<span className="text-xs text-gray-400">
{formatDuration(log.durationMs)}
</span>
</div>
</div>))}
</div>)}
</CardContent>
</Card>
{/* ── Selenium Queue ── */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
{seleniumStatus?.online ? (<Wifi className="h-4 w-4 text-green-500"/>) : (<WifiOff className="h-4 w-4 text-gray-400"/>)}
Selenium Job Queue
</CardTitle>
</CardHeader>
<CardContent>
{loadingSelenium ? (<p className="text-sm text-gray-400">Loading…</p>) : (<div className="grid grid-cols-3 gap-4">
{/* Online status */}
<div className="rounded-lg border border-gray-100 bg-gray-50 px-4 py-3 text-center">
<p className="text-xs text-gray-500 mb-1">Service</p>
{seleniumStatus?.online ? (<span className="inline-flex items-center gap-1 text-sm font-medium text-green-700">
<CheckCircle2 className="h-4 w-4"/> Online
</span>) : (<span className="inline-flex items-center gap-1 text-sm font-medium text-gray-500">
<WifiOff className="h-4 w-4"/> Offline
</span>)}
</div>
{/* Active jobs */}
<div className="rounded-lg border border-gray-100 bg-gray-50 px-4 py-3 text-center">
<p className="text-xs text-gray-500 mb-1">Active Jobs</p>
<p className={`text-2xl font-bold ${(seleniumStatus?.active_jobs ?? 0) > 0
? "text-blue-600"
: "text-gray-700"}`}>
{seleniumStatus?.active_jobs ?? 0}
</p>
</div>
{/* Queued jobs */}
<div className="rounded-lg border border-gray-100 bg-gray-50 px-4 py-3 text-center">
<p className="text-xs text-gray-500 mb-1">Queued</p>
<p className={`text-2xl font-bold ${(seleniumStatus?.queued_jobs ?? 0) > 0
? "text-amber-600"
: "text-gray-700"}`}>
{seleniumStatus?.queued_jobs ?? 0}
</p>
</div>
</div>)}
</CardContent>
</Card>
{/* ── Failed Alerts ── */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<AlertTriangle className="h-4 w-4 text-red-500"/>
Failed Job Alerts
{!loadingFailed && failed && failed.length > 0 && (<Badge className="bg-red-100 text-red-700 border-red-200 ml-1">
{failed.length}
</Badge>)}
</CardTitle>
</CardHeader>
<CardContent>
{loadingFailed ? (<p className="text-sm text-gray-400">Loading…</p>) : !failed?.length ? (<div className="flex items-center gap-2 text-sm text-green-700">
<CheckCircle2 className="h-4 w-4"/>
No failed jobs — everything looks healthy.
</div>) : (<div className="space-y-2">
{failed.map((log) => (<div key={log.id} className="rounded-lg border border-red-100 bg-red-50 px-4 py-3">
<div className="flex items-center justify-between mb-1">
<span className="text-sm font-medium text-red-800">
{jobLabel(log.jobName)}
</span>
<span className="text-xs text-gray-500">
{formatDate(log.startedAt)}
</span>
</div>
{log.errorMessage && (<p className="text-xs text-red-600">{log.errorMessage}</p>)}
</div>))}
</div>)}
</CardContent>
</Card>
{/* ── Recent History ── */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<Activity className="h-4 w-4 text-gray-500"/>
Recent Run History
</CardTitle>
</CardHeader>
<CardContent>
{loadingSummary ? (<p className="text-sm text-gray-400">Loading…</p>) : !summary?.recentLogs.length ? (<p className="text-sm text-gray-500">No history yet.</p>) : (<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-xs text-gray-500 uppercase tracking-wide">
<th className="text-left py-2 pr-4 font-medium">Job</th>
<th className="text-left py-2 pr-4 font-medium">Started</th>
<th className="text-left py-2 pr-4 font-medium">Duration</th>
<th className="text-left py-2 font-medium">Status</th>
</tr>
</thead>
<tbody>
{summary.recentLogs.map((log) => (<tr key={log.id} className="border-b border-gray-50 last:border-0 hover:bg-gray-50 transition-colors">
<td className="py-2 pr-4 font-medium text-gray-700">
{jobLabel(log.jobName)}
</td>
<td className="py-2 pr-4 text-gray-500">
{formatDate(log.startedAt)}
</td>
<td className="py-2 pr-4 text-gray-500">
{formatDuration(log.durationMs)}
</td>
<td className="py-2">
<StatusBadge status={log.status}/>
</td>
</tr>))}
</tbody>
</table>
</div>)}
</CardContent>
</Card>
</div>);
}
+18
View File
@@ -0,0 +1,18 @@
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>);
}
@@ -0,0 +1,298 @@
import { useState } from "react";
import { formatDistanceToNow, parseISO } from "date-fns";
import { useQuery, useMutation } from "@tanstack/react-query";
import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { Phone, PhoneCall, Clock, User, Search, MessageSquare, Send, X, MoonStar, } from "lucide-react";
import { SmsTemplateDialog } from "@/components/patient-connection/sms-template-diaog";
import { MessageThread } from "@/components/patient-connection/message-thread";
import { DialPad } from "@/components/patient-connection/dial-pad";
import { useToast } from "@/hooks/use-toast";
import { apiRequest } from "@/lib/queryClient";
export default function PatientConnectionPage() {
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const [searchTerm, setSearchTerm] = useState("");
const [selectedPatient, setSelectedPatient] = useState(null);
const [isSmsDialogOpen, setIsSmsDialogOpen] = useState(false);
const [showMessaging, setShowMessaging] = useState(false);
const [afterHoursEnabled, setAfterHoursEnabled] = useState(true);
const { toast } = useToast();
useQuery({
queryKey: ["/api/twilio/after-hours-handoff"],
queryFn: async () => {
const res = await apiRequest("GET", "/api/twilio/after-hours-handoff");
return res.json();
},
onSuccess: (data) => setAfterHoursEnabled(data.enabled),
});
const afterHoursMutation = useMutation({
mutationFn: async (enabled) => apiRequest("PUT", "/api/twilio/after-hours-handoff", { enabled }),
onSuccess: (_, enabled) => {
toast({
title: enabled ? "After-hours AI enabled" : "After-hours AI disabled",
description: enabled
? "AI will automatically handle messages outside office hours."
: "After-hours messages will not receive an automatic AI reply.",
});
},
});
const handleAfterHoursToggle = (enabled) => {
setAfterHoursEnabled(enabled);
afterHoursMutation.mutate(enabled);
};
const toggleMobileMenu = () => {
setIsMobileMenuOpen(!isMobileMenuOpen);
};
const makeCallMutation = useMutation({
mutationFn: async ({ to, patientId, }) => {
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) => {
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({
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) => {
if (!patient.phone?.trim()) {
toast({
title: "No Phone Number",
description: "This patient does not have a phone number on file.",
variant: "destructive",
});
return;
}
makeCallMutation.mutate({
to: patient.phone,
patientId: Number(patient.id),
});
};
// Handle sending SMS
const handleSMS = (patient) => {
setSelectedPatient(patient);
setIsSmsDialogOpen(true);
};
// Handle opening messaging
const handleOpenMessaging = (patient) => {
setSelectedPatient(patient);
setShowMessaging(true);
};
// Handle closing messaging
const handleCloseMessaging = () => {
setShowMessaging(false);
setSelectedPatient(null);
};
const { data: recentCommunications = [] } = useQuery({
queryKey: ["/api/twilio/recent-communications"],
refetchInterval: 10000,
});
// One entry per patient, keeping the most recent communication
const recentPatients = Object.values(recentCommunications.reduce((acc, comm) => {
if (!comm.patient)
return acc;
if (!acc[comm.patient.id])
acc[comm.patient.id] = comm;
return acc;
}, {}));
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>
{/* After-hours AI toggle */}
<div className="flex items-center gap-2 px-4 py-2 rounded-lg border bg-white shadow-sm">
<MoonStar className={`h-4 w-4 ${afterHoursEnabled ? "text-primary" : "text-muted-foreground"}`}/>
<span className="text-sm font-medium">Hand off to AI after hours</span>
<Switch checked={afterHoursEnabled} onCheckedChange={handleAfterHoursToggle}/>
</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>
{/* Recent Calls */}
<Card className="mb-6">
<CardHeader>
<CardTitle>Recent Conversations</CardTitle>
<CardDescription>
View and manage recent patient conversations
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-2">
{recentPatients.length === 0 ? (<p className="text-sm text-muted-foreground text-center py-4">
No conversations yet.
</p>) : (recentPatients.map((comm) => (<div key={comm.patient.id} className="flex items-center justify-between p-3 border rounded-lg hover:bg-gray-50 transition-colors cursor-pointer" onClick={() => handleOpenMessaging(comm.patient)}>
<div className="flex items-center space-x-3">
<div className="h-9 w-9 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-sm font-semibold flex-shrink-0">
{comm.patient.firstName?.[0]}{comm.patient.lastName?.[0]}
</div>
<p className="font-medium">
{comm.patient.firstName} {comm.patient.lastName}
</p>
</div>
<p className="text-xs text-muted-foreground">
{comm.createdAt
? formatDistanceToNow(parseISO(comm.createdAt), { addSuffix: true })
: ""}
</p>
</div>)))}
</div>
</CardContent>
</Card>
{/* Search and Actions */}
<Card>
<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)} data-testid={`button-call-${patient.id}`}>
<Phone className="h-4 w-4 mr-1"/>
Call
</Button>
<Button variant="outline" size="sm" onClick={() => handleSMS(patient)} data-testid={`button-sms-${patient.id}`}>
<Send className="h-4 w-4 mr-1"/>
SMS
</Button>
<Button variant="outline" size="sm" onClick={() => handleOpenMessaging(patient)} 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>
{/* Dial Pad */}
<div className="mt-6 max-w-xs">
<DialPad />
</div>
{/* 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>);
}
+438
View File
@@ -0,0 +1,438 @@
import { useState, useRef, useCallback } from "react";
import { useMutation } from "@tanstack/react-query";
import { PatientTable } from "@/components/patients/patient-table";
import { AddPatientModal } from "@/components/patients/add-patient-modal";
import { Button } from "@/components/ui/button";
import { Plus, RefreshCw, FilePlus, CalendarPlus, History, HeartPulse, FolderOpen, Scan, Camera, NotebookPen, FileCheck2, } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "@/components/ui/card";
import { apiRequest, queryClient } from "@/lib/queryClient";
import { useAuth } from "@/hooks/use-auth";
import useExtractPdfData from "@/hooks/use-extractPdfData";
import { useLocation } from "wouter";
import { QK_PATIENTS_BASE } from "@/components/patients/patient-table";
import { parse } from "date-fns";
import { formatLocalDate } from "@/utils/dateUtils";
import { MultipleFileUploadZone, } from "@/components/file-upload/multiple-file-upload-zone";
export default function PatientsPage() {
const { toast } = useToast();
const { user } = useAuth();
const [isAddPatientOpen, setIsAddPatientOpen] = useState(false);
const [currentPatient, setCurrentPatient] = useState(undefined);
const addPatientModalRef = useRef(null);
// File upload states
const uploadRef = useRef(null);
const [uploadedFiles, setUploadedFiles] = useState([]);
const [isUploading, setIsUploading] = useState(false);
// extraction state (single boolean for whole process)
const [isExtracting, setIsExtracting] = useState(false);
const { mutate: extractPdf } = useExtractPdfData();
const [location, navigate] = useLocation();
// Add patient mutation
const addPatientMutation = useMutation({
mutationFn: async (patient) => {
const res = await apiRequest("POST", "/api/patients/", patient);
return res.json();
},
onSuccess: (newPatient) => {
setIsAddPatientOpen(false);
queryClient.invalidateQueries({ queryKey: QK_PATIENTS_BASE });
toast({
title: "Success",
description: "Patient added successfully!",
variant: "default",
});
// ✅ Check claim first, then schedule
if (addPatientModalRef.current?.shouldClaim) {
addPatientModalRef.current.navigateToClaim(newPatient.id);
return;
}
if (addPatientModalRef.current?.shouldSchedule) {
addPatientModalRef.current.navigateToSchedule(newPatient.id);
return;
}
},
onError: (error) => {
toast({
title: "Error",
description: `Failed to add patient: ${error.message}`,
variant: "destructive",
});
},
});
const handleAddPatient = (patient) => {
if (user) {
addPatientMutation.mutate({
...patient,
userId: user.id,
});
}
};
const isLoading = addPatientMutation.isPending;
// Hook up file-change coming from MultipleFileUploadZone
const handleFilesChange = (files) => {
// ensure we only keep PDFs (defensive)
const pdfs = files.filter((f) => f.type === "application/pdf");
if (pdfs.length !== files.length) {
toast({
title: "Non-PDF ignored",
description: "Only PDF files are accepted — other file types were ignored.",
variant: "destructive",
});
}
setUploadedFiles(pdfs);
};
/**
* Central helper:
* - extracts PDF (awaits the mutate via a Promise wrapper),
* - shows success toast,
* - ensures patient exists (find by insuranceId, create if missing),
* - shows toasts for errors,
* - returns Patient on success or null on error.
*/
const extractAndEnsurePatientForFile = useCallback(async (file) => {
if (!file) {
toast({
title: "Error",
description: "Please upload a PDF",
variant: "destructive",
});
return null;
}
setIsExtracting(true);
try {
// wrap the extractPdf mutate in a promise so we can await it
const data = await new Promise((resolve, reject) => {
try {
extractPdf(file, {
onSuccess: (d) => resolve(d),
onError: (err) => reject(err),
});
}
catch (err) {
reject(err);
}
});
// 2) basic validation of extracted data — require memberId + name (adjust if needed)
if (!data?.memberId) {
toast({
title: "Extraction result invalid",
description: "No memberId found in PDF — cannot find/create patient.",
variant: "destructive",
});
return null;
}
if (!data?.name) {
toast({
title: "Extraction result invalid",
description: "No patient name found in PDF — cannot create patient.",
variant: "destructive",
});
return null;
}
if (!data.dob) {
toast({
title: "Extraction result invalid",
description: "No DOB extracted — cannot create patient",
variant: "default",
});
}
// success toast for extraction
toast({
title: "Success Pdf Data Extracted",
description: `Name: ${data.name}, Member ID: ${data.memberId}, DOB: ${data.dob}`,
variant: "default",
});
// 1) try to find patient by insurance/memberId
let findRes = null;
try {
findRes = await apiRequest("GET", `/api/patients/by-insurance-id?insuranceId=${encodeURIComponent(data.memberId)}`);
}
catch (err) {
// Network / fetch error — do NOT attempt create; surface error and abort.
toast({
title: "Network error",
description: err?.message ||
"Unable to verify patient by insurance ID due to a network error. Try again.",
variant: "destructive",
});
return null;
}
// 1a) handle fetch response
if (findRes.ok) {
try {
const found = await findRes.json();
if (found && found.id) {
return found;
}
// If the API returned 200 but empty body / null, we'll treat as "not found" and go to create.
}
catch (err) {
toast({
title: "Error parsing response",
description: err?.message ||
"Unable to parse server response when checking existing patient.",
variant: "destructive",
});
return null;
}
}
else {
// findRes is not ok
if (findRes.status === 404) {
// Not found -> proceed to create
}
else {
// Other non-OK -> parse body if possible and abort (don't create)
let body = null;
try {
body = await findRes.json();
}
catch { }
toast({
title: "Error checking patient",
description: body?.message ||
`Failed to check patient (status ${findRes.status}). Aborting.`,
variant: "destructive",
});
return null;
}
}
// 2) not found: create patient
try {
const [firstName, ...rest] = (data.name || "").trim().split(" ");
const lastName = rest.join(" ") || "";
const parsedDob = parse(data.dob, "M/d/yyyy", new Date()); // robust for "4/17/1964", "12/1/1975", etc.
const newPatient = {
firstName: firstName || "",
lastName: lastName || "",
dateOfBirth: formatLocalDate(parsedDob),
gender: "",
phone: "",
userId: user?.id ?? 1,
insuranceId: data.memberId || "",
};
const createRes = await apiRequest("POST", "/api/patients/", newPatient);
if (!createRes.ok) {
let body = null;
try {
body = await createRes.json();
}
catch { }
const msg = body?.message ||
`Failed to create patient (status ${createRes.status})`;
toast({
title: "Error creating patient",
description: msg,
variant: "destructive",
});
return null;
}
const created = await createRes.json();
// success toast already shown by addPatientMutation.onSuccess elsewhere — but we can also show a brief success here
queryClient.invalidateQueries({ queryKey: QK_PATIENTS_BASE });
toast({
title: "Patient created",
description: `${created.firstName || ""} ${created.lastName || ""} created.`,
variant: "default",
});
return created;
}
catch (err) {
toast({
title: "Error creating patient",
description: err?.message || "Failed to create patient.",
variant: "destructive",
});
return null;
}
}
catch (err) {
// extraction error
toast({
title: "Extraction failed",
description: err?.message ?? "Failed to extract data from PDF",
variant: "destructive",
});
return null;
}
finally {
setIsExtracting(false);
}
}, [extractPdf]);
// These two operate only when exactly one file selected
const handleExtractAndClaim = async () => {
if (uploadedFiles.length !== 1)
return;
const patient = await extractAndEnsurePatientForFile(uploadedFiles[0]);
if (!patient)
return;
navigate(`/claims?newPatient=${patient.id}`);
};
const handleExtractAndAppointment = async () => {
if (uploadedFiles.length !== 1)
return;
const patient = await extractAndEnsurePatientForFile(uploadedFiles[0]);
if (!patient)
return;
navigate(`/appointments?newPatient=${patient.id}`);
};
// Batch: iterate files one-by-one and call extractAndEnsurePatientForFile
const handleExtractAndSave = async () => {
if (uploadedFiles.length === 0) {
toast({
title: "No files",
description: "Please upload one or more PDF files first.",
variant: "destructive",
});
return;
}
setIsExtracting(true);
// iterate serially so server isn't hit all at once and order is predictable
for (let i = 0; i < uploadedFiles.length; i++) {
const file = uploadedFiles[i];
toast({
title: `Processing file ${i + 1} of ${uploadedFiles.length}`,
description: file.name,
variant: "default",
});
// await each file
/* eslint-disable no-await-in-loop */
await extractAndEnsurePatientForFile(file);
/* eslint-enable no-await-in-loop */
}
setIsExtracting(false);
// optionally clear files after a successful batch run:
setUploadedFiles([]);
if (uploadRef.current)
uploadRef.current.reset?.();
toast({
title: "Batch complete",
description: `Processed ${uploadedFiles.length} file(s).`,
variant: "default",
});
};
return (<div>
<div className="container mx-auto space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold tracking-tight">Patients</h1>
<p className="text-muted-foreground">
Manage patient records and information
</p>
</div>
<div className="flex space-x-2">
<Button onClick={() => {
setCurrentPatient(undefined);
setIsAddPatientOpen(true);
}} className="gap-1" disabled={isLoading}>
<Plus className="h-4 w-4"/>
New Patient
</Button>
</div>
</div>
{/* Patients Table */}
<Card>
<CardHeader>
<CardTitle>Patient Records</CardTitle>
<CardDescription>
View and manage all patient information
</CardDescription>
</CardHeader>
<CardContent>
<PatientTable allowDelete={true} allowEdit={true} allowView={true} allowFinancial={true}/>
</CardContent>
</Card>
{/* File Upload Zone */}
<div className="space-y-8 py-8">
<Card>
<CardHeader>
<CardTitle>Upload Patient Document</CardTitle>
<CardDescription>
You can upload 1 file. Allowed types: PDF
</CardDescription>
</CardHeader>
<CardContent>
<MultipleFileUploadZone ref={uploadRef} onFilesChange={handleFilesChange} isUploading={isUploading} acceptedFileTypes="application/pdf" maxFiles={20} maxFileSizeMB={20}/>
<div className="flex flex-wrap gap-2 mt-4">
<Button className="w-full h-12 gap-2" disabled={uploadedFiles.length === 0 || isExtracting} onClick={handleExtractAndSave}>
{isExtracting ? (<>
<RefreshCw className="h-4 w-4 animate-spin"/>
Processing...
</>) : (<>
<FilePlus className="h-4 w-4"/>
Extract Info & Save
</>)}
</Button>
<Button className="w-full h-12 gap-2" disabled={uploadedFiles.length !== 1 || isExtracting} onClick={handleExtractAndAppointment}>
{isExtracting ? (<>
<RefreshCw className="h-4 w-4 animate-spin"/>
Processing...
</>) : (<>
<FilePlus className="h-4 w-4"/>
Extract Info & Appointment
</>)}
</Button>
<Button className="w-full h-12 gap-2" disabled={uploadedFiles.length !== 1 || isExtracting} onClick={handleExtractAndClaim}>
{isExtracting ? (<>
<RefreshCw className="h-4 w-4 animate-spin"/>
Processing...
</>) : (<>
<FilePlus className="h-4 w-4"/>
Extract Info & Claim/PreAuth
</>)}
</Button>
</div>
{/* Patient action buttons */}
<div className="mt-5 pt-4 border-t border-gray-200">
<p className="text-xs font-medium text-gray-500 uppercase tracking-wider mb-3">
Patient Actions
</p>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
<Button variant="outline" className="h-11 gap-2 text-sm justify-start" disabled>
<CalendarPlus className="h-4 w-4 text-blue-500 flex-shrink-0"/>
New Appointment
</Button>
<Button variant="outline" className="h-11 gap-2 text-sm justify-start" disabled>
<History className="h-4 w-4 text-indigo-500 flex-shrink-0"/>
Appointment History
</Button>
<Button variant="outline" className="h-11 gap-2 text-sm justify-start" disabled>
<HeartPulse className="h-4 w-4 text-rose-500 flex-shrink-0"/>
Medical/Dental History
</Button>
<Button variant="outline" className="h-11 gap-2 text-sm justify-start" disabled>
<FolderOpen className="h-4 w-4 text-yellow-500 flex-shrink-0"/>
Documents
</Button>
<Button variant="outline" className="h-11 gap-2 text-sm justify-start" disabled>
<Scan className="h-4 w-4 text-teal-500 flex-shrink-0"/>
View Radiographs
</Button>
<Button variant="outline" className="h-11 gap-2 text-sm justify-start" disabled>
<Camera className="h-4 w-4 text-cyan-500 flex-shrink-0"/>
Take New Radiographs
</Button>
<Button variant="outline" className="h-11 gap-2 text-sm justify-start" disabled>
<NotebookPen className="h-4 w-4 text-violet-500 flex-shrink-0"/>
Clinic Notes
</Button>
<Button variant="outline" className="h-11 gap-2 text-sm justify-start" disabled>
<FileCheck2 className="h-4 w-4 text-orange-500 flex-shrink-0"/>
New Claims
</Button>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Add/Edit Patient Modal */}
<AddPatientModal ref={addPatientModalRef} open={isAddPatientOpen} onOpenChange={setIsAddPatientOpen} onSubmit={handleAddPatient} isLoading={isLoading} patient={currentPatient}/>
</div>
</div>);
}
+289
View File
@@ -0,0 +1,289 @@
import { useEffect, useState } from "react";
import { Card, CardHeader, CardTitle, CardContent, CardDescription, } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { DollarSign, CalendarIcon } from "lucide-react";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select";
import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover";
import { Calendar } from "@/components/ui/calendar";
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 PaymentUploadDocumentsBlock from "@/components/payments/payment-upload-documents-block";
import { useLocation } from "wouter";
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("all-time");
// Check Payments Online date range
const [mhFromDate, setMhFromDate] = useState(undefined);
const [mhToDate, setMhToDate] = useState(undefined);
const [fromCalendarOpen, setFromCalendarOpen] = useState(false);
const [toCalendarOpen, setToCalendarOpen] = useState(false);
// for auto-open from appointment redirect
const [location] = useLocation();
const [initialPatientForModal, setInitialPatientForModal] = useState(null);
const [openPatientModalFromAppointment, setOpenPatientModalFromAppointment] = useState(false);
// Payment edit modal state (opens directly when ?paymentId=)
const [paymentIdToEdit, setPaymentIdToEdit] = useState(null);
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
// small helper: remove query params silently
const clearUrlParams = (params) => {
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 = 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);
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>
{/* Check Payments Online */}
<Card className="mb-6">
<CardHeader>
<CardTitle>Check MH Payments</CardTitle>
<CardDescription>
Select a date range and check MH payment status online
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-wrap items-center gap-4">
{/* From date picker */}
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-600 whitespace-nowrap">From:</span>
<Popover open={fromCalendarOpen} onOpenChange={setFromCalendarOpen}>
<PopoverTrigger asChild>
<Button variant="outline" className="w-[160px] justify-start text-left font-normal">
<CalendarIcon className="mr-2 h-4 w-4 text-gray-400"/>
{mhFromDate
? mhFromDate.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })
: <span className="text-muted-foreground">Pick a date</span>}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar mode="single" selected={mhFromDate} onSelect={(d) => {
setMhFromDate(d);
setFromCalendarOpen(false);
}} onClose={() => setFromCalendarOpen(false)}/>
</PopoverContent>
</Popover>
</div>
{/* To date picker */}
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-600 whitespace-nowrap">To:</span>
<Popover open={toCalendarOpen} onOpenChange={setToCalendarOpen}>
<PopoverTrigger asChild>
<Button variant="outline" className="w-[160px] justify-start text-left font-normal">
<CalendarIcon className="mr-2 h-4 w-4 text-gray-400"/>
{mhToDate
? mhToDate.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })
: <span className="text-muted-foreground">Pick a date</span>}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar mode="single" selected={mhToDate} onSelect={(d) => {
setMhToDate(d);
setToCalendarOpen(false);
}} onClose={() => setToCalendarOpen(false)}/>
</PopoverContent>
</Popover>
</div>
{/* Check All MH Payment button */}
<Button variant="default" onClick={() => {
// Logic to be defined later
}}>
Go
</Button>
</div>
</CardContent>
</Card>
{/* 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 allowCheckbox/>
</CardContent>
</Card>
{/* Recent Payments by Patients*/}
<PaymentsOfPatientModal initialPatient={initialPatientForModal} openInitially={openPatientModalFromAppointment} onClose={() => {
// reset the local flags when modal is closed
setOpenPatientModalFromAppointment(false);
setInitialPatientForModal(null);
}}/>
{/* Upload Payment Documents Section */}
<PaymentUploadDocumentsBlock />
{/* OCR Image Upload Section*/}
<PaymentOCRBlock />
{/* 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>);
}
+53
View File
@@ -0,0 +1,53 @@
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 SummaryCards from "@/components/reports/summary-cards";
import CommissionSection from "@/components/reports/commission-section";
export default function ReportPage() {
const { user } = useAuth();
const [startDate, setStartDate] = useState(() => {
const d = new Date();
d.setMonth(d.getMonth() - 1);
return d.toISOString().split("T")[0] ?? "";
});
const [endDate, setEndDate] = useState(() => new Date().toISOString().split("T")[0] ?? "");
const [selectedReportType, setSelectedReportType] = useState("patients_with_balance");
const [npiProviderId, setNpiProviderId] = useState(null);
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} npiProviderId={npiProviderId} setNpiProviderId={setNpiProviderId}/>
</div>
{/* SINGLE authoritative SummaryCards instance for the page */}
<div className="mb-4">
<SummaryCards startDate={startDate} endDate={endDate} npiProviderId={npiProviderId}/>
</div>
<div>
{selectedReportType === "patients_with_balance" && (<PatientsWithBalanceReport startDate={startDate} endDate={endDate} npiProviderId={npiProviderId}/>)}
{/* Add other report components here as needed */}
</div>
{/* Commission section */}
<div className="mt-8">
<CommissionSection />
</div>
</div>);
}
+259
View File
@@ -0,0 +1,259 @@
import React, { useState, useEffect } from "react";
import { Eye, EyeOff } from "lucide-react";
import { useQuery, useMutation } from "@tanstack/react-query";
import { useParams } from "wouter";
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 { NpiProviderTable } from "@/components/settings/npiProviderTable";
import { ProgramBridgeTable } from "@/components/settings/program-bridge-table";
import { TwilioSettingsCard } from "@/components/settings/twilio-settings-card";
import { AiSettingsCard } from "@/components/settings/ai-settings-card";
import { OfficeHoursCard } from "@/components/settings/office-hours-card";
import { OfficeContactCard } from "@/components/settings/office-contact-card";
import { ProcedureTimeslotCard } from "@/components/settings/procedure-timeslot-card";
import { InsuranceContactCard } from "@/components/settings/insurance-contact-card";
import { AiChatSettingsCard } from "@/components/settings/ai-chat-settings-card";
export default function SettingsPage() {
const { toast } = useToast();
const { user: currentUser } = useAuth();
const isAdmin = currentUser?.username === "admin";
const params = useParams();
const section = params.section ?? "staff";
// Staff state
const [modalOpen, setModalOpen] = useState(false);
const [editingStaff, setEditingStaff] = useState(null);
const [isDeleteStaffOpen, setIsDeleteStaffOpen] = useState(false);
const [currentStaff, setCurrentStaff] = useState(undefined);
// User management state
const [newUsername, setNewUsername] = useState("");
const [newPassword, setNewPassword] = useState("");
const [showNewUserPassword, setShowNewUserPassword] = useState(false);
const [showAdminPassword, setShowAdminPassword] = useState(false);
const [usernameUser, setUsernameUser] = useState("");
useEffect(() => {
if (currentUser?.username)
setUsernameUser(currentUser.username);
}, [currentUser]);
// ── Staff ──────────────────────────────────────────────────────
const { data: staff = [], isLoading: staffLoading, isError: staffError, error: staffErrorMsg } = useQuery({
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,
});
const addStaffMutate = useMutation({
mutationFn: async (newStaff) => {
const res = await apiRequest("POST", "/api/staffs/", newStaff);
if (!res.ok) {
const e = await res.json().catch(() => null);
throw new Error(e?.message || "Failed to add staff");
}
return res.json();
},
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["/api/staffs/"] }); toast({ title: "Staff Added" }); },
onError: (e) => toast({ title: "Error", description: e?.message, variant: "destructive" }),
});
const updateStaffMutate = useMutation({
mutationFn: async ({ id, updatedFields }) => {
const res = await apiRequest("PUT", `/api/staffs/${id}`, updatedFields);
if (!res.ok) {
const e = await res.json().catch(() => null);
throw new Error(e?.message || "Failed to update staff");
}
return res.json();
},
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["/api/staffs/"] }); toast({ title: "Staff Updated" }); },
onError: (e) => toast({ title: "Error", description: e?.message, variant: "destructive" }),
});
const deleteStaffMutation = useMutation({
mutationFn: async (id) => {
const res = await apiRequest("DELETE", `/api/staffs/${id}`);
if (!res.ok) {
const e = await res.json().catch(() => null);
throw new Error(e?.message || "Failed to delete staff");
}
return id;
},
onSuccess: () => { setIsDeleteStaffOpen(false); queryClient.invalidateQueries({ queryKey: ["/api/staffs/"] }); toast({ title: "Staff Removed" }); },
onError: (e) => toast({ title: "Error", description: e?.message, variant: "destructive" }),
});
useEffect(() => {
if (addStaffMutate.status === "success" || updateStaffMutate.status === "success")
setModalOpen(false);
}, [addStaffMutate.status, updateStaffMutate.status]);
// ── Users ──────────────────────────────────────────────────────
const { data: allUsers = [], refetch: refetchUsers } = useQuery({
queryKey: ["/api/users/list"],
queryFn: async () => { const res = await apiRequest("GET", "/api/users/list"); if (!res.ok)
return []; return res.json(); },
enabled: false,
});
useEffect(() => { if (isAdmin)
refetchUsers(); }, [isAdmin]);
const addUserMutation = useMutation({
mutationFn: async (data) => {
const res = await apiRequest("POST", "/api/users/", data);
if (!res.ok) {
const e = await res.json().catch(() => null);
throw new Error(e?.error || "Failed to create user");
}
return res.json();
},
onSuccess: () => { setNewUsername(""); setNewPassword(""); refetchUsers(); toast({ title: "User Created" }); },
onError: (e) => toast({ title: "Error", description: e?.message, variant: "destructive" }),
});
const deleteUserMutation = useMutation({
mutationFn: async (id) => { const res = await apiRequest("DELETE", `/api/users/${id}`); if (!res.ok)
throw new Error("Failed to delete user"); },
onSuccess: () => { refetchUsers(); toast({ title: "User Deleted" }); },
onError: (e) => toast({ title: "Error", description: e?.message, variant: "destructive" }),
});
const updateUserMutate = useMutation({
mutationFn: async (updates) => {
if (!currentUser?.id)
throw new Error("User not loaded");
const res = await apiRequest("PUT", `/api/users/${currentUser.id}`, updates);
if (!res.ok) {
const e = await res.json().catch(() => null);
throw new Error(e?.error || "Failed to update user");
}
return res.json();
},
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["/api/users/"] }); toast({ title: "Updated", description: "Your profile has been updated." }); },
onError: (e) => toast({ title: "Error", description: e?.message, variant: "destructive" }),
});
// ── Section renderer ───────────────────────────────────────────
const renderSection = () => {
switch (section) {
case "staff":
return (<Card>
<CardContent>
<div className="mt-4">
<StaffTable staff={staff} isLoading={staffLoading} isError={staffError} onAdd={() => { setEditingStaff(null); setModalOpen(true); }} onEdit={(s) => { setEditingStaff(s); setModalOpen(true); }} onDelete={(s) => { setCurrentStaff(s); setIsDeleteStaffOpen(true); }} onView={(s) => alert(`Viewing staff member:\n${s.name} (${s.email || "No email"})`)}/>
{staffError && <p className="mt-4 text-red-600">{staffErrorMsg?.message || "Failed to load staff data."}</p>}
<DeleteConfirmationDialog isOpen={isDeleteStaffOpen} onConfirm={() => { if (currentStaff?.id)
deleteStaffMutation.mutate(currentStaff.id); }} onCancel={() => setIsDeleteStaffOpen(false)} entityName={currentStaff?.name}/>
</div>
</CardContent>
</Card>);
case "users":
return (<Card>
<CardContent className="space-y-4 py-6">
<h3 className="text-lg font-semibold">Manage Users</h3>
<div className="border rounded divide-y">
{allUsers.length === 0 && <p className="text-sm text-gray-500 p-3">No users found.</p>}
{allUsers.map((u) => (<div key={u.id} className="flex items-center justify-between px-3 py-2">
<span className="text-sm font-medium">{u.username}</span>
{u.username !== "admin" && (<button className="text-sm text-red-600 hover:underline" onClick={() => deleteUserMutation.mutate(u.id)} disabled={deleteUserMutation.isPending}>
Delete
</button>)}
</div>))}
</div>
<form className="space-y-3 pt-2" onSubmit={(e) => { e.preventDefault(); if (!newUsername.trim() || !newPassword.trim())
return; addUserMutation.mutate({ username: newUsername.trim(), password: newPassword.trim() }); }}>
<h4 className="text-sm font-semibold text-gray-700">Add New User</h4>
<div>
<label className="block text-sm font-medium">Username</label>
<input type="text" value={newUsername} onChange={(e) => setNewUsername(e.target.value)} className="mt-1 p-2 border rounded w-full" placeholder="Enter username" required/>
</div>
<div>
<label className="block text-sm font-medium">Password</label>
<div className="relative mt-1">
<input type={showNewUserPassword ? "text" : "password"} value={newPassword} onChange={(e) => setNewPassword(e.target.value)} className="p-2 border rounded w-full pr-10" placeholder="••••••••" required/>
<button type="button" onClick={() => setShowNewUserPassword((v) => !v)} className="absolute inset-y-0 right-2 flex items-center text-gray-500 hover:text-gray-700" tabIndex={-1}>
{showNewUserPassword ? <EyeOff size={16}/> : <Eye size={16}/>}
</button>
</div>
</div>
<button type="submit" className="bg-teal-600 text-white px-4 py-2 rounded hover:bg-teal-700" disabled={addUserMutation.isPending}>
{addUserMutation.isPending ? "Adding..." : "Add User"}
</button>
</form>
</CardContent>
</Card>);
case "account":
return (<Card>
<CardContent className="space-y-4 py-6">
<h3 className="text-lg font-semibold">Account 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({ ...(isAdmin ? {} : { 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 disabled:bg-gray-100 disabled:cursor-not-allowed disabled:text-gray-500" disabled={isAdmin}/>
{isAdmin && <p className="text-xs text-gray-500 mt-1">Admin username cannot be changed.</p>}
</div>
<div>
<label className="block text-sm font-medium">New Password</label>
<div className="relative mt-1">
<input type={showAdminPassword ? "text" : "password"} name="password" className="p-2 border rounded w-full pr-10" placeholder="••••••••"/>
<button type="button" onClick={() => setShowAdminPassword((v) => !v)} className="absolute inset-y-0 right-2 flex items-center text-gray-500 hover:text-gray-700" tabIndex={-1}>
{showAdminPassword ? <EyeOff size={16}/> : <Eye size={16}/>}
</button>
</div>
<p className="text-xs text-gray-500 mt-1">Leave blank to keep current password.</p>
</div>
<button type="submit" className="bg-teal-600 text-white px-4 py-2 rounded hover:bg-teal-700" disabled={updateUserMutate.isPending}>
{updateUserMutate.isPending ? "Saving..." : "Save Changes"}
</button>
</form>
</CardContent>
</Card>);
case "credentials":
return <CredentialTable />;
case "npi":
return <NpiProviderTable />;
case "programs":
return <ProgramBridgeTable />;
case "twilio":
return <TwilioSettingsCard />;
case "ai":
return <AiSettingsCard />;
case "aichat":
return <AiChatSettingsCard />;
case "officehours":
return <OfficeHoursCard />;
case "officecontact":
return <OfficeContactCard />;
case "proceduretimeslot":
return <ProcedureTimeslotCard />;
case "insurancecontact":
return <InsuranceContactCard />;
default:
return null;
}
};
return (<>
{renderSection()}
{/* Staff add/edit modal */}
{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={(formData) => {
if (editingStaff) {
if (!editingStaff.id)
return;
updateStaffMutate.mutate({ id: editingStaff.id, updatedFields: formData });
}
else {
addStaffMutate.mutate(formData);
}
}} onCancel={() => setModalOpen(false)} isLoading={addStaffMutate.status === "pending" || updateStaffMutate.status === "pending"}/>
</div>
</div>)}
</>);
}