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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-26 00:23:43 -04:00
co-authored by Claude Sonnet 4.6
parent b7e06adf2f
commit 1edf73fdc8
173 changed files with 33469 additions and 0 deletions
@@ -0,0 +1,119 @@
import { useEffect, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "@/lib/queryClient";
import { toast } from "@/hooks/use-toast";
import { Eye, EyeOff } from "lucide-react";
const SITE_KEY_OPTIONS = [
{ value: "MH", label: "MassHealth (MH)" },
{ value: "DDMA", label: "Delta Dental MA (DDMA)" },
{ value: "DELTAINS", label: "Delta Dental Ins (DELTAINS)" },
{ value: "TUFTS_SCO", label: "Tufts SCO (TUFTS_SCO)" },
{ value: "UNITED_SCO", label: "United SCO / DentalHub (UNITED_SCO)" },
{ value: "CCA", label: "CCA (CCA)" },
{ value: "BCBS_MA", label: "BCBS MA (BCBS_MA)" },
];
export function CredentialForm({ onClose, userId, defaultValues }) {
const [siteKey, setSiteKey] = useState(defaultValues?.siteKey || "");
const [username, setUsername] = useState(defaultValues?.username || "");
const [password, setPassword] = useState(defaultValues?.password || "");
const [showPassword, setShowPassword] = useState(false);
const queryClient = useQueryClient();
// Create or Update Mutation inside form
const mutation = useMutation({
mutationFn: async () => {
const payload = {
siteKey: siteKey.trim(),
username: username.trim(),
password: password.trim(),
userId,
};
const url = defaultValues?.id
? `/api/insuranceCreds/${defaultValues.id}`
: "/api/insuranceCreds/";
const method = defaultValues?.id ? "PUT" : "POST";
const res = await apiRequest(method, url, payload);
if (!res.ok) {
const errorData = await res.json().catch(() => null);
throw new Error(errorData?.message || "Failed to save credential");
}
return res.json();
},
onSuccess: () => {
toast({
title: `Credential ${defaultValues?.id ? "updated" : "created"}.`,
});
queryClient.invalidateQueries({ queryKey: ["/api/insuranceCreds/"] });
onClose();
},
onError: (error) => {
toast({
title: "Error",
description: error.message || "Unknown error",
variant: "destructive",
});
},
});
// Reset form on defaultValues change (edit mode)
useEffect(() => {
setSiteKey(defaultValues?.siteKey || "");
setUsername(defaultValues?.username || "");
setPassword(defaultValues?.password || "");
}, [defaultValues]);
const handleSubmit = (e) => {
e.preventDefault();
if (!siteKey || !username || !password) {
toast({
title: "Error",
description: "All fields are required.",
variant: "destructive",
});
return;
}
mutation.mutate();
};
return (<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">
{defaultValues?.id ? "Edit Credential" : "Create Credential"}
</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium">Insurance</label>
<select value={siteKey} onChange={(e) => setSiteKey(e.target.value)} className="mt-1 p-2 border rounded w-full bg-white">
<option value="">— Select insurance —</option>
{SITE_KEY_OPTIONS.map((opt) => (<option key={opt.value} value={opt.value}>
{opt.label}
</option>))}
</select>
</div>
<div>
<label className="block text-sm font-medium">Username</label>
<input type="text" value={username} onChange={(e) => setUsername(e.target.value)} className="mt-1 p-2 border rounded w-full"/>
</div>
<div>
<label className="block text-sm font-medium">Password</label>
<div className="relative mt-1">
<input type={showPassword ? "text" : "password"} value={password} onChange={(e) => setPassword(e.target.value)} className="p-2 border rounded w-full pr-10"/>
<button type="button" onClick={() => setShowPassword((prev) => !prev)} className="absolute inset-y-0 right-2 flex items-center text-gray-500 hover:text-gray-700" tabIndex={-1}>
{showPassword ? <EyeOff className="h-4 w-4"/> : <Eye className="h-4 w-4"/>}
</button>
</div>
</div>
<div className="flex justify-end gap-2">
<button type="button" onClick={onClose} className="text-gray-600 hover:underline" disabled={mutation.isPending}>
Cancel
</button>
<button type="submit" disabled={mutation.isPending} className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 disabled:opacity-50">
{mutation.isPending
? defaultValues?.id
? "Updating..."
: "Creating..."
: defaultValues?.id
? "Update"
: "Create"}
</button>
</div>
</form>
</div>
</div>);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,226 @@
import { useState, useEffect, useRef } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { useToast } from "@/hooks/use-toast";
import { apiRequest, queryClient } from "@/lib/queryClient";
import { Bot, CalendarCheck, UserPlus, MessageCircle, Info, CalendarX } from "lucide-react";
const SUPPORTED_SMS_VARS = [
"{firstName}", "{officeName}", "{appointmentDate}", "{appointmentTime}", "{date}", "{time}",
];
const DEFAULTS = {
reminderSms: "Hi {firstName}, this is a reminder from {officeName}. You have an appointment on {appointmentDate} at {appointmentTime}. Please reply YES to confirm or NO to reschedule. Thank you!",
reminderGreeting: "Hi! My name is Lisa, the dedicated AI assistant at {officeName}. I can confirm or reschedule your appointment and answer general questions 24/7. I will reply to your message at any time you need.",
newPatientGreeting: "Hi! My name is Lisa, the dedicated AI assistant at {officeName}. I can help you schedule an appointment, check your insurance, and answer general questions 24/7. How can I help you today?",
generalFallback: "Hi! My name is Lisa, the dedicated AI assistant at {officeName}. How can I help you today?",
rescheduleGreeting: "It is understandable! When would you like to reschedule your appointment?",
};
function preview(text, officeName) {
return text.replace(/\{officeName\}/g, officeName || "your dental office");
}
/** Returns any {variable} tokens in `text` that are not in the supported list. */
function unsupportedVars(text) {
const found = text.match(/\{[^}]+\}/g) ?? [];
return [...new Set(found)].filter((v) => !SUPPORTED_SMS_VARS.includes(v));
}
/** True if text starts with a self-introduction ("Hi! My name is Lisa…"). */
function hasIntroPattern(text) {
return /^(Hi[!,]?\s*)?(My name is|I'?m|I am)\s+/i.test(text.trim());
}
export function AiChatTemplatesCard() {
const { toast } = useToast();
const [reminderSms, setReminderSms] = useState(DEFAULTS.reminderSms);
const [reminderGreeting, setReminderGreeting] = useState(DEFAULTS.reminderGreeting);
const [newPatientGreeting, setNewPatientGreeting] = useState(DEFAULTS.newPatientGreeting);
const [generalFallback, setGeneralFallback] = useState(DEFAULTS.generalFallback);
const [rescheduleGreeting, setRescheduleGreeting] = useState(DEFAULTS.rescheduleGreeting);
const initialized = useRef(false);
const { data: officeContact } = useQuery({
queryKey: ["/api/office-contact"],
queryFn: async () => {
const res = await apiRequest("GET", "/api/office-contact");
if (!res.ok)
return null;
return res.json();
},
staleTime: 60_000,
});
const { data: templates, isLoading } = useQuery({
queryKey: ["/api/ai/chat-templates"],
queryFn: async () => {
const res = await apiRequest("GET", "/api/ai/chat-templates");
if (!res.ok)
throw new Error("Failed to load templates");
return res.json();
},
staleTime: Infinity, // never silently refetch and overwrite user edits
refetchOnWindowFocus: false,
});
// Seed state from server on first successful load only
useEffect(() => {
if (templates && !initialized.current) {
initialized.current = true;
setReminderSms(templates.reminderSms || DEFAULTS.reminderSms);
setReminderGreeting(templates.reminderGreeting || DEFAULTS.reminderGreeting);
setNewPatientGreeting(templates.newPatientGreeting || DEFAULTS.newPatientGreeting);
setGeneralFallback(templates.generalFallback || DEFAULTS.generalFallback);
setRescheduleGreeting(templates.rescheduleGreeting || DEFAULTS.rescheduleGreeting);
}
}, [templates]);
const saveMutation = useMutation({
mutationFn: async (data) => {
const res = await apiRequest("PUT", "/api/ai/chat-templates", data);
if (!res.ok) {
const err = await res.json().catch(() => null);
throw new Error(err?.message || "Failed to save templates");
}
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/ai/chat-templates"] });
toast({ title: "Templates saved", description: "AI chat templates have been updated." });
},
onError: (err) => {
toast({ title: "Error", description: err?.message, variant: "destructive" });
},
});
const handleSubmit = (e) => {
e.preventDefault();
saveMutation.mutate({
reminderSms: reminderSms.trim() || DEFAULTS.reminderSms,
reminderGreeting: reminderGreeting.trim() || DEFAULTS.reminderGreeting,
newPatientGreeting: newPatientGreeting.trim() || DEFAULTS.newPatientGreeting,
generalFallback: generalFallback.trim() || DEFAULTS.generalFallback,
rescheduleGreeting: rescheduleGreeting.trim() || DEFAULTS.rescheduleGreeting,
});
};
const officeName = officeContact?.officeName?.trim() || "";
const templates_list = [
{
key: "reminderSms",
icon: <CalendarCheck className="h-4 w-4 text-primary"/>,
label: "Reminder SMS Text",
description: "Outgoing text sent from the Schedule page. Supported variables: {firstName}, {officeName}, {appointmentDate}, {appointmentTime}, {date}, {time}. Any other {variable} will be sent as plain text.",
value: reminderSms,
onChange: setReminderSms,
placeholder: DEFAULTS.reminderSms,
},
{
key: "reminder",
icon: <CalendarCheck className="h-4 w-4 text-primary"/>,
label: "Appointment Reminder Reply",
description: "Sent when the AI introduces itself after the office sends an appointment reminder.",
value: reminderGreeting,
onChange: setReminderGreeting,
placeholder: DEFAULTS.reminderGreeting,
},
{
key: "newPatient",
icon: <UserPlus className="h-4 w-4 text-primary"/>,
label: "New Patient Greeting",
description: "Sent when a new patient texts in for the first time.",
value: newPatientGreeting,
onChange: setNewPatientGreeting,
placeholder: DEFAULTS.newPatientGreeting,
},
{
key: "general",
icon: <MessageCircle className="h-4 w-4 text-primary"/>,
label: "General Fallback",
description: "Used when the AI cannot determine the context of the patient's message.",
value: generalFallback,
onChange: setGeneralFallback,
placeholder: DEFAULTS.generalFallback,
},
{
key: "reschedule",
icon: <CalendarX className="h-4 w-4 text-primary"/>,
label: "Reschedule Patients",
description: "Sent when the office initiates a reschedule flow for a patient.",
value: rescheduleGreeting,
onChange: setRescheduleGreeting,
placeholder: DEFAULTS.rescheduleGreeting,
},
];
return (<Card>
<CardContent className="py-6 space-y-6">
{/* Header */}
<div className="flex items-center gap-2">
<Bot className="h-5 w-5 text-primary"/>
<h3 className="text-lg font-semibold">AI Chat Templates</h3>
</div>
<p className="text-sm text-muted-foreground">
Customize the reminder SMS and AI reply templates. Available variables:{" "}
<code className="bg-muted px-1 py-0.5 rounded text-xs font-mono">{"{firstName}"}</code>{" "}
<code className="bg-muted px-1 py-0.5 rounded text-xs font-mono">{"{officeName}"}</code>{" "}
<code className="bg-muted px-1 py-0.5 rounded text-xs font-mono">{"{appointmentDate}"}</code>{" "}
<code className="bg-muted px-1 py-0.5 rounded text-xs font-mono">{"{appointmentTime}"}</code>{" "}
— replaced automatically when reminders are sent.
</p>
{/* Office name hint */}
{officeName && (<div className="flex items-center gap-2 text-xs text-muted-foreground bg-muted/50 rounded px-3 py-2">
<Info className="h-3.5 w-3.5 flex-shrink-0"/>
<span>
<span className="font-medium">{"{officeName}"}</span> will display as{" "}
<span className="font-medium text-foreground">"{officeName}"</span>
</span>
</div>)}
{isLoading ? (<p className="text-sm text-muted-foreground">Loading templates...</p>) : (<form onSubmit={handleSubmit} className="space-y-6">
{templates_list.map((t) => {
const badVars = t.key === "reminderSms" ? unsupportedVars(t.value) : [];
const hasIntro = t.key === "reschedule" && hasIntroPattern(t.value);
const hasWarn = badVars.length > 0 || hasIntro;
return (<div key={t.key} className="space-y-2">
<div className="flex items-center gap-2">
{t.icon}
<span className="text-sm font-medium">{t.label}</span>
</div>
<p className="text-xs text-muted-foreground">{t.description}</p>
<Textarea value={t.value} onChange={(e) => t.onChange(e.target.value)} placeholder={t.placeholder} rows={3} className={`text-sm resize-none ${hasWarn ? "border-amber-400 focus-visible:ring-amber-400" : ""}`}/>
{/* Unsupported variable warning */}
{badVars.length > 0 && (<div className="flex items-start gap-2 rounded-md bg-amber-50 border border-amber-300 px-3 py-2 text-xs text-amber-800">
<span className="mt-0.5">⚠️</span>
<span>
<strong>Unsupported variable{badVars.length > 1 ? "s" : ""}:</strong>{" "}
{badVars.join(", ")} — {badVars.length > 1 ? "these" : "this"} will be sent as plain text to patients.
Supported: {SUPPORTED_SMS_VARS.join(", ")}.
</span>
</div>)}
{/* Self-intro warning for reschedule greeting */}
{hasIntro && (<div className="flex items-start gap-2 rounded-md bg-amber-50 border border-amber-300 px-3 py-2 text-xs text-amber-800">
<span className="mt-0.5">⚠️</span>
<span>
This template starts with a self-introduction ("Hi! My name is Lisa…").
The AI introduction is already sent as a <strong>separate first message</strong> —
this template is only used as the <strong>second message</strong> (the intent response).
Remove the intro to avoid the patient receiving "My name is Lisa…" twice.
</span>
</div>)}
{/* Live preview */}
{officeName && t.value.includes("{officeName}") && (<p className="text-xs text-muted-foreground italic pl-1">
Preview: {preview(t.value, officeName)}
</p>)}
</div>);
})}
<div className="flex items-center gap-3 pt-2">
<Button type="submit" disabled={saveMutation.isPending} className="bg-teal-600 hover:bg-teal-700 text-white">
{saveMutation.isPending ? "Saving..." : "Save Templates"}
</Button>
<Button type="button" variant="ghost" className="text-xs text-muted-foreground" onClick={() => {
setReminderSms(DEFAULTS.reminderSms);
setReminderGreeting(DEFAULTS.reminderGreeting);
setNewPatientGreeting(DEFAULTS.newPatientGreeting);
setGeneralFallback(DEFAULTS.generalFallback);
setRescheduleGreeting(DEFAULTS.rescheduleGreeting);
}}>
Reset to defaults
</Button>
</div>
</form>)}
</CardContent>
</Card>);
}
@@ -0,0 +1,235 @@
import { useState, useEffect } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import { Card, CardContent } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { Switch } from "@/components/ui/switch";
import { Eye, EyeOff, CheckCircle } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
import { apiRequest, queryClient } from "@/lib/queryClient";
function ApiKeySection({ title, description, apiKey, enabled, isConfigured, onSave, onToggle, isSaving, isToggling, modelOptions, selectedModel, onModelChange, }) {
const [localKey, setLocalKey] = useState(apiKey);
const [showKey, setShowKey] = useState(false);
useEffect(() => {
setLocalKey(apiKey);
}, [apiKey]);
return (<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<h3 className="text-lg font-semibold">{title}</h3>
{isConfigured && (<span className="flex items-center gap-1 text-xs text-green-600 font-medium">
<CheckCircle className="h-3.5 w-3.5"/> Configured
</span>)}
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-gray-500">{enabled ? "On" : "Off"}</span>
<Switch checked={enabled} onCheckedChange={onToggle} disabled={isToggling}/>
</div>
</div>
<p className="text-sm text-gray-500">{description}</p>
{modelOptions && onModelChange && (<div>
<label className="block text-sm font-medium mb-1">Model</label>
<select value={selectedModel} onChange={(e) => onModelChange(e.target.value)} className="p-2 border rounded w-full text-sm bg-white">
{modelOptions.map((opt) => (<option key={opt.value} value={opt.value}>{opt.label}</option>))}
</select>
</div>)}
<div>
<label className="block text-sm font-medium">API Key</label>
<div className="relative mt-1">
<input type={showKey ? "text" : "password"} value={localKey} onChange={(e) => setLocalKey(e.target.value)} className="p-2 border rounded w-full pr-10 font-mono text-sm" placeholder="••••••••••••••••••••••••••••••••••••••"/>
<button type="button" onClick={() => setShowKey((v) => !v)} className="absolute inset-y-0 right-2 flex items-center text-gray-500 hover:text-gray-700" tabIndex={-1}>
{showKey ? <EyeOff size={16}/> : <Eye size={16}/>}
</button>
</div>
</div>
<button onClick={() => { if (localKey.trim())
onSave(localKey.trim()); }} className="bg-teal-600 text-white px-4 py-2 rounded hover:bg-teal-700 text-sm" disabled={isSaving || !localKey.trim()}>
{isSaving ? "Saving..." : "Save"}
</button>
</div>);
}
export function AiSettingsCard() {
const { toast } = useToast();
const [apiKey, setApiKey] = useState("");
const [showGoogleKey, setShowGoogleKey] = useState(false);
const [aiEnabled, setAiEnabled] = useState(true);
const { data: settings, isLoading } = useQuery({
queryKey: ["/api/ai/settings"],
queryFn: async () => {
const res = await apiRequest("GET", "/api/ai/settings");
if (!res.ok)
return null;
return res.json();
},
});
useEffect(() => {
if (settings) {
setApiKey(settings.apiKey ?? "");
setAiEnabled(settings.aiEnabled ?? true);
}
}, [settings]);
// Google AI save
const googleSaveMutation = useMutation({
mutationFn: async (key) => {
const res = await apiRequest("PUT", "/api/ai/settings", { apiKey: key });
if (!res.ok)
throw new Error((await res.json().catch(() => null))?.message || "Failed to save");
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/ai/settings"] });
toast({ title: "Saved", description: "Google AI API key saved." });
},
onError: (err) => toast({ title: "Error", description: err?.message, variant: "destructive" }),
});
// Google AI toggle
const googleToggleMutation = useMutation({
mutationFn: async (enabled) => {
const res = await apiRequest("PUT", "/api/ai/enabled", { aiEnabled: enabled });
if (!res.ok)
throw new Error((await res.json().catch(() => null))?.message || "Failed");
return res.json();
},
onSuccess: (_d, enabled) => {
queryClient.invalidateQueries({ queryKey: ["/api/ai/settings"] });
toast({ title: enabled ? "Google AI Enabled" : "Google AI Disabled" });
},
onError: (err) => {
setAiEnabled((v) => !v);
toast({ title: "Error", description: err?.message, variant: "destructive" });
},
});
// Provider key save
const providerKeyMutation = useMutation({
mutationFn: async ({ provider, apiKey }) => {
const res = await apiRequest("PUT", "/api/ai/provider-key", { provider, apiKey });
if (!res.ok)
throw new Error((await res.json().catch(() => null))?.message || "Failed");
return { provider };
},
onSuccess: ({ provider }) => {
queryClient.invalidateQueries({ queryKey: ["/api/ai/settings"] });
const names = { openAi: "OpenAI", claudeAi: "Claude AI", dentalMgmt: "DentalManagement AI" };
toast({ title: "Saved", description: `${names[provider]} API key saved.` });
},
onError: (err) => toast({ title: "Error", description: err?.message, variant: "destructive" }),
});
// Provider model change
const providerModelMutation = useMutation({
mutationFn: async ({ provider, model }) => {
const res = await apiRequest("PUT", "/api/ai/provider-model", { provider, model });
if (!res.ok)
throw new Error((await res.json().catch(() => null))?.message || "Failed");
return { provider, model };
},
onSuccess: ({ provider, model }) => {
queryClient.invalidateQueries({ queryKey: ["/api/ai/settings"] });
const names = { claudeAi: "Claude AI", openAi: "OpenAI" };
toast({ title: "Model updated", description: `${names[provider]} model set to ${model}.` });
},
onError: (err) => toast({ title: "Error", description: err?.message, variant: "destructive" }),
});
// Provider toggle
const providerToggleMutation = useMutation({
mutationFn: async ({ provider, enabled }) => {
const res = await apiRequest("PUT", "/api/ai/provider-enabled", { provider, enabled });
if (!res.ok)
throw new Error((await res.json().catch(() => null))?.message || "Failed");
return { provider, enabled };
},
onSuccess: ({ provider, enabled }) => {
queryClient.invalidateQueries({ queryKey: ["/api/ai/settings"] });
const names = { openAi: "OpenAI", claudeAi: "Claude AI", dentalMgmt: "DentalManagement AI" };
toast({ title: `${names[provider]} ${enabled ? "Enabled" : "Disabled"}` });
},
onError: (err) => toast({ title: "Error", description: err?.message, variant: "destructive" }),
});
const CLAUDE_MODELS = [
{ value: "claude-haiku-4-5-20251001", label: "Claude Haiku 4.5 — Fast & affordable" },
{ value: "claude-sonnet-4-6", label: "Claude Sonnet 4.6 — Balanced" },
{ value: "claude-opus-4-8", label: "Claude Opus 4.8 — Most capable" },
];
const OPENAI_MODELS = [
{ value: "gpt-5.2", label: "GPT-5.2 — Standard (recommended)" },
{ value: "gpt-5.2-pro", label: "GPT-5.2 Pro — Professional grade" },
{ value: "gpt-5.4", label: "GPT-5.4 — Previous gen, high quality" },
{ value: "gpt-5.4-pro", label: "GPT-5.4 Pro — Previous gen pro" },
{ value: "gpt-5.5", label: "GPT-5.5 — Flagship, complex tasks" },
{ value: "gpt-5.5-pro", label: "GPT-5.5 Pro — Flagship professional" },
];
const GOOGLE_MODELS = [
{ value: "gemini-2.5-flash", label: "Gemini 2.5 Flash — Fast & balanced (recommended)" },
{ value: "gemini-2.5-pro", label: "Gemini 2.5 Pro — High capability, 1M context" },
{ value: "gemini-3.1-flash-lite", label: "Gemini 3.1 Flash Lite — Optimized for speed/cost" },
{ value: "gemini-3.5-flash", label: "Gemini 3.5 Flash — Latest frontier" },
];
if (isLoading) {
return <Card><CardContent className="py-6"><p className="text-sm text-gray-400">Loading...</p></CardContent></Card>;
}
return (<Card>
<CardContent className="py-6 space-y-6">
{/* Google AI */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<h3 className="text-lg font-semibold">Google AI Settings</h3>
{!!settings?.apiKey && (<span className="flex items-center gap-1 text-xs text-green-600 font-medium">
<CheckCircle className="h-3.5 w-3.5"/> Configured
</span>)}
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-gray-500">{aiEnabled ? "On" : "Off"}</span>
<Switch checked={aiEnabled} onCheckedChange={(v) => { setAiEnabled(v); googleToggleMutation.mutate(v); }} disabled={googleToggleMutation.isPending}/>
</div>
</div>
<p className="text-sm text-gray-500">
Enter your Google AI Studio API key to enable AI-powered SMS replies.
</p>
<div>
<label className="block text-sm font-medium mb-1">Model</label>
<select value={settings?.googleAiModel ?? "gemini-2.5-flash"} onChange={(e) => providerModelMutation.mutate({ provider: "googleAi", model: e.target.value })} className="p-2 border rounded w-full text-sm bg-white">
{GOOGLE_MODELS.map((opt) => (<option key={opt.value} value={opt.value}>{opt.label}</option>))}
</select>
</div>
<div>
<label className="block text-sm font-medium">Google AI Studio API Key</label>
<div className="relative mt-1">
<input type={showGoogleKey ? "text" : "password"} value={apiKey} onChange={(e) => setApiKey(e.target.value)} className="p-2 border rounded w-full pr-10 font-mono text-sm" placeholder="AIza••••••••••••••••••••••••••••••••••••••"/>
<button type="button" onClick={() => setShowGoogleKey((v) => !v)} className="absolute inset-y-0 right-2 flex items-center text-gray-500 hover:text-gray-700" tabIndex={-1}>
{showGoogleKey ? <EyeOff size={16}/> : <Eye size={16}/>}
</button>
</div>
</div>
<button onClick={() => { if (apiKey.trim())
googleSaveMutation.mutate(apiKey.trim()); }} className="bg-teal-600 text-white px-4 py-2 rounded hover:bg-teal-700 text-sm" disabled={googleSaveMutation.isPending || !apiKey.trim()}>
{googleSaveMutation.isPending ? "Saving..." : "Save"}
</button>
{!!settings?.apiKey && (<div className="mt-2 p-3 bg-gray-50 rounded border text-xs text-gray-600 space-y-1">
<p className="font-medium text-gray-700">AI Auto-Reply Rules</p>
<p><span className="text-green-600 font-medium">Patient replies "Yes"</span> → AI sends a thank-you confirmation</p>
<p><span className="text-red-500 font-medium">Patient replies "No" / "Not available"</span> → AI notifies them an assistant will follow up</p>
</div>)}
</div>
<Separator />
{/* OpenAI */}
<ApiKeySection title="OpenAI Settings" description="Enter your OpenAI API key to enable OpenAI-powered features." apiKey={settings?.openAiKey ?? ""} enabled={settings?.openAiEnabled ?? false} isConfigured={!!settings?.openAiKey} onSave={(key) => providerKeyMutation.mutate({ provider: "openAi", apiKey: key })} onToggle={(v) => providerToggleMutation.mutate({ provider: "openAi", enabled: v })} isSaving={providerKeyMutation.isPending && providerKeyMutation.variables?.provider === "openAi"} isToggling={providerToggleMutation.isPending && providerToggleMutation.variables?.provider === "openAi"} modelOptions={OPENAI_MODELS} selectedModel={settings?.openAiModel ?? "gpt-4o-mini"} onModelChange={(model) => providerModelMutation.mutate({ provider: "openAi", model })}/>
<Separator />
{/* Claude AI */}
<ApiKeySection title="Claude AI Settings" description="Enter your Anthropic Claude API key to enable Claude-powered features." apiKey={settings?.claudeAiKey ?? ""} enabled={settings?.claudeAiEnabled ?? false} isConfigured={!!settings?.claudeAiKey} onSave={(key) => providerKeyMutation.mutate({ provider: "claudeAi", apiKey: key })} onToggle={(v) => providerToggleMutation.mutate({ provider: "claudeAi", enabled: v })} isSaving={providerKeyMutation.isPending && providerKeyMutation.variables?.provider === "claudeAi"} isToggling={providerToggleMutation.isPending && providerToggleMutation.variables?.provider === "claudeAi"} modelOptions={CLAUDE_MODELS} selectedModel={settings?.claudeAiModel ?? "claude-haiku-4-5-20251001"} onModelChange={(model) => providerModelMutation.mutate({ provider: "claudeAi", model })}/>
<Separator />
{/* DentalManagement AI */}
<ApiKeySection title="DentalManagement AI Settings" description="Enter your DentalManagement AI API key to enable DentalManagement-powered features." apiKey={settings?.dentalMgmtKey ?? ""} enabled={settings?.dentalMgmtEnabled ?? false} isConfigured={!!settings?.dentalMgmtKey} onSave={(key) => providerKeyMutation.mutate({ provider: "dentalMgmt", apiKey: key })} onToggle={(v) => providerToggleMutation.mutate({ provider: "dentalMgmt", enabled: v })} isSaving={providerKeyMutation.isPending && providerKeyMutation.variables?.provider === "dentalMgmt"} isToggling={providerToggleMutation.isPending && providerToggleMutation.variables?.provider === "dentalMgmt"}/>
</CardContent>
</Card>);
}
@@ -0,0 +1,197 @@
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "@/lib/queryClient";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useToast } from "@/hooks/use-toast";
import { DeleteConfirmationDialog } from "@/components/ui/deleteDialog";
import { Plus, Pencil, Trash2, X, Check } from "lucide-react";
const EMPTY_FORM = { name: "", phoneNumber: "" };
export function InsuranceContactCard() {
const { toast } = useToast();
const queryClient = useQueryClient();
const [showForm, setShowForm] = useState(false);
const [editingId, setEditingId] = useState(null);
const [form, setForm] = useState(EMPTY_FORM);
const [deleteTarget, setDeleteTarget] = useState(null);
const { data: contacts = [], isLoading } = useQuery({
queryKey: ["/api/insurance-contacts"],
queryFn: async () => {
const res = await apiRequest("GET", "/api/insurance-contacts");
if (!res.ok)
throw new Error("Failed to fetch");
return res.json();
},
});
const invalidate = () => queryClient.invalidateQueries({ queryKey: ["/api/insurance-contacts"] });
const createMutation = useMutation({
mutationFn: async (data) => {
const res = await apiRequest("POST", "/api/insurance-contacts", data);
if (!res.ok) {
const e = await res.json().catch(() => null);
throw new Error(e?.message || "Failed to save");
}
return res.json();
},
onSuccess: () => {
invalidate();
setShowForm(false);
setForm(EMPTY_FORM);
toast({ title: "Insurance contact added" });
},
onError: (e) => toast({ title: "Error", description: e.message, variant: "destructive" }),
});
const updateMutation = useMutation({
mutationFn: async ({ id, data }) => {
const res = await apiRequest("PUT", `/api/insurance-contacts/${id}`, data);
if (!res.ok) {
const e = await res.json().catch(() => null);
throw new Error(e?.message || "Failed to update");
}
return res.json();
},
onSuccess: () => {
invalidate();
setEditingId(null);
setForm(EMPTY_FORM);
toast({ title: "Insurance contact updated" });
},
onError: (e) => toast({ title: "Error", description: e.message, variant: "destructive" }),
});
const deleteMutation = useMutation({
mutationFn: async (id) => {
const res = await apiRequest("DELETE", `/api/insurance-contacts/${id}`);
if (!res.ok)
throw new Error("Failed to delete");
},
onSuccess: () => {
invalidate();
setDeleteTarget(null);
toast({ title: "Insurance contact deleted" });
},
onError: (e) => toast({ title: "Error", description: e.message, variant: "destructive" }),
});
const openAdd = () => {
setEditingId(null);
setForm(EMPTY_FORM);
setShowForm(true);
};
const openEdit = (c) => {
setShowForm(false);
setEditingId(c.id);
setForm({ name: c.name, phoneNumber: c.phoneNumber ?? "" });
};
const cancelEdit = () => { setEditingId(null); setForm(EMPTY_FORM); };
const cancelAdd = () => { setShowForm(false); setForm(EMPTY_FORM); };
const handleSave = () => {
if (!form.name.trim()) {
toast({ title: "Name is required", variant: "destructive" });
return;
}
if (editingId !== null) {
updateMutation.mutate({ id: editingId, data: form });
}
else {
createMutation.mutate(form);
}
};
const isSaving = createMutation.isPending || updateMutation.isPending;
return (<div className="bg-white shadow rounded-lg overflow-hidden">
{/* Header */}
<div className="flex justify-between items-center p-4 border-b border-gray-200">
<div>
<h2 className="text-lg font-semibold text-gray-900">Insurance Contacts</h2>
<p className="text-sm text-gray-500 mt-0.5">Phone numbers for insurance companies</p>
</div>
<Button onClick={openAdd} size="sm">
<Plus className="h-4 w-4 mr-1"/> Add Contact
</Button>
</div>
{/* Add form */}
{showForm && (<div className="p-4 border-b bg-gray-50">
<p className="text-sm font-medium text-gray-700 mb-3">New Insurance Contact</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Company Name *</label>
<Input placeholder="e.g. Delta MA" value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} className="h-9 text-sm"/>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Phone Number</label>
<Input placeholder="e.g. (800) 555-0100" value={form.phoneNumber} onChange={(e) => setForm((f) => ({ ...f, phoneNumber: e.target.value }))} className="h-9 text-sm"/>
</div>
</div>
<div className="flex gap-2 mt-3">
<Button size="sm" onClick={handleSave} disabled={isSaving}>
<Check className="h-3.5 w-3.5 mr-1"/>
{isSaving ? "Saving..." : "Save"}
</Button>
<Button size="sm" variant="outline" onClick={cancelAdd} disabled={isSaving}>
<X className="h-3.5 w-3.5 mr-1"/> Cancel
</Button>
</div>
</div>)}
{/* Table */}
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Insurance Company
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Phone Number
</th>
<th className="px-4 py-3 w-24"/>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{isLoading ? (<tr>
<td colSpan={3} className="text-center py-6 text-sm text-gray-400">
Loading...
</td>
</tr>) : contacts.length === 0 ? (<tr>
<td colSpan={3} className="text-center py-10 text-sm text-gray-400">
No insurance contacts yet. Click "Add Contact" to add one.
</td>
</tr>) : (contacts.map((c) => editingId === c.id ? (
/* Inline edit row */
<tr key={c.id} className="bg-blue-50">
<td className="px-4 py-2">
<Input value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} className="h-8 text-sm" placeholder="Company name"/>
</td>
<td className="px-4 py-2">
<Input value={form.phoneNumber} onChange={(e) => setForm((f) => ({ ...f, phoneNumber: e.target.value }))} className="h-8 text-sm" placeholder="Phone number"/>
</td>
<td className="px-4 py-2 text-right">
<Button variant="ghost" size="sm" onClick={handleSave} disabled={isSaving} className="text-green-600 hover:text-green-700">
<Check className="h-4 w-4"/>
</Button>
<Button variant="ghost" size="sm" onClick={cancelEdit} disabled={isSaving}>
<X className="h-4 w-4"/>
</Button>
</td>
</tr>) : (
/* Display row */
<tr key={c.id} className="hover:bg-gray-50">
<td className="px-4 py-3 text-sm font-medium text-gray-900">{c.name}</td>
<td className="px-4 py-3 text-sm text-gray-600">
{c.phoneNumber || <span className="text-gray-300">—</span>}
</td>
<td className="px-4 py-3 text-right">
<Button variant="ghost" size="sm" onClick={() => openEdit(c)}>
<Pencil className="h-4 w-4 text-gray-500"/>
</Button>
<Button variant="ghost" size="sm" onClick={() => setDeleteTarget(c)}>
<Trash2 className="h-4 w-4 text-red-500"/>
</Button>
</td>
</tr>)))}
</tbody>
</table>
</div>
<DeleteConfirmationDialog isOpen={!!deleteTarget} onConfirm={() => deleteTarget && deleteMutation.mutate(deleteTarget.id)} onCancel={() => setDeleteTarget(null)} entityName={deleteTarget?.name}/>
</div>);
}
@@ -0,0 +1,155 @@
import React, { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "@/lib/queryClient";
import { Button } from "../ui/button";
import { Edit, Delete, Plus } from "lucide-react";
import { CredentialForm } from "./InsuranceCredForm";
import { DeleteConfirmationDialog } from "../ui/deleteDialog";
import { useAuth } from "@/hooks/use-auth";
export function CredentialTable() {
const queryClient = useQueryClient();
const { user: currentUser } = useAuth();
const [currentPage, setCurrentPage] = useState(1);
const [modalOpen, setModalOpen] = useState(false);
const [editingCred, setEditingCred] = useState(null);
const credentialsPerPage = 5;
const { data: credentials = [], isLoading, error } = useQuery({
queryKey: ["/api/insuranceCreds/"],
queryFn: async () => {
const res = await apiRequest("GET", "/api/insuranceCreds/");
if (!res.ok)
throw new Error("Failed to fetch credentials");
return res.json();
},
});
const deleteMutation = useMutation({
mutationFn: async (cred) => {
const res = await apiRequest("DELETE", `/api/insuranceCreds/${cred.id}`);
if (!res.ok)
throw new Error("Failed to delete credential");
return true;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/insuranceCreds/"] });
},
});
// New state for delete dialog
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [credentialToDelete, setCredentialToDelete] = useState(null);
const handleDeleteClick = (cred) => {
setCredentialToDelete(cred);
setIsDeleteDialogOpen(true);
};
const handleConfirmDelete = () => {
if (credentialToDelete) {
deleteMutation.mutate(credentialToDelete, {
onSuccess: () => {
setIsDeleteDialogOpen(false);
setCredentialToDelete(null);
},
});
}
};
const handleCancelDelete = () => {
setIsDeleteDialogOpen(false);
setCredentialToDelete(null);
};
const indexOfLast = currentPage * credentialsPerPage;
const indexOfFirst = indexOfLast - credentialsPerPage;
const currentCredentials = credentials.slice(indexOfFirst, indexOfLast);
const totalPages = Math.ceil(credentials.length / credentialsPerPage);
return (<div className="bg-white shadow rounded-lg overflow-hidden">
<div className="flex justify-between items-center p-4 border-b border-gray-200">
<h2 className="text-lg font-semibold text-gray-900">Insurance Credentials</h2>
<Button onClick={() => {
setEditingCred(null);
setModalOpen(true);
}}>
<Plus className="mr-2 h-4 w-4"/> Add Credentials
</Button>
</div>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Site Key
</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Username
</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Password
</th>
<th className="px-4 py-2"/>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{isLoading ? (<tr>
<td colSpan={4} className="text-center py-4">
Loading credentials...
</td>
</tr>) : error ? (<tr>
<td colSpan={4} className="text-center py-4 text-red-600">
Error loading credentials
</td>
</tr>) : currentCredentials.length === 0 ? (<tr>
<td colSpan={4} className="text-center py-4">
No credentials found.
</td>
</tr>) : (currentCredentials.map((cred) => (<tr key={cred.id}>
<td className="px-4 py-2">{cred.siteKey}</td>
<td className="px-4 py-2">{cred.username}</td>
<td className="px-4 py-2">••••••••</td>
<td className="px-4 py-2 text-right">
<Button variant="ghost" size="sm" onClick={() => {
setEditingCred(cred);
setModalOpen(true);
}}>
<Edit className="h-4 w-4"/>
</Button>
<Button variant="ghost" size="sm" onClick={() => handleDeleteClick(cred)}>
<Delete className="h-4 w-4 text-red-600"/>
</Button>
</td>
</tr>)))}
</tbody>
</table>
</div>
{/* Pagination */}
{credentials.length > credentialsPerPage && (<div className="bg-white px-4 py-3 flex items-center justify-between border-t border-gray-200">
<div className="hidden sm:flex sm:flex-1 sm:items-center sm:justify-between">
<p className="text-sm text-gray-700">
Showing <span className="font-medium">{indexOfFirst + 1}</span> to{" "}
<span className="font-medium">{Math.min(indexOfLast, credentials.length)}</span> of{" "}
<span className="font-medium">{credentials.length}</span> results
</p>
<nav className="inline-flex -space-x-px rounded-md shadow-sm" aria-label="Pagination">
<a href="#" onClick={(e) => { e.preventDefault(); if (currentPage > 1)
setCurrentPage(currentPage - 1); }} className={`relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 ${currentPage === 1 ? "pointer-events-none opacity-50" : ""}`}>
Previous
</a>
{Array.from({ length: totalPages }).map((_, i) => (<a key={i} href="#" onClick={(e) => { e.preventDefault(); setCurrentPage(i + 1); }} className={`relative inline-flex items-center px-4 py-2 border text-sm font-medium ${currentPage === i + 1
? "z-10 bg-blue-50 border-blue-500 text-blue-600"
: "border-gray-300 text-gray-500 hover:bg-gray-50"}`}>
{i + 1}
</a>))}
<a href="#" onClick={(e) => { e.preventDefault(); if (currentPage < totalPages)
setCurrentPage(currentPage + 1); }} className={`relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 ${currentPage === totalPages ? "pointer-events-none opacity-50" : ""}`}>
Next
</a>
</nav>
</div>
</div>)}
{/* Modal for Add/Edit */}
{modalOpen && currentUser && (<CredentialForm userId={currentUser.id} defaultValues={editingCred || undefined} onClose={() => setModalOpen(false)}/>)}
<DeleteConfirmationDialog isOpen={isDeleteDialogOpen} onConfirm={handleConfirmDelete} onCancel={handleCancelDelete} entityName={credentialToDelete?.siteKey}/>
</div>);
}
@@ -0,0 +1,99 @@
import { useEffect, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "@/lib/queryClient";
import { toast } from "@/hooks/use-toast";
export function NpiProviderForm({ onClose, defaultValues }) {
const [npiNumber, setNpiNumber] = useState(defaultValues?.npiNumber || "");
const [providerName, setProviderName] = useState(defaultValues?.providerName || "");
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: async () => {
const payload = {
npiNumber: npiNumber.trim(),
providerName: providerName.trim(),
};
const url = defaultValues?.id
? `/api/npiProviders/${defaultValues.id}`
: "/api/npiProviders/";
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 NPI provider");
}
return res.json();
},
onSuccess: () => {
toast({
title: `NPI provider ${defaultValues?.id ? "updated" : "created"}.`,
});
queryClient.invalidateQueries({
queryKey: ["/api/npiProviders/"],
});
onClose();
},
onError: (error) => {
toast({
title: "Error",
description: error.message,
variant: "destructive",
});
},
});
useEffect(() => {
setNpiNumber(defaultValues?.npiNumber || "");
setProviderName(defaultValues?.providerName || "");
}, [defaultValues]);
const handleSubmit = (e) => {
e.preventDefault();
if (!npiNumber || !providerName) {
toast({
title: "Error",
description: "All fields are required.",
variant: "destructive",
});
return;
}
mutation.mutate();
};
return (<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-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">
{defaultValues?.id
? "Edit NPI Provider"
: "Create NPI Provider"}
</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium">
NPI Number
</label>
<input type="text" value={npiNumber} onChange={(e) => setNpiNumber(e.target.value)} className="mt-1 p-2 border rounded w-full" placeholder="e.g., 1489890992"/>
</div>
<div>
<label className="block text-sm font-medium">
Provider Name
</label>
<input type="text" value={providerName} onChange={(e) => setProviderName(e.target.value)} className="mt-1 p-2 border rounded w-full" placeholder="e.g., Kai Gao"/>
</div>
<div className="flex justify-end gap-2">
<button type="button" onClick={onClose} className="text-gray-600 hover:underline">
Cancel
</button>
<button type="submit" disabled={mutation.isPending} className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 disabled:opacity-50">
{mutation.isPending
? defaultValues?.id
? "Updating..."
: "Creating..."
: defaultValues?.id
? "Update"
: "Create"}
</button>
</div>
</form>
</div>
</div>);
}
@@ -0,0 +1,162 @@
import React, { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "@/lib/queryClient";
import { Button } from "../ui/button";
import { Edit, Delete, Plus, ChevronUp, ChevronDown } from "lucide-react";
import { DeleteConfirmationDialog } from "../ui/deleteDialog";
import { NpiProviderForm } from "./npiProviderForm";
export function NpiProviderTable() {
const queryClient = useQueryClient();
const [currentPage, setCurrentPage] = useState(1);
const [modalOpen, setModalOpen] = useState(false);
const [editingProvider, setEditingProvider] = useState(null);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [providerToDelete, setProviderToDelete] = useState(null);
const providersPerPage = 5;
const { data: providers = [], isLoading, error } = useQuery({
queryKey: ["/api/npiProviders/"],
queryFn: async () => {
const res = await apiRequest("GET", "/api/npiProviders/");
if (!res.ok)
throw new Error("Failed to fetch NPI providers");
return res.json();
},
});
const reorderMutation = useMutation({
mutationFn: async (orderedIds) => {
const res = await apiRequest("POST", "/api/npiProviders/reorder", { orderedIds });
if (!res.ok)
throw new Error("Failed to reorder NPI providers");
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/npiProviders/"] });
},
});
const deleteMutation = useMutation({
mutationFn: async (provider) => {
const res = await apiRequest("DELETE", `/api/npiProviders/${provider.id}`);
if (!res.ok)
throw new Error("Failed to delete NPI provider");
return true;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/npiProviders/"] });
},
});
const handleMove = (index, direction) => {
const swapIndex = direction === "up" ? index - 1 : index + 1;
if (swapIndex < 0 || swapIndex >= providers.length)
return;
const newOrder = [...providers];
[newOrder[index], newOrder[swapIndex]] = [newOrder[swapIndex], newOrder[index]];
reorderMutation.mutate(newOrder.map((p) => p.id));
};
const handleDeleteClick = (provider) => {
setProviderToDelete(provider);
setIsDeleteDialogOpen(true);
};
const handleConfirmDelete = () => {
if (!providerToDelete)
return;
deleteMutation.mutate(providerToDelete, {
onSuccess: () => {
setIsDeleteDialogOpen(false);
setProviderToDelete(null);
},
});
};
const indexOfLast = currentPage * providersPerPage;
const indexOfFirst = indexOfLast - providersPerPage;
const currentProviders = providers.slice(indexOfFirst, indexOfLast);
const totalPages = Math.ceil(providers.length / providersPerPage);
return (<div className="bg-white shadow rounded-lg overflow-hidden">
<div className="flex justify-between items-center p-4 border-b border-gray-200">
<h2 className="text-lg font-semibold text-gray-900">NPI Providers</h2>
<Button onClick={() => {
setEditingProvider(null);
setModalOpen(true);
}}>
<Plus className="mr-2 h-4 w-4"/> Add NPI Providers
</Button>
</div>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase w-28">
Provider #
</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">
NPI Number
</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">
Provider Name
</th>
<th className="px-4 py-2 w-32"/>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{isLoading ? (<tr>
<td colSpan={4} className="text-center py-4">
Loading NPI providers...
</td>
</tr>) : error ? (<tr>
<td colSpan={4} className="text-center py-4 text-red-600">
Error loading NPI providers
</td>
</tr>) : currentProviders.length === 0 ? (<tr>
<td colSpan={4} className="text-center py-4">
No NPI providers found.
</td>
</tr>) : (currentProviders.map((provider, pageIndex) => {
const globalIndex = indexOfFirst + pageIndex;
const isDefault = globalIndex === 0;
return (<tr key={provider.id} className={isDefault ? "bg-blue-50" : ""}>
<td className="px-4 py-2">
<span className={`inline-flex items-center gap-1 text-sm font-medium ${isDefault ? "text-blue-700" : "text-gray-600"}`}>
Provider #{globalIndex + 1}
{isDefault && (<span className="ml-1 text-xs bg-blue-100 text-blue-700 px-1.5 py-0.5 rounded">
default
</span>)}
</span>
</td>
<td className="px-4 py-2 text-sm">{provider.npiNumber}</td>
<td className="px-4 py-2 text-sm">{provider.providerName}</td>
<td className="px-4 py-2 text-right">
<Button variant="ghost" size="sm" disabled={globalIndex === 0 || reorderMutation.isPending} onClick={() => handleMove(globalIndex, "up")} title="Move up">
<ChevronUp className="h-4 w-4"/>
</Button>
<Button variant="ghost" size="sm" disabled={globalIndex === providers.length - 1 || reorderMutation.isPending} onClick={() => handleMove(globalIndex, "down")} title="Move down">
<ChevronDown className="h-4 w-4"/>
</Button>
<Button variant="ghost" size="sm" onClick={() => {
setEditingProvider(provider);
setModalOpen(true);
}}>
<Edit className="h-4 w-4"/>
</Button>
<Button variant="ghost" size="sm" onClick={() => handleDeleteClick(provider)}>
<Delete className="h-4 w-4 text-red-600"/>
</Button>
</td>
</tr>);
}))}
</tbody>
</table>
</div>
{providers.length > providersPerPage && (<div className="px-4 py-3 border-t flex justify-between">
<Button variant="ghost" disabled={currentPage === 1} onClick={() => setCurrentPage((p) => p - 1)}>
Previous
</Button>
<Button variant="ghost" disabled={currentPage === totalPages} onClick={() => setCurrentPage((p) => p + 1)}>
Next
</Button>
</div>)}
{modalOpen && (<NpiProviderForm defaultValues={editingProvider || undefined} onClose={() => setModalOpen(false)}/>)}
<DeleteConfirmationDialog isOpen={isDeleteDialogOpen} onConfirm={handleConfirmDelete} onCancel={() => setIsDeleteDialogOpen(false)} entityName={providerToDelete?.providerName}/>
</div>);
}
@@ -0,0 +1,136 @@
import { useState, useEffect } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import { Card, CardContent } from "@/components/ui/card";
import { useToast } from "@/hooks/use-toast";
import { apiRequest, queryClient } from "@/lib/queryClient";
export function OfficeContactCard() {
const { toast } = useToast();
const [officeName, setOfficeName] = useState("");
const [receptionistName, setReceptionistName] = useState("");
const [dentistName, setDentistName] = useState("");
const [phoneNumber, setPhoneNumber] = useState("");
const [email, setEmail] = useState("");
const [fax, setFax] = useState("");
const [streetAddress, setStreetAddress] = useState("");
const [city, setCity] = useState("");
const [state, setState] = useState("");
const [zipCode, setZipCode] = useState("");
const { data: contact, isLoading } = useQuery({
queryKey: ["/api/office-contact"],
queryFn: async () => {
const res = await apiRequest("GET", "/api/office-contact");
if (!res.ok)
return null;
return res.json();
},
});
useEffect(() => {
if (contact) {
setOfficeName(contact.officeName ?? "");
setReceptionistName(contact.receptionistName ?? "");
setDentistName(contact.dentistName ?? "");
setPhoneNumber(contact.phoneNumber ?? "");
setEmail(contact.email ?? "");
setFax(contact.fax ?? "");
setStreetAddress(contact.streetAddress ?? "");
setCity(contact.city ?? "");
setState(contact.state ?? "");
setZipCode(contact.zipCode ?? "");
}
}, [contact]);
const saveMutation = useMutation({
mutationFn: async (data) => {
const res = await apiRequest("PUT", "/api/office-contact", data);
if (!res.ok) {
const err = await res.json().catch(() => null);
throw new Error(err?.message || "Failed to save office contact");
}
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/office-contact"] });
toast({ title: "Office Contact Saved", description: "Office contact information has been saved." });
},
onError: (err) => {
toast({ title: "Error", description: err?.message || "Failed to save office contact", variant: "destructive" });
},
});
const handleSubmit = (e) => {
e.preventDefault();
saveMutation.mutate({ officeName, receptionistName, dentistName, phoneNumber, email, fax, streetAddress, city, state, zipCode });
};
return (<Card>
<CardContent className="space-y-4 py-6">
<div>
<h3 className="text-lg font-semibold">Office Contact</h3>
<p className="text-sm text-gray-500 mt-1">
Contact information for your dental office staff and communications.
</p>
</div>
{isLoading ? (<p className="text-sm text-gray-400">Loading...</p>) : (<form className="space-y-4" onSubmit={handleSubmit}>
<div>
<label className="block text-sm font-medium">Dental Office Name</label>
<input type="text" value={officeName} onChange={(e) => setOfficeName(e.target.value)} className="mt-1 p-2 border rounded w-full text-sm" placeholder="e.g. Summit Dental Care"/>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label className="block text-sm font-medium">Receptionist Name</label>
<input type="text" value={receptionistName} onChange={(e) => setReceptionistName(e.target.value)} className="mt-1 p-2 border rounded w-full text-sm" placeholder="e.g. Jane Smith"/>
</div>
<div>
<label className="block text-sm font-medium">Dentist Name</label>
<input type="text" value={dentistName} onChange={(e) => setDentistName(e.target.value)} className="mt-1 p-2 border rounded w-full text-sm" placeholder="e.g. Dr. John Doe"/>
</div>
<div>
<label className="block text-sm font-medium">Office Phone Number</label>
<input type="tel" value={phoneNumber} onChange={(e) => setPhoneNumber(e.target.value)} className="mt-1 p-2 border rounded w-full text-sm" placeholder="e.g. (508) 555-0100"/>
</div>
<div>
<label className="block text-sm font-medium">Office Email Address</label>
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} className="mt-1 p-2 border rounded w-full text-sm" placeholder="e.g. office@dentalclinic.com"/>
</div>
<div>
<label className="block text-sm font-medium">Office Fax</label>
<input type="tel" value={fax} onChange={(e) => setFax(e.target.value)} className="mt-1 p-2 border rounded w-full text-sm" placeholder="e.g. (508) 555-0199"/>
</div>
</div>
<div className="pt-2">
<h4 className="text-sm font-semibold text-gray-700 mb-3">Office Address</h4>
<div className="space-y-3">
<div>
<label className="block text-sm font-medium">Street Address</label>
<input type="text" value={streetAddress} onChange={(e) => setStreetAddress(e.target.value)} className="mt-1 p-2 border rounded w-full text-sm" placeholder="e.g. 123 Main Street"/>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<div>
<label className="block text-sm font-medium">City</label>
<input type="text" value={city} onChange={(e) => setCity(e.target.value)} className="mt-1 p-2 border rounded w-full text-sm" placeholder="e.g. Framingham"/>
</div>
<div>
<label className="block text-sm font-medium">State</label>
<input type="text" value={state} onChange={(e) => setState(e.target.value)} className="mt-1 p-2 border rounded w-full text-sm" placeholder="e.g. MA"/>
</div>
<div>
<label className="block text-sm font-medium">ZIP Code</label>
<input type="text" value={zipCode} onChange={(e) => setZipCode(e.target.value)} className="mt-1 p-2 border rounded w-full text-sm" placeholder="e.g. 01701"/>
</div>
</div>
</div>
</div>
<div className="pt-1">
<button type="submit" className="bg-teal-600 text-white px-4 py-2 rounded hover:bg-teal-700 text-sm" disabled={saveMutation.isPending}>
{saveMutation.isPending ? "Saving..." : "Save Office Contact"}
</button>
</div>
</form>)}
</CardContent>
</Card>);
}
@@ -0,0 +1,231 @@
import { useState, useEffect } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import { Card, CardContent } from "@/components/ui/card";
import { useToast } from "@/hooks/use-toast";
import { apiRequest, queryClient } from "@/lib/queryClient";
import { Calendar } from "@/components/ui/calendar";
const DAYS = ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"];
const DAY_LABELS = {
monday: "Monday",
tuesday: "Tuesday",
wednesday: "Wednesday",
thursday: "Thursday",
friday: "Friday",
saturday: "Saturday",
sunday: "Sunday",
};
const DEFAULT_DAY_HOURS = {
enabled: true,
amStart: "09:00",
amEnd: "12:00",
pmStart: "13:00",
pmEnd: "17:00",
};
const WEEKEND_DEFAULT = {
enabled: false,
amStart: "09:00",
amEnd: "12:00",
pmStart: "13:00",
pmEnd: "17:00",
};
function buildDefaultWeek() {
return {
monday: { ...DEFAULT_DAY_HOURS },
tuesday: { ...DEFAULT_DAY_HOURS },
wednesday: { ...DEFAULT_DAY_HOURS },
thursday: { ...DEFAULT_DAY_HOURS },
friday: { ...DEFAULT_DAY_HOURS },
saturday: { ...WEEKEND_DEFAULT },
sunday: { ...WEEKEND_DEFAULT },
};
}
const DEFAULT_OFFICE_HOURS = {
doctors: buildDefaultWeek(),
hygienists: buildDefaultWeek(),
};
function TimeSelect({ value, onChange, disabled, }) {
const options = [];
for (let h = 6; h <= 20; h++) {
options.push(`${String(h).padStart(2, "0")}:00`);
options.push(`${String(h).padStart(2, "0")}:30`);
}
const toDisplay = (t) => {
const parts = t.split(":").map(Number);
const hh = parts[0] ?? 0;
const mm = parts[1] ?? 0;
const period = hh >= 12 ? "PM" : "AM";
const h12 = hh > 12 ? hh - 12 : hh === 0 ? 12 : hh;
return `${h12}:${String(mm).padStart(2, "0")} ${period}`;
};
return (<select value={value} onChange={(e) => onChange(e.target.value)} disabled={disabled} className="border rounded px-1 py-0.5 text-sm disabled:opacity-50 disabled:cursor-not-allowed">
{options.map((o) => (<option key={o} value={o}>
{toDisplay(o)}
</option>))}
</select>);
}
function DayRow({ day, hours, onChange, }) {
const set = (field, value) => onChange({ ...hours, [field]: value });
return (<div className={`grid grid-cols-[120px_1fr] gap-2 items-start py-2 border-b last:border-b-0 ${!hours.enabled ? "opacity-60" : ""}`}>
<label className="flex items-center gap-2 cursor-pointer pt-1">
<input type="checkbox" checked={hours.enabled} onChange={(e) => set("enabled", e.target.checked)} className="w-4 h-4 accent-teal-600"/>
<span className="text-sm font-medium">{DAY_LABELS[day]}</span>
</label>
<div className="flex flex-wrap gap-x-6 gap-y-1">
<div className="flex items-center gap-1.5 text-sm">
<span className="text-gray-500 w-6">AM</span>
<TimeSelect value={hours.amStart} onChange={(v) => set("amStart", v)} disabled={!hours.enabled}/>
<span className="text-gray-400">–</span>
<TimeSelect value={hours.amEnd} onChange={(v) => set("amEnd", v)} disabled={!hours.enabled}/>
</div>
<div className="flex items-center gap-1.5 text-sm">
<span className="text-gray-500 w-6">PM</span>
<TimeSelect value={hours.pmStart} onChange={(v) => set("pmStart", v)} disabled={!hours.enabled}/>
<span className="text-gray-400">–</span>
<TimeSelect value={hours.pmEnd} onChange={(v) => set("pmEnd", v)} disabled={!hours.enabled}/>
</div>
</div>
</div>);
}
function SectionHours({ title, subtitle, week, onChange, }) {
return (<div className="border rounded-lg p-4">
<div className="mb-3">
<h4 className="font-semibold text-gray-800">{title}</h4>
<p className="text-xs text-gray-500 mt-0.5">{subtitle}</p>
</div>
{DAYS.map((day) => (<DayRow key={day} day={day} hours={week[day]} onChange={(updated) => onChange({ ...week, [day]: updated })}/>))}
</div>);
}
function toYMD(date) {
return date.toLocaleDateString("en-CA"); // "YYYY-MM-DD" in local time
}
function formatDisplayDate(ymd) {
// "2026-05-10" → "May 10, 2026"
const [y, m, d] = ymd.split("-").map(Number);
return new Date(y, m - 1, d).toLocaleDateString("en-US", {
month: "short", day: "numeric", year: "numeric",
});
}
export function OfficeHoursCard() {
const { toast } = useToast();
const [formData, setFormData] = useState(DEFAULT_OFFICE_HOURS);
// Override-dates UI state (not persisted until Save is clicked)
const [overrideToggle, setOverrideToggle] = useState(false);
const [pickedDate, setPickedDate] = useState(undefined);
const { data: savedHours, isLoading } = useQuery({
queryKey: ["/api/office-hours"],
queryFn: async () => {
const res = await apiRequest("GET", "/api/office-hours");
if (!res.ok)
return null;
return res.json();
},
});
useEffect(() => {
if (savedHours) {
setFormData({
doctors: { ...buildDefaultWeek(), ...savedHours.doctors },
hygienists: { ...buildDefaultWeek(), ...savedHours.hygienists },
overrideDates: savedHours.overrideDates ?? [],
});
}
}, [savedHours]);
const overrideDates = formData.overrideDates ?? [];
const addOverrideDate = () => {
if (!pickedDate)
return;
const ymd = toYMD(pickedDate);
if (overrideDates.includes(ymd))
return; // already added
setFormData((prev) => ({ ...prev, overrideDates: [...overrideDates, ymd].sort() }));
setPickedDate(undefined);
};
const removeOverrideDate = (ymd) => {
setFormData((prev) => ({
...prev,
overrideDates: overrideDates.filter((d) => d !== ymd),
}));
};
const saveMutation = useMutation({
mutationFn: async (data) => {
const res = await apiRequest("PUT", "/api/office-hours", data);
if (!res.ok) {
const err = await res.json().catch(() => null);
throw new Error(err?.message || "Failed to save office hours");
}
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/office-hours"] });
toast({ title: "Office Hours Saved" });
},
onError: (err) => {
toast({ title: "Error", description: err?.message, variant: "destructive" });
},
});
if (isLoading)
return <p className="text-sm text-gray-400 py-4">Loading...</p>;
return (<Card>
<CardContent className="py-6 space-y-6">
<div>
<h3 className="text-lg font-semibold">Office Hours</h3>
<p className="text-sm text-gray-500 mt-1">
Define which time slots are available for scheduling. Appointments outside these hours
require a manual override by a dental assistant.
</p>
</div>
<SectionHours title="Doctors' Hours" subtitle="Applies to schedule columns A, B, C" week={formData.doctors} onChange={(updated) => setFormData((prev) => ({ ...prev, doctors: updated }))}/>
<SectionHours title="Hygienists' Hours" subtitle="Applies to schedule columns D, E, F" week={formData.hygienists} onChange={(updated) => setFormData((prev) => ({ ...prev, hygienists: updated }))}/>
{/* Override Office Hours section */}
<div className="border rounded-lg p-4 space-y-4">
<div className="flex items-center justify-between">
<div>
<h4 className="font-semibold text-gray-800">Override Office Hours</h4>
<p className="text-xs text-gray-500 mt-0.5">
On selected dates, all time slots are open with no restrictions.
</p>
</div>
{/* Toggle */}
<button type="button" onClick={() => setOverrideToggle((v) => !v)} className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none ${overrideToggle ? "bg-teal-600" : "bg-gray-300"}`} aria-pressed={overrideToggle}>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${overrideToggle ? "translate-x-6" : "translate-x-1"}`}/>
</button>
</div>
{/* Calendar + add button — shown when toggle is on */}
{overrideToggle && (<div className="space-y-3">
<p className="text-sm text-gray-600">Select a date to add to the override list:</p>
<div className="border rounded-md inline-block">
<Calendar mode="single" selected={pickedDate} onSelect={(d) => setPickedDate(d ?? undefined)} className="p-2"/>
</div>
<div>
<button type="button" onClick={addOverrideDate} disabled={!pickedDate} className="px-3 py-1.5 text-sm bg-teal-600 text-white rounded hover:bg-teal-700 disabled:opacity-40">
Add Date
</button>
</div>
</div>)}
{/* List of saved override dates */}
{overrideDates.length > 0 && (<div className="space-y-1">
<p className="text-xs font-medium text-gray-500 uppercase tracking-wide">Override dates</p>
<ul className="space-y-1">
{overrideDates.map((ymd) => (<li key={ymd} className="flex items-center justify-between text-sm bg-teal-50 border border-teal-100 rounded px-3 py-1.5">
<span className="font-medium text-teal-800">{formatDisplayDate(ymd)}</span>
<button type="button" onClick={() => removeOverrideDate(ymd)} className="text-teal-400 hover:text-red-500 text-xs ml-4" title="Remove">
✕
</button>
</li>))}
</ul>
</div>)}
</div>
<div className="pt-2">
<button onClick={() => saveMutation.mutate(formData)} disabled={saveMutation.isPending} className="bg-teal-600 text-white px-4 py-2 rounded hover:bg-teal-700 text-sm disabled:opacity-50">
{saveMutation.isPending ? "Saving..." : "Save Office Hours"}
</button>
</div>
</CardContent>
</Card>);
}
@@ -0,0 +1,550 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import { apiRequest, queryClient } from "@/lib/queryClient";
import { Card, CardContent } from "@/components/ui/card";
import { useToast } from "@/hooks/use-toast";
import { Trash2, Plus, X, Pencil } from "lucide-react";
// ── Time grid helpers ─────────────────────────────────────────────────────────
function buildTimeSlots() {
const slots = [];
for (let h = 8; h <= 21; h++) {
for (let m = 0; m < 60; m += 15) {
if (h === 21 && m > 0)
continue;
const pad = (n) => n.toString().padStart(2, "0");
const time = `${pad(h)}:${pad(m)}`;
const h12 = h > 12 ? h - 12 : h === 0 ? 12 : h;
const period = h >= 12 ? "PM" : "AM";
const display = `${h12}:${pad(m)} ${period}`;
slots.push({ time, display });
}
}
return slots;
}
const TIME_SLOTS = buildTimeSlots();
function timeToIdx(time) {
const [h, m] = time.split(":").map(Number);
return (h - 8) * 4 + Math.floor(m / 15);
}
function idxToTime(idx) {
const totalMin = idx * 15 + 8 * 60;
const h = Math.floor(totalMin / 60);
const m = totalMin % 60;
return `${h.toString().padStart(2, "0")}:${m.toString().padStart(2, "0")}`;
}
function uid() {
return Math.random().toString(36).slice(2, 10);
}
const DURATION_OPTIONS = [15, 30, 45, 60, 75, 90, 120];
const COL_COLORS = {
A: "bg-sky-500",
B: "bg-teal-500",
C: "bg-indigo-500",
};
const COL_LIGHT = {
A: "bg-sky-100 border-sky-300 text-sky-800",
B: "bg-teal-100 border-teal-300 text-teal-800",
C: "bg-indigo-100 border-indigo-300 text-indigo-800",
};
// ── Main component ────────────────────────────────────────────────────────────
export function ProcedureTimeslotCard() {
const { toast } = useToast();
// ── Remote data ─────────────────────────────────────────────────
const { data: remote, isLoading } = useQuery({
queryKey: ["/api/procedure-timeslot"],
queryFn: async () => {
const res = await apiRequest("GET", "/api/procedure-timeslot");
if (!res.ok)
throw new Error("Failed to load");
return res.json();
},
});
const saveMutation = useMutation({
mutationFn: async (payload) => {
const res = await apiRequest("PUT", "/api/procedure-timeslot", { data: payload });
if (!res.ok)
throw new Error("Failed to save");
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/procedure-timeslot"] });
toast({ title: "Saved", description: "Settings saved successfully." });
},
onError: () => toast({ title: "Error", description: "Failed to save.", variant: "destructive" }),
});
// ── Section 1 state: Procedure durations ─────────────────────────
const [procedures, setProcedures] = useState([]);
const [newCode, setNewCode] = useState("");
const [newDesc, setNewDesc] = useState("");
const [newDuration, setNewDuration] = useState(30);
// ── Section 2 state: Doctor time slots ───────────────────────────
const [doctorSlots, setDoctorSlots] = useState([]);
// Drag selection state
const [dragCol, setDragCol] = useState(null);
const [dragStartIdx, setDragStartIdx] = useState(null);
const [dragEndIdx, setDragEndIdx] = useState(null);
const mouseDownRef = useRef(false);
// Pending slot confirmation
const [pendingSlot, setPendingSlot] = useState(null);
const [pendingLabel, setPendingLabel] = useState("");
// Edit existing slot
const [editingSlot, setEditingSlot] = useState(null);
const [editLabel, setEditLabel] = useState("");
const [editStart, setEditStart] = useState("");
const [editEnd, setEditEnd] = useState("");
const [editCol, setEditCol] = useState("A");
// ── Section 3 state: Hygienist slots ─────────────────────────────
const [hygienistSlots, setHygienistSlots] = useState([]);
const [newHygDesc, setNewHygDesc] = useState("");
const [newHygDuration, setNewHygDuration] = useState(30);
// Populate from remote when loaded
useEffect(() => {
if (!remote?.data)
return;
const d = remote.data;
setProcedures(d.procedures ?? []);
setDoctorSlots(d.doctorSlots ?? []);
setHygienistSlots(d.hygienistSlots ?? []);
}, [remote]);
// Global mouseup handler to end drag regardless of cursor position
useEffect(() => {
const onMouseUp = () => {
if (!mouseDownRef.current)
return;
mouseDownRef.current = false;
if (dragCol !== null && dragStartIdx !== null && dragEndIdx !== null) {
const s = Math.min(dragStartIdx, dragEndIdx);
const e = Math.max(dragStartIdx, dragEndIdx);
if (e > s) {
setPendingSlot({ col: dragCol, startIdx: s, endIdx: e });
setPendingLabel("");
}
}
setDragCol(null);
setDragStartIdx(null);
setDragEndIdx(null);
};
window.addEventListener("mouseup", onMouseUp);
return () => window.removeEventListener("mouseup", onMouseUp);
}, [dragCol, dragStartIdx, dragEndIdx]);
// ── Drag handlers ────────────────────────────────────────────────
const handleCellMouseDown = useCallback((col, idx) => {
mouseDownRef.current = true;
setDragCol(col);
setDragStartIdx(idx);
setDragEndIdx(idx);
}, []);
const handleCellMouseEnter = useCallback((col, idx) => {
if (!mouseDownRef.current || dragCol !== col)
return;
setDragEndIdx(idx);
}, [dragCol]);
const isDragHighlighted = (col, idx) => {
if (dragCol !== col || dragStartIdx === null || dragEndIdx === null)
return false;
const s = Math.min(dragStartIdx, dragEndIdx);
const e = Math.max(dragStartIdx, dragEndIdx);
return idx >= s && idx <= e;
};
// ── Doctor slot helpers ──────────────────────────────────────────
const getSlotOccupancy = (col, rowIdx) => {
for (const slot of doctorSlots) {
if (slot.column !== col)
continue;
const startIdx = timeToIdx(slot.startTime);
const endIdx = timeToIdx(slot.endTime);
if (rowIdx === startIdx)
return { slot, isStart: true, rowSpan: endIdx - startIdx };
if (rowIdx > startIdx && rowIdx < endIdx)
return { slot, isStart: false, rowSpan: 0 };
}
return null;
};
const confirmPendingSlot = () => {
if (!pendingSlot)
return;
// Check for overlap with existing slots in same column
const newStart = idxToTime(pendingSlot.startIdx);
const newEnd = idxToTime(pendingSlot.endIdx);
const overlap = doctorSlots.some((s) => {
if (s.column !== pendingSlot.col)
return false;
return !(newEnd <= s.startTime || newStart >= s.endTime);
});
if (overlap) {
toast({ title: "Overlap", description: "This time range overlaps an existing slot.", variant: "destructive" });
setPendingSlot(null);
return;
}
setDoctorSlots((prev) => [
...prev,
{ id: uid(), column: pendingSlot.col, startTime: newStart, endTime: newEnd, label: pendingLabel.trim() },
]);
setPendingSlot(null);
setPendingLabel("");
};
const deleteDocSlot = (id) => setDoctorSlots((prev) => prev.filter((s) => s.id !== id));
const openEditSlot = (slot) => {
setEditingSlot(slot);
setEditLabel(slot.label);
setEditStart(slot.startTime);
setEditEnd(slot.endTime);
setEditCol(slot.column);
setPendingSlot(null);
};
const saveEditSlot = () => {
if (!editingSlot)
return;
if (editStart >= editEnd) {
toast({ title: "Invalid time range", description: "End time must be after start time.", variant: "destructive" });
return;
}
const overlap = doctorSlots.some((s) => {
if (s.id === editingSlot.id || s.column !== editCol)
return false;
return !(editEnd <= s.startTime || editStart >= s.endTime);
});
if (overlap) {
toast({ title: "Overlap", description: "This time range overlaps an existing slot.", variant: "destructive" });
return;
}
setDoctorSlots((prev) => prev.map((s) => s.id === editingSlot.id
? { ...s, column: editCol, startTime: editStart, endTime: editEnd, label: editLabel.trim() }
: s));
setEditingSlot(null);
};
const confirmDeleteEditSlot = () => {
if (!editingSlot)
return;
deleteDocSlot(editingSlot.id);
setEditingSlot(null);
};
// ── Save helpers ─────────────────────────────────────────────────
const buildPayload = () => ({ procedures, doctorSlots, hygienistSlots });
const handleSaveProcedures = () => saveMutation.mutate({ ...buildPayload(), procedures });
const handleSaveDoctorSlots = () => saveMutation.mutate({ ...buildPayload(), doctorSlots });
const handleSaveHygienistSlots = () => saveMutation.mutate({ ...buildPayload(), hygienistSlots });
// ── Section 1 actions ────────────────────────────────────────────
const addProcedure = () => {
if (!newCode.trim())
return;
setProcedures((prev) => [...prev, { id: uid(), code: newCode.trim().toUpperCase(), description: newDesc.trim(), durationMin: newDuration }]);
setNewCode("");
setNewDesc("");
setNewDuration(30);
};
const deleteProcedure = (id) => setProcedures((prev) => prev.filter((p) => p.id !== id));
const updateProcedure = (id, field, value) => {
setProcedures((prev) => prev.map((p) => (p.id === id ? { ...p, [field]: value } : p)));
};
// ── Section 3 actions ────────────────────────────────────────────
const addHygSlot = () => {
if (!newHygDesc.trim())
return;
setHygienistSlots((prev) => [...prev, { id: uid(), description: newHygDesc.trim(), durationMin: newHygDuration }]);
setNewHygDesc("");
setNewHygDuration(30);
};
const deleteHygSlot = (id) => setHygienistSlots((prev) => prev.filter((s) => s.id !== id));
const updateHygSlot = (id, field, value) => {
setHygienistSlots((prev) => prev.map((s) => (s.id === id ? { ...s, [field]: value } : s)));
};
// ── Render ───────────────────────────────────────────────────────
if (isLoading)
return <Card><CardContent className="py-10 text-center text-gray-400">Loading...</CardContent></Card>;
return (<div className="space-y-6">
{/* ── Section 1: Procedure Duration ── */}
<Card>
<CardContent className="py-6 space-y-4">
<div>
<h2 className="text-lg font-semibold text-gray-800">Procedure Duration</h2>
<p className="text-sm text-gray-500 mt-0.5">Set the standard time needed for common dental procedures by CDT code.</p>
</div>
{/* Existing procedure rows */}
{procedures.length > 0 && (<div className="border rounded overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-gray-50 border-b">
<tr>
<th className="text-left px-3 py-2 font-medium text-gray-600 w-32">CDT Code</th>
<th className="text-left px-3 py-2 font-medium text-gray-600">Description</th>
<th className="text-left px-3 py-2 font-medium text-gray-600 w-36">Duration</th>
<th className="w-10"/>
</tr>
</thead>
<tbody className="divide-y">
{procedures.map((proc) => (<tr key={proc.id} className="hover:bg-gray-50">
<td className="px-3 py-2">
<input type="text" value={proc.code} onChange={(e) => updateProcedure(proc.id, "code", e.target.value.toUpperCase())} className="w-full border rounded px-2 py-1 text-sm font-mono uppercase" placeholder="D1110"/>
</td>
<td className="px-3 py-2">
<input type="text" value={proc.description} onChange={(e) => updateProcedure(proc.id, "description", e.target.value)} className="w-full border rounded px-2 py-1 text-sm" placeholder="Adult Prophy"/>
</td>
<td className="px-3 py-2">
<select value={proc.durationMin} onChange={(e) => updateProcedure(proc.id, "durationMin", Number(e.target.value))} className="border rounded px-2 py-1 text-sm w-full">
{DURATION_OPTIONS.map((d) => (<option key={d} value={d}>{d} min</option>))}
</select>
</td>
<td className="px-2 py-2 text-center">
<button onClick={() => deleteProcedure(proc.id)} className="text-red-400 hover:text-red-600">
<Trash2 size={15}/>
</button>
</td>
</tr>))}
</tbody>
</table>
</div>)}
{/* Add new procedure row */}
<div className="flex gap-2 items-end">
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-gray-600">CDT Code</label>
<input type="text" value={newCode} onChange={(e) => setNewCode(e.target.value.toUpperCase())} onKeyDown={(e) => e.key === "Enter" && addProcedure()} className="border rounded px-2 py-1.5 text-sm font-mono uppercase w-28" placeholder="D1110"/>
</div>
<div className="flex flex-col gap-1 flex-1">
<label className="text-xs font-medium text-gray-600">Description</label>
<input type="text" value={newDesc} onChange={(e) => setNewDesc(e.target.value)} onKeyDown={(e) => e.key === "Enter" && addProcedure()} className="border rounded px-2 py-1.5 text-sm w-full" placeholder="e.g. Adult Prophy"/>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-gray-600">Duration</label>
<select value={newDuration} onChange={(e) => setNewDuration(Number(e.target.value))} className="border rounded px-2 py-1.5 text-sm">
{DURATION_OPTIONS.map((d) => (<option key={d} value={d}>{d} min</option>))}
</select>
</div>
<button onClick={addProcedure} className="flex items-center gap-1 bg-teal-600 text-white px-3 py-1.5 rounded hover:bg-teal-700 text-sm">
<Plus size={14}/> Add
</button>
</div>
<div className="flex justify-end pt-1">
<button onClick={handleSaveProcedures} disabled={saveMutation.isPending} className="bg-teal-600 text-white px-5 py-2 rounded hover:bg-teal-700 text-sm">
{saveMutation.isPending ? "Saving…" : "Save Procedures"}
</button>
</div>
</CardContent>
</Card>
{/* ── Section 2: Doctor Time Slot Settings ── */}
<Card>
<CardContent className="py-6 space-y-4">
<div>
<h2 className="text-lg font-semibold text-gray-800">Doctor Time Slot Settings</h2>
<p className="text-sm text-gray-500 mt-0.5">
Template reference for scheduling — drag to block time ranges in columns A, B, C. This does not affect the main schedule.
</p>
<div className="flex gap-4 mt-2">
{["A", "B", "C"].map((col) => (<span key={col} className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium border ${COL_LIGHT[col]}`}>
<span className={`w-2.5 h-2.5 rounded-full ${COL_COLORS[col]}`}/> Column {col}
</span>))}
<span className="text-xs text-gray-400 self-center">Drag cells to block a time range</span>
</div>
</div>
{/* Pending slot confirmation dialog */}
{pendingSlot && (<div className="border border-blue-200 bg-blue-50 rounded-lg p-4 flex flex-col gap-3">
<p className="text-sm font-medium text-blue-800">
New slot in column <strong>{pendingSlot.col}</strong>:{" "}
{TIME_SLOTS[pendingSlot.startIdx]?.display} → {TIME_SLOTS[pendingSlot.endIdx]?.display}
</p>
<div className="flex gap-2 items-center">
<input type="text" value={pendingLabel} onChange={(e) => setPendingLabel(e.target.value)} onKeyDown={(e) => e.key === "Enter" && confirmPendingSlot()} className="border rounded px-2 py-1.5 text-sm flex-1" placeholder="Label (e.g. Root Canal D3330) — optional" autoFocus/>
<button onClick={confirmPendingSlot} className="bg-teal-600 text-white px-3 py-1.5 rounded text-sm hover:bg-teal-700">
Confirm
</button>
<button onClick={() => setPendingSlot(null)} className="text-gray-500 hover:text-gray-700">
<X size={18}/>
</button>
</div>
</div>)}
{/* Edit existing slot dialog */}
{editingSlot && (<div className="border border-amber-200 bg-amber-50 rounded-lg p-4 space-y-3">
<div className="flex items-center justify-between">
<p className="text-sm font-semibold text-amber-800 flex items-center gap-1.5">
<Pencil size={14}/> Edit Slot
</p>
<button onClick={() => setEditingSlot(null)} className="text-gray-400 hover:text-gray-600">
<X size={16}/>
</button>
</div>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-gray-600">Column</label>
<select value={editCol} onChange={(e) => setEditCol(e.target.value)} className="border rounded px-2 py-1.5 text-sm">
{["A", "B", "C"].map((c) => (<option key={c} value={c}>{c}</option>))}
</select>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-gray-600">Start Time</label>
<select value={editStart} onChange={(e) => setEditStart(e.target.value)} className="border rounded px-2 py-1.5 text-sm">
{TIME_SLOTS.slice(0, -1).map(({ time, display }) => (<option key={time} value={time}>{display}</option>))}
</select>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-gray-600">End Time</label>
<select value={editEnd} onChange={(e) => setEditEnd(e.target.value)} className="border rounded px-2 py-1.5 text-sm">
{TIME_SLOTS.filter(({ time }) => time > editStart).map(({ time, display }) => (<option key={time} value={time}>{display}</option>))}
</select>
</div>
<div className="flex flex-col gap-1 sm:col-span-1 col-span-2">
<label className="text-xs font-medium text-gray-600">Label</label>
<input type="text" value={editLabel} onChange={(e) => setEditLabel(e.target.value)} onKeyDown={(e) => e.key === "Enter" && saveEditSlot()} className="border rounded px-2 py-1.5 text-sm w-full" placeholder="e.g. Root Canal D3330" autoFocus/>
</div>
</div>
<div className="flex gap-2 justify-between pt-1">
<button onClick={confirmDeleteEditSlot} className="flex items-center gap-1 text-red-600 border border-red-200 bg-red-50 hover:bg-red-100 px-3 py-1.5 rounded text-sm">
<Trash2 size={13}/> Delete Slot
</button>
<div className="flex gap-2">
<button onClick={() => setEditingSlot(null)} className="border px-3 py-1.5 rounded text-sm text-gray-600 hover:bg-gray-100">
Cancel
</button>
<button onClick={saveEditSlot} className="bg-teal-600 text-white px-4 py-1.5 rounded text-sm hover:bg-teal-700">
Save Changes
</button>
</div>
</div>
</div>)}
{/* Time grid */}
<div className="border rounded overflow-auto max-h-[520px]">
<table className="w-full text-xs border-collapse select-none" style={{ tableLayout: "fixed" }} onMouseLeave={() => {
// don't cancel drag on table leave — global mouseup handles it
}}>
<colgroup>
<col style={{ width: "72px" }}/>
<col />
<col />
<col />
</colgroup>
<thead className="sticky top-0 z-10 bg-white border-b">
<tr>
<th className="text-left px-2 py-2 font-medium text-gray-500 border-r">Time</th>
{["A", "B", "C"].map((col) => (<th key={col} className={`py-2 font-semibold text-center border-r last:border-r-0 ${COL_LIGHT[col]}`}>
{col}
</th>))}
</tr>
</thead>
<tbody>
{TIME_SLOTS.map(({ time, display }, rowIdx) => {
const isHour = time.endsWith(":00");
return (<tr key={time} className={isHour ? "border-t border-gray-300" : "border-t border-gray-100"}>
{/* Time label */}
<td className="px-2 py-0 border-r text-gray-400 whitespace-nowrap" style={{ height: "22px" }}>
{isHour ? display : ""}
</td>
{/* Columns A / B / C */}
{["A", "B", "C"].map((col) => {
const occ = getSlotOccupancy(col, rowIdx);
if (occ && !occ.isStart)
return null; // spanned — skip td
if (occ && occ.isStart) {
const isBeingEdited = editingSlot?.id === occ.slot.id;
return (<td key={col} rowSpan={occ.rowSpan} className={`border-r last:border-r-0 px-1 py-0.5 align-top cursor-pointer ${isBeingEdited
? "ring-2 ring-inset ring-amber-400 " + COL_LIGHT[col]
: COL_LIGHT[col]}`} onClick={(e) => { e.stopPropagation(); openEditSlot(occ.slot); }}>
<div className="flex items-start justify-between gap-1 h-full">
<div className="min-w-0">
<div className="font-medium leading-tight break-words text-[11px]">
{occ.slot.label || "Slot"}
</div>
<div className="text-[10px] opacity-70 mt-0.5 whitespace-nowrap">
{TIME_SLOTS[timeToIdx(occ.slot.startTime)]?.display} – {TIME_SLOTS[timeToIdx(occ.slot.endTime)]?.display}
</div>
</div>
<div className="flex flex-col gap-0.5 flex-shrink-0">
<button onMouseDown={(e) => e.stopPropagation()} onClick={(e) => { e.stopPropagation(); openEditSlot(occ.slot); }} className="text-gray-500 hover:text-amber-600" title="Edit">
<Pencil size={11}/>
</button>
<button onMouseDown={(e) => e.stopPropagation()} onClick={(e) => { e.stopPropagation(); deleteDocSlot(occ.slot.id); if (editingSlot?.id === occ.slot.id)
setEditingSlot(null); }} className="text-red-400 hover:text-red-600" title="Delete">
<X size={11}/>
</button>
</div>
</div>
</td>);
}
// Empty / draggable cell
const highlighted = isDragHighlighted(col, rowIdx);
return (<td key={col} className={`border-r last:border-r-0 cursor-crosshair transition-colors ${highlighted ? "bg-blue-200" : "hover:bg-gray-50"}`} onMouseDown={() => handleCellMouseDown(col, rowIdx)} onMouseEnter={() => handleCellMouseEnter(col, rowIdx)}/>);
})}
</tr>);
})}
</tbody>
</table>
</div>
<div className="flex justify-end pt-1">
<button onClick={handleSaveDoctorSlots} disabled={saveMutation.isPending} className="bg-teal-600 text-white px-5 py-2 rounded hover:bg-teal-700 text-sm">
{saveMutation.isPending ? "Saving…" : "Save Doctor Slots"}
</button>
</div>
</CardContent>
</Card>
{/* ── Section 3: Hygienist Time Slot Settings ── */}
<Card>
<CardContent className="py-6 space-y-4">
<div>
<h2 className="text-lg font-semibold text-gray-800">Hygienist Time Slot Settings</h2>
<p className="text-sm text-gray-500 mt-0.5">
Define standard procedure types for hygienists with typical durations.
</p>
</div>
{hygienistSlots.length > 0 && (<div className="border rounded overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-gray-50 border-b">
<tr>
<th className="text-left px-3 py-2 font-medium text-gray-600">Procedure Description</th>
<th className="text-left px-3 py-2 font-medium text-gray-600 w-36">Duration</th>
<th className="w-10"/>
</tr>
</thead>
<tbody className="divide-y">
{hygienistSlots.map((slot) => (<tr key={slot.id} className="hover:bg-gray-50">
<td className="px-3 py-2">
<input type="text" value={slot.description} onChange={(e) => updateHygSlot(slot.id, "description", e.target.value)} className="w-full border rounded px-2 py-1 text-sm" placeholder="e.g. Exam / Recalls / Teeth Cleaning"/>
</td>
<td className="px-3 py-2">
<select value={slot.durationMin} onChange={(e) => updateHygSlot(slot.id, "durationMin", Number(e.target.value))} className="border rounded px-2 py-1 text-sm w-full">
{DURATION_OPTIONS.map((d) => (<option key={d} value={d}>{d} min</option>))}
</select>
</td>
<td className="px-2 py-2 text-center">
<button onClick={() => deleteHygSlot(slot.id)} className="text-red-400 hover:text-red-600">
<Trash2 size={15}/>
</button>
</td>
</tr>))}
</tbody>
</table>
</div>)}
<div className="flex gap-2 items-end">
<div className="flex flex-col gap-1 flex-1">
<label className="text-xs font-medium text-gray-600">Procedure Description</label>
<input type="text" value={newHygDesc} onChange={(e) => setNewHygDesc(e.target.value)} onKeyDown={(e) => e.key === "Enter" && addHygSlot()} className="border rounded px-2 py-1.5 text-sm w-full" placeholder="e.g. Exam, Recalls / Teeth Cleaning"/>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-gray-600">Duration</label>
<select value={newHygDuration} onChange={(e) => setNewHygDuration(Number(e.target.value))} className="border rounded px-2 py-1.5 text-sm">
{DURATION_OPTIONS.map((d) => (<option key={d} value={d}>{d} min</option>))}
</select>
</div>
<button onClick={addHygSlot} className="flex items-center gap-1 bg-teal-600 text-white px-3 py-1.5 rounded hover:bg-teal-700 text-sm">
<Plus size={14}/> Add
</button>
</div>
<div className="flex justify-end pt-1">
<button onClick={handleSaveHygienistSlots} disabled={saveMutation.isPending} className="bg-teal-600 text-white px-5 py-2 rounded hover:bg-teal-700 text-sm">
{saveMutation.isPending ? "Saving…" : "Save Hygienist Slots"}
</button>
</div>
</CardContent>
</Card>
</div>);
}
@@ -0,0 +1,219 @@
import { useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, } from "@/components/ui/dialog";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@/components/ui/table";
import { Switch } from "@/components/ui/switch";
import { Plus, Pencil, Trash2, Link2 } from "lucide-react";
const CATEGORY_LABELS = {
imaging: "3D / CBCT Imaging",
xray: "Digital X-Ray",
perio: "Perio Charting",
lab: "Lab Software",
prescription: "e-Prescriptions",
billing: "Billing / Clearinghouse",
other: "Other",
};
const CATEGORY_COLORS = {
imaging: "bg-purple-100 text-purple-700 border-purple-200",
xray: "bg-blue-100 text-blue-700 border-blue-200",
perio: "bg-green-100 text-green-700 border-green-200",
lab: "bg-orange-100 text-orange-700 border-orange-200",
prescription: "bg-rose-100 text-rose-700 border-rose-200",
billing: "bg-amber-100 text-amber-700 border-amber-200",
other: "bg-gray-100 text-gray-600 border-gray-200",
};
const COMMON_PROGRAMS = [
"DEXIS",
"Sidexis",
"Schick CDR",
"Apteryx XVWeb",
"Carestream CS Imaging",
"Dolphin Imaging",
"i-CAT Vision",
"Romexis",
"EagleSoft",
"Dentrix",
"Eaglesoft Perio",
"Florida Probe",
"PerioVision",
"DrFirst Rcopia",
"NewCrop eRx",
"DentalConnect",
"Custom",
];
let nextId = 1;
const newBridge = () => ({
id: nextId++,
name: "",
category: "other",
executablePath: "",
arguments: "",
enabled: true,
notes: "",
});
export function ProgramBridgeTable() {
const [bridges, setBridges] = useState([]);
const [dialogOpen, setDialogOpen] = useState(false);
const [editing, setEditing] = useState(newBridge());
const openAdd = () => {
setEditing(newBridge());
setDialogOpen(true);
};
const openEdit = (bridge) => {
setEditing({ ...bridge });
setDialogOpen(true);
};
const handleSave = () => {
setBridges((prev) => {
const idx = prev.findIndex((b) => b.id === editing.id);
if (idx >= 0) {
const next = [...prev];
next[idx] = editing;
return next;
}
return [...prev, editing];
});
setDialogOpen(false);
};
const handleDelete = (id) => {
setBridges((prev) => prev.filter((b) => b.id !== id));
};
const toggleEnabled = (id) => {
setBridges((prev) => prev.map((b) => (b.id === id ? { ...b, enabled: !b.enabled } : b)));
};
const enabledCount = bridges.filter((b) => b.enabled).length;
return (<Card>
<CardHeader className="pb-3 pt-4 px-4 flex flex-row items-center justify-between">
<div className="flex items-center gap-2">
<Link2 className="h-4 w-4 text-indigo-500"/>
<CardTitle className="text-base font-semibold">Program Bridge</CardTitle>
{bridges.length > 0 && (<Badge variant="secondary" className="text-xs">
{enabledCount} / {bridges.length} active
</Badge>)}
</div>
<Button size="sm" onClick={openAdd} className="gap-1.5">
<Plus className="h-4 w-4"/>
Add Bridge
</Button>
</CardHeader>
<CardContent className="px-4 pb-4">
<div className="border rounded-lg overflow-hidden">
<Table>
<TableHeader>
<TableRow className="bg-gray-50">
<TableHead className="w-10 text-center">On</TableHead>
<TableHead>Program</TableHead>
<TableHead className="w-36">Category</TableHead>
<TableHead>Executable Path</TableHead>
<TableHead>Arguments</TableHead>
<TableHead className="w-20 text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{bridges.length === 0 ? (<TableRow>
<TableCell colSpan={6} className="text-center text-gray-400 py-10">
<div className="flex flex-col items-center gap-2">
<Link2 className="h-8 w-8 text-gray-300"/>
<span>No program bridges configured. Click "Add Bridge" to connect external software.</span>
</div>
</TableCell>
</TableRow>) : (bridges.map((bridge) => (<TableRow key={bridge.id} className={!bridge.enabled ? "opacity-50" : ""}>
<TableCell className="text-center">
<Switch checked={bridge.enabled} onCheckedChange={() => toggleEnabled(bridge.id)} className="scale-75"/>
</TableCell>
<TableCell>
<div>
<p className="text-sm font-medium">{bridge.name}</p>
{bridge.notes && (<p className="text-xs text-gray-400 truncate max-w-xs">{bridge.notes}</p>)}
</div>
</TableCell>
<TableCell>
<Badge className={`text-xs border ${CATEGORY_COLORS[bridge.category]}`} variant="outline">
{CATEGORY_LABELS[bridge.category]}
</Badge>
</TableCell>
<TableCell className="text-sm text-gray-600 font-mono truncate max-w-[200px]">
{bridge.executablePath || "—"}
</TableCell>
<TableCell className="text-sm text-gray-500 font-mono truncate max-w-[120px]">
{bridge.arguments || "—"}
</TableCell>
<TableCell>
<div className="flex justify-end gap-1">
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => openEdit(bridge)}>
<Pencil className="h-3.5 w-3.5"/>
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-red-500 hover:text-red-600 hover:bg-red-50" onClick={() => handleDelete(bridge.id)}>
<Trash2 className="h-3.5 w-3.5"/>
</Button>
</div>
</TableCell>
</TableRow>)))}
</TableBody>
</Table>
</div>
</CardContent>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Program Bridge</DialogTitle>
</DialogHeader>
<div className="grid grid-cols-2 gap-3 py-2">
<div className="col-span-2 space-y-1">
<Label className="text-xs">Program Name</Label>
<Select value={editing.name} onValueChange={(v) => setEditing((d) => ({ ...d, name: v }))}>
<SelectTrigger className="h-9 text-sm">
<SelectValue placeholder="Select or type program name..."/>
</SelectTrigger>
<SelectContent>
{COMMON_PROGRAMS.map((p) => (<SelectItem key={p} value={p}>{p}</SelectItem>))}
</SelectContent>
</Select>
{editing.name === "Custom" && (<Input className="h-9 text-sm mt-1" placeholder="Enter custom program name" value={editing.name === "Custom" ? "" : editing.name} onChange={(e) => setEditing((d) => ({ ...d, name: e.target.value }))}/>)}
</div>
<div className="col-span-2 space-y-1">
<Label className="text-xs">Category</Label>
<Select value={editing.category} onValueChange={(v) => setEditing((d) => ({ ...d, category: v }))}>
<SelectTrigger className="h-9 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.keys(CATEGORY_LABELS).map((c) => (<SelectItem key={c} value={c}>{CATEGORY_LABELS[c]}</SelectItem>))}
</SelectContent>
</Select>
</div>
<div className="col-span-2 space-y-1">
<Label className="text-xs">Executable Path</Label>
<Input placeholder="C:\Program Files\Software\app.exe" value={editing.executablePath} onChange={(e) => setEditing((d) => ({ ...d, executablePath: e.target.value }))} className="h-9 text-sm font-mono"/>
</div>
<div className="col-span-2 space-y-1">
<Label className="text-xs">Launch Arguments</Label>
<Input placeholder="-patient {patientId} -mode bridge" value={editing.arguments} onChange={(e) => setEditing((d) => ({ ...d, arguments: e.target.value }))} className="h-9 text-sm font-mono"/>
</div>
<div className="col-span-2 space-y-1">
<Label className="text-xs">Notes</Label>
<Input placeholder="Optional notes or version info" value={editing.notes} onChange={(e) => setEditing((d) => ({ ...d, notes: e.target.value }))} className="h-9 text-sm"/>
</div>
<div className="col-span-2 flex items-center gap-2">
<Switch checked={editing.enabled} onCheckedChange={(v) => setEditing((d) => ({ ...d, enabled: v }))}/>
<Label className="text-sm cursor-pointer">
{editing.enabled ? "Enabled" : "Disabled"}
</Label>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDialogOpen(false)}>Cancel</Button>
<Button onClick={handleSave} disabled={!editing.name || editing.name === "Custom"}>
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Card>);
}
@@ -0,0 +1,123 @@
import { useState, useEffect } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import { Card, CardContent } from "@/components/ui/card";
import { Eye, EyeOff, CheckCircle } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
import { apiRequest, queryClient } from "@/lib/queryClient";
export function TwilioSettingsCard() {
const { toast } = useToast();
const [accountSid, setAccountSid] = useState("");
const [authToken, setAuthToken] = useState("");
const [phoneNumber, setPhoneNumber] = useState("");
const [greetingMessage, setGreetingMessage] = useState("");
const [twimlAppSid, setTwimlAppSid] = useState("");
const [showAuthToken, setShowAuthToken] = useState(false);
const { data: settings, isLoading } = useQuery({
queryKey: ["/api/twilio/settings"],
queryFn: async () => {
const res = await apiRequest("GET", "/api/twilio/settings");
if (!res.ok)
return null;
return res.json();
},
});
useEffect(() => {
if (settings) {
setAccountSid(settings.accountSid ?? "");
setAuthToken(settings.authToken ?? "");
setPhoneNumber(settings.phoneNumber ?? "");
setGreetingMessage(settings.greetingMessage ?? "");
setTwimlAppSid(settings.twimlAppSid ?? "");
}
}, [settings]);
const saveMutation = useMutation({
mutationFn: async (data) => {
const res = await apiRequest("PUT", "/api/twilio/settings", data);
if (!res.ok) {
const err = await res.json().catch(() => null);
throw new Error(err?.message || "Failed to save Twilio settings");
}
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/twilio/settings"] });
toast({ title: "Twilio Settings Saved", description: "Your Twilio credentials have been saved." });
},
onError: (err) => {
toast({ title: "Error", description: err?.message || "Failed to save settings", variant: "destructive" });
},
});
const handleSubmit = (e) => {
e.preventDefault();
if (!accountSid.trim() || !authToken.trim() || !phoneNumber.trim())
return;
saveMutation.mutate({ accountSid: accountSid.trim(), authToken: authToken.trim(), phoneNumber: phoneNumber.trim(), greetingMessage: greetingMessage.trim() || null, twimlAppSid: twimlAppSid.trim() || null });
};
const isConfigured = !!(settings?.accountSid && settings?.authToken && settings?.phoneNumber);
return (<Card>
<CardContent className="space-y-4 py-6">
<div className="flex items-center gap-2">
<h3 className="text-lg font-semibold">Twilio Settings</h3>
{isConfigured && (<span className="flex items-center gap-1 text-xs text-green-600 font-medium">
<CheckCircle className="h-3.5 w-3.5"/> Configured
</span>)}
</div>
<p className="text-sm text-gray-500">
Enter your Twilio credentials to enable SMS and calling features. Find these in your{" "}
<span className="font-medium">Twilio Console</span> → Account Info.
</p>
{isLoading ? (<p className="text-sm text-gray-400">Loading...</p>) : (<form className="space-y-4" onSubmit={handleSubmit}>
<div>
<label className="block text-sm font-medium">Account SID</label>
<input type="text" value={accountSid} onChange={(e) => setAccountSid(e.target.value)} className="mt-1 p-2 border rounded w-full font-mono text-sm" placeholder="ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" required/>
</div>
<div>
<label className="block text-sm font-medium">Auth Token</label>
<div className="relative mt-1">
<input type={showAuthToken ? "text" : "password"} value={authToken} onChange={(e) => setAuthToken(e.target.value)} className="p-2 border rounded w-full pr-10 font-mono text-sm" placeholder="••••••••••••••••••••••••••••••••" required/>
<button type="button" onClick={() => setShowAuthToken((v) => !v)} className="absolute inset-y-0 right-2 flex items-center text-gray-500 hover:text-gray-700" tabIndex={-1}>
{showAuthToken ? <EyeOff size={16}/> : <Eye size={16}/>}
</button>
</div>
</div>
<div>
<label className="block text-sm font-medium">Twilio Phone Number</label>
<input type="text" value={phoneNumber} onChange={(e) => setPhoneNumber(e.target.value)} className="mt-1 p-2 border rounded w-full font-mono text-sm" placeholder="+1xxxxxxxxxx" required/>
<p className="text-xs text-gray-500 mt-1">Must be in E.164 format, e.g. +16175551234</p>
</div>
<div>
<label className="block text-sm font-medium">Voicemail Greeting</label>
<textarea value={greetingMessage} onChange={(e) => setGreetingMessage(e.target.value)} className="mt-1 p-2 border rounded w-full text-sm resize-none" rows={3} placeholder="Thank you for calling. Please leave a message after the beep and we will get back to you shortly."/>
<p className="text-xs text-gray-500 mt-1">This message plays when a patient calls your Twilio number. Leave blank to use the default.</p>
</div>
<div>
<label className="block text-sm font-medium">TwiML App SID <span className="text-gray-400 font-normal">(for in-browser calling)</span></label>
<input type="text" value={twimlAppSid} onChange={(e) => setTwimlAppSid(e.target.value)} className="mt-1 p-2 border rounded w-full font-mono text-sm" placeholder="APxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"/>
<p className="text-xs text-gray-500 mt-1">
In Twilio Console → TwiML Apps → create one → set Voice URL to{" "}
<code className="bg-gray-100 px-1 rounded text-xs">https://communitydentistsoflowell.mydentalofficemanagement.com/api/twilio/webhook/voice-browser</code>
{" "}then paste the App SID here. Required for the dial pad.
</p>
</div>
<div className="flex items-center gap-3 pt-1">
<button type="submit" className="bg-teal-600 text-white px-4 py-2 rounded hover:bg-teal-700 text-sm" disabled={saveMutation.isPending}>
{saveMutation.isPending ? "Saving..." : "Save Twilio Settings"}
</button>
</div>
{isConfigured && (<div className="mt-2 p-3 bg-gray-50 rounded border text-xs text-gray-600 space-y-1">
<p className="font-medium text-gray-700">Twilio Webhook URLs</p>
<p>SMS: <code className="bg-white border px-1 rounded">https://communitydentistsoflowell.mydentalofficemanagement.com/api/twilio/webhook/sms</code></p>
<p>Voice: <code className="bg-white border px-1 rounded">https://communitydentistsoflowell.mydentalofficemanagement.com/api/twilio/webhook/voice</code></p>
<p className="text-gray-400">Set these in your Twilio Console → Phone Numbers → your number</p>
</div>)}
</form>)}
</CardContent>
</Card>);
}