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:
@@ -53,8 +53,8 @@ router.put("/chat-templates", async (req: Request, res: Response): Promise<any>
|
|||||||
try {
|
try {
|
||||||
const userId = req.user?.id;
|
const userId = req.user?.id;
|
||||||
if (!userId) return res.status(401).json({ message: "Unauthorized" });
|
if (!userId) return res.status(401).json({ message: "Unauthorized" });
|
||||||
const { reminderGreeting, newPatientGreeting, generalFallback, rescheduleGreeting } = req.body;
|
const { reminderGreeting, newPatientGreeting, generalFallback, rescheduleGreeting, reminderSms } = req.body;
|
||||||
await storage.saveAiChatTemplates(userId, { reminderGreeting, newPatientGreeting, generalFallback, rescheduleGreeting });
|
await storage.saveAiChatTemplates(userId, { reminderGreeting, newPatientGreeting, generalFallback, rescheduleGreeting, reminderSms });
|
||||||
const updated = await storage.getAiChatTemplates(userId);
|
const updated = await storage.getAiChatTemplates(userId);
|
||||||
return res.status(200).json(updated);
|
return res.status(200).json(updated);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import express, { Request, Response } from "express";
|
import express, { Request, Response } from "express";
|
||||||
import twilio from "twilio";
|
import twilio from "twilio";
|
||||||
import { storage } from "../storage";
|
import { storage } from "../storage";
|
||||||
|
import { prisma as db } from "@repo/db/client";
|
||||||
import { getHandoff, setHandoff, resetConversation, startNewPatientConversation, startRescheduleConversation, getAfterHoursHandoff, setAfterHoursHandoff } from "../ai/aiHandoffStore";
|
import { getHandoff, setHandoff, resetConversation, startNewPatientConversation, startRescheduleConversation, getAfterHoursHandoff, setAfterHoursHandoff } from "../ai/aiHandoffStore";
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
@@ -97,7 +98,10 @@ router.post("/send-sms", async (req: Request, res: Response): Promise<any> => {
|
|||||||
await startNewPatientConversation(userId, pid);
|
await startNewPatientConversation(userId, pid);
|
||||||
} else if (startFlow === "reschedule") {
|
} else if (startFlow === "reschedule") {
|
||||||
await startRescheduleConversation(userId, pid);
|
await startRescheduleConversation(userId, pid);
|
||||||
|
} else if (startFlow === "no_follow_up") {
|
||||||
|
// AI follow-up disabled — leave existing stage untouched
|
||||||
} else {
|
} else {
|
||||||
|
// "reminder" or unspecified → start reminder AI flow
|
||||||
await resetConversation(userId, pid);
|
await resetConversation(userId, pid);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -108,6 +112,116 @@ router.post("/send-sms", async (req: Request, res: Response): Promise<any> => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// POST /api/twilio/send-reminders-batch
|
||||||
|
router.post("/send-reminders-batch", async (req: Request, res: Response): Promise<any> => {
|
||||||
|
try {
|
||||||
|
const userId = req.user?.id;
|
||||||
|
if (!userId) return res.status(401).json({ message: "Unauthorized" });
|
||||||
|
|
||||||
|
const { date, staffIds, aiFollowUp = true } = req.body as {
|
||||||
|
date: string;
|
||||||
|
staffIds: number[];
|
||||||
|
aiFollowUp?: boolean;
|
||||||
|
};
|
||||||
|
if (!date || !Array.isArray(staffIds) || staffIds.length === 0) {
|
||||||
|
return res.status(400).json({ message: "date and staffIds are required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const settings = await storage.getTwilioSettings(userId);
|
||||||
|
if (!settings) {
|
||||||
|
return res.status(400).json({ message: "Twilio is not configured. Please add your Twilio credentials in Settings." });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve office name and reminder SMS template
|
||||||
|
const officeContact = await storage.getOfficeContact(userId);
|
||||||
|
const officeName = (officeContact as any)?.officeName?.trim() || "";
|
||||||
|
const chatTemplates = await storage.getAiChatTemplates(userId);
|
||||||
|
|
||||||
|
const DEFAULT_REMINDER_SMS =
|
||||||
|
"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!";
|
||||||
|
const templateBody = chatTemplates.reminderSms?.trim() || DEFAULT_REMINDER_SMS;
|
||||||
|
|
||||||
|
// Fetch appointments for the selected staff columns on the given date
|
||||||
|
const dayStart = new Date(date);
|
||||||
|
dayStart.setUTCHours(0, 0, 0, 0);
|
||||||
|
const dayEnd = new Date(date);
|
||||||
|
dayEnd.setUTCHours(23, 59, 59, 999);
|
||||||
|
|
||||||
|
const appointments = await db.appointment.findMany({
|
||||||
|
where: {
|
||||||
|
staffId: { in: staffIds },
|
||||||
|
date: { gte: dayStart, lte: dayEnd },
|
||||||
|
status: { not: "cancelled" },
|
||||||
|
patient: { userId },
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
patient: { select: { id: true, firstName: true, phone: true } },
|
||||||
|
},
|
||||||
|
orderBy: { startTime: "asc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Format a date string as "May 9, 2026"
|
||||||
|
const months = ["January","February","March","April","May","June","July","August","September","October","November","December"];
|
||||||
|
const formatApptDate = (d: Date | string) => {
|
||||||
|
const dt = new Date(d);
|
||||||
|
return `${months[dt.getUTCMonth()]} ${dt.getUTCDate()}, ${dt.getUTCFullYear()}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const client = getTwilioClient(settings.accountSid, settings.authToken);
|
||||||
|
let sent = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
|
||||||
|
// Deduplicate by patientId — send at most one reminder per patient per run
|
||||||
|
const seen = new Set<number>();
|
||||||
|
|
||||||
|
for (const appt of appointments) {
|
||||||
|
const patient = appt.patient;
|
||||||
|
if (!patient?.phone || seen.has(patient.id)) { skipped++; continue; }
|
||||||
|
seen.add(patient.id);
|
||||||
|
|
||||||
|
const apptDate = formatApptDate(appt.date);
|
||||||
|
const apptTime = typeof appt.startTime === "string"
|
||||||
|
? appt.startTime.substring(0, 5)
|
||||||
|
: String(appt.startTime);
|
||||||
|
|
||||||
|
const message = templateBody
|
||||||
|
.replace(/\{firstName\}/g, patient.firstName ?? "")
|
||||||
|
.replace(/\{officeName\}/g, officeName)
|
||||||
|
.replace(/\{appointmentDate\}/g, apptDate)
|
||||||
|
.replace(/\{appointmentTime\}/g, apptTime)
|
||||||
|
.replace(/\{date\}/g, apptDate)
|
||||||
|
.replace(/\{time\}/g, apptTime);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const twilioMsg = await client.messages.create({
|
||||||
|
body: message,
|
||||||
|
from: settings.phoneNumber,
|
||||||
|
to: patient.phone,
|
||||||
|
});
|
||||||
|
await storage.createCommunication({
|
||||||
|
patientId: patient.id,
|
||||||
|
userId,
|
||||||
|
channel: "sms",
|
||||||
|
direction: "outbound",
|
||||||
|
status: "sent",
|
||||||
|
body: message,
|
||||||
|
twilioSid: twilioMsg.sid,
|
||||||
|
});
|
||||||
|
if (aiFollowUp) {
|
||||||
|
await resetConversation(userId, patient.id);
|
||||||
|
}
|
||||||
|
sent++;
|
||||||
|
} catch {
|
||||||
|
skipped++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(200).json({ sent, skipped });
|
||||||
|
} catch (err: any) {
|
||||||
|
return res.status(500).json({ error: err.message || "Failed to send reminders" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// GET /api/twilio/templates
|
// GET /api/twilio/templates
|
||||||
router.get("/templates", async (req: Request, res: Response): Promise<any> => {
|
router.get("/templates", async (req: Request, res: Response): Promise<any> => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -67,10 +67,11 @@ export const twilioStorage = {
|
|||||||
newPatientGreeting: all["_ai_chat_new_patient_greeting"] ?? "",
|
newPatientGreeting: all["_ai_chat_new_patient_greeting"] ?? "",
|
||||||
generalFallback: all["_ai_chat_general_fallback"] ?? "",
|
generalFallback: all["_ai_chat_general_fallback"] ?? "",
|
||||||
rescheduleGreeting: all["_ai_chat_reschedule_greeting"] ?? "",
|
rescheduleGreeting: all["_ai_chat_reschedule_greeting"] ?? "",
|
||||||
|
reminderSms: all["_ai_chat_reminder_sms"] ?? "",
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
async saveAiChatTemplates(userId: number, templates: { reminderGreeting?: string; newPatientGreeting?: string; generalFallback?: string; rescheduleGreeting?: string }) {
|
async saveAiChatTemplates(userId: number, templates: { reminderGreeting?: string; newPatientGreeting?: string; generalFallback?: string; rescheduleGreeting?: string; reminderSms?: string }) {
|
||||||
const settings = await db.twilioSettings.findUnique({ where: { userId } });
|
const settings = await db.twilioSettings.findUnique({ where: { userId } });
|
||||||
const existing = (settings?.templates as Record<string, string>) || {};
|
const existing = (settings?.templates as Record<string, string>) || {};
|
||||||
const updated: Record<string, string> = { ...existing };
|
const updated: Record<string, string> = { ...existing };
|
||||||
@@ -78,6 +79,7 @@ export const twilioStorage = {
|
|||||||
if (templates.newPatientGreeting !== undefined) updated["_ai_chat_new_patient_greeting"] = templates.newPatientGreeting;
|
if (templates.newPatientGreeting !== undefined) updated["_ai_chat_new_patient_greeting"] = templates.newPatientGreeting;
|
||||||
if (templates.generalFallback !== undefined) updated["_ai_chat_general_fallback"] = templates.generalFallback;
|
if (templates.generalFallback !== undefined) updated["_ai_chat_general_fallback"] = templates.generalFallback;
|
||||||
if (templates.rescheduleGreeting !== undefined) updated["_ai_chat_reschedule_greeting"] = templates.rescheduleGreeting;
|
if (templates.rescheduleGreeting !== undefined) updated["_ai_chat_reschedule_greeting"] = templates.rescheduleGreeting;
|
||||||
|
if (templates.reminderSms !== undefined) updated["_ai_chat_reminder_sms"] = templates.reminderSms;
|
||||||
return db.twilioSettings.upsert({
|
return db.twilioSettings.upsert({
|
||||||
where: { userId },
|
where: { userId },
|
||||||
update: { templates: updated },
|
update: { templates: updated },
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ type AiChatTemplates = {
|
|||||||
newPatientGreeting: string;
|
newPatientGreeting: string;
|
||||||
generalFallback: string;
|
generalFallback: string;
|
||||||
rescheduleGreeting: string;
|
rescheduleGreeting: string;
|
||||||
|
reminderSms: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type OfficeContact = {
|
type OfficeContact = {
|
||||||
@@ -19,6 +20,8 @@ type OfficeContact = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULTS = {
|
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:
|
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?",
|
"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:
|
newPatientGreeting:
|
||||||
@@ -36,6 +39,7 @@ function preview(text: string, officeName: string) {
|
|||||||
export function AiChatTemplatesCard() {
|
export function AiChatTemplatesCard() {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const [reminderSms, setReminderSms] = useState(DEFAULTS.reminderSms);
|
||||||
const [reminderGreeting, setReminderGreeting] = useState(DEFAULTS.reminderGreeting);
|
const [reminderGreeting, setReminderGreeting] = useState(DEFAULTS.reminderGreeting);
|
||||||
const [newPatientGreeting, setNewPatientGreeting] = useState(DEFAULTS.newPatientGreeting);
|
const [newPatientGreeting, setNewPatientGreeting] = useState(DEFAULTS.newPatientGreeting);
|
||||||
const [generalFallback, setGeneralFallback] = useState(DEFAULTS.generalFallback);
|
const [generalFallback, setGeneralFallback] = useState(DEFAULTS.generalFallback);
|
||||||
@@ -67,6 +71,7 @@ export function AiChatTemplatesCard() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (templates && !initialized.current) {
|
if (templates && !initialized.current) {
|
||||||
initialized.current = true;
|
initialized.current = true;
|
||||||
|
setReminderSms(templates.reminderSms || DEFAULTS.reminderSms);
|
||||||
setReminderGreeting(templates.reminderGreeting || DEFAULTS.reminderGreeting);
|
setReminderGreeting(templates.reminderGreeting || DEFAULTS.reminderGreeting);
|
||||||
setNewPatientGreeting(templates.newPatientGreeting || DEFAULTS.newPatientGreeting);
|
setNewPatientGreeting(templates.newPatientGreeting || DEFAULTS.newPatientGreeting);
|
||||||
setGeneralFallback(templates.generalFallback || DEFAULTS.generalFallback);
|
setGeneralFallback(templates.generalFallback || DEFAULTS.generalFallback);
|
||||||
@@ -95,6 +100,7 @@ export function AiChatTemplatesCard() {
|
|||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
saveMutation.mutate({
|
saveMutation.mutate({
|
||||||
|
reminderSms: reminderSms.trim() || DEFAULTS.reminderSms,
|
||||||
reminderGreeting: reminderGreeting.trim() || DEFAULTS.reminderGreeting,
|
reminderGreeting: reminderGreeting.trim() || DEFAULTS.reminderGreeting,
|
||||||
newPatientGreeting: newPatientGreeting.trim() || DEFAULTS.newPatientGreeting,
|
newPatientGreeting: newPatientGreeting.trim() || DEFAULTS.newPatientGreeting,
|
||||||
generalFallback: generalFallback.trim() || DEFAULTS.generalFallback,
|
generalFallback: generalFallback.trim() || DEFAULTS.generalFallback,
|
||||||
@@ -105,6 +111,15 @@ export function AiChatTemplatesCard() {
|
|||||||
const officeName = officeContact?.officeName?.trim() || "";
|
const officeName = officeContact?.officeName?.trim() || "";
|
||||||
|
|
||||||
const templates_list = [
|
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,
|
key: "reminder" as const,
|
||||||
icon: <CalendarCheck className="h-4 w-4 text-primary" />,
|
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>
|
<h3 className="text-lg font-semibold">AI Chat Templates</h3>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">
|
<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>{" "}
|
<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>
|
</p>
|
||||||
|
|
||||||
{/* Office name hint */}
|
{/* Office name hint */}
|
||||||
@@ -208,6 +226,7 @@ export function AiChatTemplatesCard() {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="text-xs text-muted-foreground"
|
className="text-xs text-muted-foreground"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
setReminderSms(DEFAULTS.reminderSms);
|
||||||
setReminderGreeting(DEFAULTS.reminderGreeting);
|
setReminderGreeting(DEFAULTS.reminderGreeting);
|
||||||
setNewPatientGreeting(DEFAULTS.newPatientGreeting);
|
setNewPatientGreeting(DEFAULTS.newPatientGreeting);
|
||||||
setGeneralFallback(DEFAULTS.generalFallback);
|
setGeneralFallback(DEFAULTS.generalFallback);
|
||||||
|
|||||||
@@ -158,6 +158,9 @@ export default function AppointmentsPage() {
|
|||||||
const [selectedClaimColumns, setSelectedClaimColumns] = useState<Set<number>>(new Set());
|
const [selectedClaimColumns, setSelectedClaimColumns] = useState<Set<number>>(new Set());
|
||||||
const [isClaimingColumn, setIsClaimingColumn] = useState(false);
|
const [isClaimingColumn, setIsClaimingColumn] = useState(false);
|
||||||
const [selectedReminderColumns, setSelectedReminderColumns] = useState<Set<number>>(new Set());
|
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 [selectedDownloadPdfColumns, setSelectedDownloadPdfColumns] = useState<Set<number>>(new Set());
|
||||||
const [isDownloadingClaimPdfs, setIsDownloadingClaimPdfs] = useState(false);
|
const [isDownloadingClaimPdfs, setIsDownloadingClaimPdfs] = useState(false);
|
||||||
const [columnLabels, setColumnLabels] = useState<Record<string, string>>({});
|
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) => {
|
const toggleDownloadPdfColumn = (staffId: number) => {
|
||||||
setSelectedDownloadPdfColumns((prev) => {
|
setSelectedDownloadPdfColumns((prev) => {
|
||||||
const next = new Set(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 () => {
|
const handleDownloadClaimPdfs = async () => {
|
||||||
if (!user || selectedDownloadPdfColumns.size === 0) return;
|
if (!user || selectedDownloadPdfColumns.size === 0) return;
|
||||||
const staffIdsParam = Array.from(selectedDownloadPdfColumns).join(",");
|
const staffIdsParam = Array.from(selectedDownloadPdfColumns).join(",");
|
||||||
@@ -1349,10 +1382,21 @@ export default function AppointmentsPage() {
|
|||||||
{/* Text Reminder for Column section */}
|
{/* Text Reminder for Column section */}
|
||||||
<div className="flex items-center gap-2 border rounded-md px-3 py-2 bg-white shadow-sm">
|
<div className="flex items-center gap-2 border rounded-md px-3 py-2 bg-white shadow-sm">
|
||||||
<Button
|
<Button
|
||||||
disabled={true}
|
onClick={() => handleSendRemindersForColumn()}
|
||||||
|
disabled={isLoading || isSendingReminders || selectedReminderColumns.size === 0}
|
||||||
size="sm"
|
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>
|
</Button>
|
||||||
{staffMembers.map((staff, index) => (
|
{staffMembers.map((staff, index) => (
|
||||||
<label
|
<label
|
||||||
@@ -1370,6 +1414,46 @@ export default function AppointmentsPage() {
|
|||||||
</span>
|
</span>
|
||||||
</label>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Download Claim PDF for Column section */}
|
{/* Download Claim PDF for Column section */}
|
||||||
|
|||||||
Reference in New Issue
Block a user