feat: add new frontend components, MH batch worker, and gitignore rules
- Add all new Frontend source files (pages, components, hooks, utils) - Add selenium_MHBatchPaymentCheckWorker.py and MHSinglePaymentCheckWorker.py - Add install-steps-5-13.sh setup script - Update .gitignore to exclude runtime/sensitive data (backups, uploads, chat-history, keys, downloads, generated .d.ts files) while keeping folders - Add .gitkeep to preserve empty runtime folders in git Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,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
@@ -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>);
|
||||
}
|
||||
@@ -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>);
|
||||
}
|
||||
@@ -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>);
|
||||
}
|
||||
@@ -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>);
|
||||
}
|
||||
@@ -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>);
|
||||
}
|
||||
@@ -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>);
|
||||
}
|
||||
@@ -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>);
|
||||
}
|
||||
@@ -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>);
|
||||
}
|
||||
@@ -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>)}
|
||||
</>);
|
||||
}
|
||||
Reference in New Issue
Block a user