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