feat: schedule page SMS reminders with AI follow-up and reschedule column
- Add Text Reminder for Column button with per-column checkboxes and AI follow-up toggle (default on)
- Batch reminder endpoint resolves {firstName}, {officeName}, {appointmentDate}, {appointmentTime} from AI chat templates
- Add Reschedule for Column UI (logic TBD)
- Move Download Claim PDF for Column below Reschedule for Column
- Add reminderSms template field to AI Chat Settings with variable hints
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,7 @@ type AiChatTemplates = {
|
||||
newPatientGreeting: string;
|
||||
generalFallback: string;
|
||||
rescheduleGreeting: string;
|
||||
reminderSms: string;
|
||||
};
|
||||
|
||||
type OfficeContact = {
|
||||
@@ -19,6 +20,8 @@ type OfficeContact = {
|
||||
};
|
||||
|
||||
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. How can I help you today?",
|
||||
newPatientGreeting:
|
||||
@@ -36,6 +39,7 @@ function preview(text: string, officeName: string) {
|
||||
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);
|
||||
@@ -67,6 +71,7 @@ export function AiChatTemplatesCard() {
|
||||
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);
|
||||
@@ -95,6 +100,7 @@ export function AiChatTemplatesCard() {
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
saveMutation.mutate({
|
||||
reminderSms: reminderSms.trim() || DEFAULTS.reminderSms,
|
||||
reminderGreeting: reminderGreeting.trim() || DEFAULTS.reminderGreeting,
|
||||
newPatientGreeting: newPatientGreeting.trim() || DEFAULTS.newPatientGreeting,
|
||||
generalFallback: generalFallback.trim() || DEFAULTS.generalFallback,
|
||||
@@ -105,6 +111,15 @@ export function AiChatTemplatesCard() {
|
||||
const officeName = officeContact?.officeName?.trim() || "";
|
||||
|
||||
const templates_list = [
|
||||
{
|
||||
key: "reminderSms" as const,
|
||||
icon: <CalendarCheck className="h-4 w-4 text-primary" />,
|
||||
label: "Reminder SMS Text",
|
||||
description: "Outgoing text sent from the Schedule page. Supports: {firstName}, {officeName}, {appointmentDate}, {appointmentTime}.",
|
||||
value: reminderSms,
|
||||
onChange: setReminderSms,
|
||||
placeholder: DEFAULTS.reminderSms,
|
||||
},
|
||||
{
|
||||
key: "reminder" as const,
|
||||
icon: <CalendarCheck className="h-4 w-4 text-primary" />,
|
||||
@@ -152,9 +167,12 @@ export function AiChatTemplatesCard() {
|
||||
<h3 className="text-lg font-semibold">AI Chat Templates</h3>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Customize how your AI assistant introduces itself and responds to patients. Use{" "}
|
||||
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>{" "}
|
||||
as a placeholder — it will be replaced automatically with your dental office name.
|
||||
<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 */}
|
||||
@@ -208,6 +226,7 @@ export function AiChatTemplatesCard() {
|
||||
variant="ghost"
|
||||
className="text-xs text-muted-foreground"
|
||||
onClick={() => {
|
||||
setReminderSms(DEFAULTS.reminderSms);
|
||||
setReminderGreeting(DEFAULTS.reminderGreeting);
|
||||
setNewPatientGreeting(DEFAULTS.newPatientGreeting);
|
||||
setGeneralFallback(DEFAULTS.generalFallback);
|
||||
|
||||
@@ -158,6 +158,9 @@ export default function AppointmentsPage() {
|
||||
const [selectedClaimColumns, setSelectedClaimColumns] = useState<Set<number>>(new Set());
|
||||
const [isClaimingColumn, setIsClaimingColumn] = useState(false);
|
||||
const [selectedReminderColumns, setSelectedReminderColumns] = useState<Set<number>>(new Set());
|
||||
const [isSendingReminders, setIsSendingReminders] = useState(false);
|
||||
const [reminderAiFollowUp, setReminderAiFollowUp] = useState(true);
|
||||
const [selectedRescheduleColumns, setSelectedRescheduleColumns] = useState<Set<number>>(new Set());
|
||||
const [selectedDownloadPdfColumns, setSelectedDownloadPdfColumns] = useState<Set<number>>(new Set());
|
||||
const [isDownloadingClaimPdfs, setIsDownloadingClaimPdfs] = useState(false);
|
||||
const [columnLabels, setColumnLabels] = useState<Record<string, string>>({});
|
||||
@@ -187,6 +190,15 @@ export default function AppointmentsPage() {
|
||||
});
|
||||
};
|
||||
|
||||
const toggleRescheduleColumn = (staffId: number) => {
|
||||
setSelectedRescheduleColumns((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(staffId)) next.delete(staffId);
|
||||
else next.add(staffId);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleDownloadPdfColumn = (staffId: number) => {
|
||||
setSelectedDownloadPdfColumns((prev) => {
|
||||
const next = new Set(prev);
|
||||
@@ -1202,6 +1214,27 @@ export default function AppointmentsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendRemindersForColumn = async () => {
|
||||
if (!user || selectedReminderColumns.size === 0) return;
|
||||
setIsSendingReminders(true);
|
||||
try {
|
||||
const res = await apiRequest("POST", "/api/twilio/send-reminders-batch", {
|
||||
date: formattedSelectedDate,
|
||||
staffIds: Array.from(selectedReminderColumns),
|
||||
aiFollowUp: reminderAiFollowUp,
|
||||
});
|
||||
const { sent, skipped } = await res.json();
|
||||
toast({
|
||||
title: "Text Reminders Sent",
|
||||
description: `Sent ${sent} reminder${sent !== 1 ? "s" : ""}${skipped > 0 ? `, skipped ${skipped} (no phone)` : ""}.`,
|
||||
});
|
||||
} catch (err: any) {
|
||||
toast({ title: "Failed to Send Reminders", description: err?.message ?? String(err), variant: "destructive" });
|
||||
} finally {
|
||||
setIsSendingReminders(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadClaimPdfs = async () => {
|
||||
if (!user || selectedDownloadPdfColumns.size === 0) return;
|
||||
const staffIdsParam = Array.from(selectedDownloadPdfColumns).join(",");
|
||||
@@ -1349,10 +1382,21 @@ export default function AppointmentsPage() {
|
||||
{/* Text Reminder for Column section */}
|
||||
<div className="flex items-center gap-2 border rounded-md px-3 py-2 bg-white shadow-sm">
|
||||
<Button
|
||||
disabled={true}
|
||||
onClick={() => handleSendRemindersForColumn()}
|
||||
disabled={isLoading || isSendingReminders || selectedReminderColumns.size === 0}
|
||||
size="sm"
|
||||
>
|
||||
Text Reminder for Column
|
||||
{isSendingReminders ? (
|
||||
<>
|
||||
<LoaderCircleIcon className="h-4 w-4 mr-1 animate-spin" />
|
||||
Sending...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<MessageSquare className="h-4 w-4 mr-1" />
|
||||
Text Reminder for Column
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
{staffMembers.map((staff, index) => (
|
||||
<label
|
||||
@@ -1370,6 +1414,46 @@ export default function AppointmentsPage() {
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
<div className="flex items-center gap-1.5 ml-2 pl-2 border-l border-gray-200">
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={reminderAiFollowUp}
|
||||
onClick={() => setReminderAiFollowUp((v) => !v)}
|
||||
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus:outline-none ${reminderAiFollowUp ? "bg-teal-600" : "bg-gray-300"}`}
|
||||
>
|
||||
<span
|
||||
className={`pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform ${reminderAiFollowUp ? "translate-x-4" : "translate-x-0"}`}
|
||||
/>
|
||||
</button>
|
||||
<span className="text-xs text-gray-600 whitespace-nowrap">AI follow up</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reschedule for Column section */}
|
||||
<div className="flex items-center gap-2 border rounded-md px-3 py-2 bg-white shadow-sm">
|
||||
<Button
|
||||
disabled={true}
|
||||
size="sm"
|
||||
>
|
||||
Reschedule for Column
|
||||
</Button>
|
||||
{staffMembers.map((staff, index) => (
|
||||
<label
|
||||
key={staff.id}
|
||||
className="flex items-center gap-1 cursor-pointer select-none"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="w-4 h-4 rounded border-gray-400 accent-teal-600"
|
||||
checked={selectedRescheduleColumns.has(Number(staff.id))}
|
||||
onChange={() => toggleRescheduleColumn(Number(staff.id))}
|
||||
/>
|
||||
<span className="text-sm font-medium">
|
||||
{columnLabels[String(staff.id)] ?? String.fromCharCode(65 + index)}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Download Claim PDF for Column section */}
|
||||
|
||||
Reference in New Issue
Block a user