From 131733564ec3e5d420b084f37203725bf9ba9351 Mon Sep 17 00:00:00 2001 From: Gitead Date: Wed, 13 May 2026 00:36:38 -0400 Subject: [PATCH] feat: reschedule-by-office batch SMS, AI follow-up toggle, date-shortcut fix, combined flow diagram MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add Reschedule for Column button on schedule page with AI follow-up toggle (default on) - Add POST /api/twilio/send-reschedule-batch — sends Reschedule by Office template, starts AI reschedule conversation per patient - Add {officePhone} (office call-in number) and {twilioPhone} (SMS number) variable replacement in both batch endpoints - Fix broken variable names in Reschedule by Office template ({office phone number) → {officePhone}, {Twilio phone number} → {twilioPhone}) - Fix reschedule-graph: when patient replies with date in same message as YES/NO (e.g. "ok, 5/18"), AI now checks day open and asks for time instead of asking "what day and time?" - Fix twilio-webhooks: same date-shortcut logic for reminder flow — "no, 5/18" skips "when to reschedule?" and goes straight to day check - Update LangGraph SVG: rename to Reminder & Reschedule Flow, combine both entry points (Reminder SMS + Reschedule SMS) into one diagram with date-shortcut annotations Co-Authored-By: Claude Sonnet 4.6 --- apps/Backend/src/ai/reschedule-graph.ts | 78 +++++++- apps/Backend/src/routes/twilio-webhooks.ts | 28 +++ apps/Backend/src/routes/twilio.ts | 121 ++++++++++++- .../settings/ai-chat-settings-card.tsx | 170 ++++++++++-------- apps/Frontend/src/pages/appointments-page.tsx | 52 +++++- 5 files changed, 371 insertions(+), 78 deletions(-) diff --git a/apps/Backend/src/ai/reschedule-graph.ts b/apps/Backend/src/ai/reschedule-graph.ts index d8e66bc7..84ae8138 100644 --- a/apps/Backend/src/ai/reschedule-graph.ts +++ b/apps/Backend/src/ai/reschedule-graph.ts @@ -837,8 +837,84 @@ export async function runRescheduleStep( return { reply, nextStage: "done" }; } - // Patient confirmed they want to reschedule — move straight to datetime request + // Patient confirmed they want to reschedule. + // First check if they already included a date (and optionally time) in the same message. if (yes(t)) { + const hasTime = messageHasTime(message); + + if (hasTime) { + // Try to parse full datetime from the message + const parsed = await parseDatetimeFromMessage(message, apiKey); + if (parsed) { + const { date, startTime, displayLabel } = parsed; + const dayCheck = await isOfficeDayOpen(date, userId); + if (!dayCheck.open) { + const datePart = displayLabel.split(" at ")[0] ?? displayLabel; + const fallbacks: Record = { + English: `Our office is closed on ${datePart} (${dayCheck.displayDay}). Can you please choose another day?`, + Spanish: `Nuestra oficina está cerrada el ${datePart} (${dayCheck.displayDay}). ¿Puede elegir otro día?`, + Portuguese: `Nosso consultório está fechado em ${datePart} (${dayCheck.displayDay}). Pode escolher outro dia?`, + Mandarin: `我们诊所在 ${datePart}(${dayCheck.displayDay})不开放。请您选择另一天好吗?`, + Cantonese: `我們診所在 ${datePart}(${dayCheck.displayDay})不開放。請您選擇另一天好嗎?`, + Arabic: `مكتبنا مغلق في ${datePart} (${dayCheck.displayDay}). هل يمكنك اختيار يوم آخر؟`, + "Haitian Creole": `Biwo nou fèmen nan ${datePart} (${dayCheck.displayDay}). Tanpri chwazi yon lòt jou?`, + }; + return { reply: fallbacks[lang] ?? fallbacks["English"]!, nextStage: "asked_reschedule_datetime" }; + } + setPendingReschedule(userId, patientId, { newDate: date, dayLabel: displayLabel, startTime }); + const fallbacks: Record = { + English: `Just to confirm — do you prefer ${displayLabel}?`, + Spanish: `Solo para confirmar — ¿prefiere el ${displayLabel}?`, + Portuguese: `Só para confirmar — você prefere ${displayLabel}?`, + Mandarin: `确认一下——您希望的时间是 ${displayLabel} 吗?`, + Cantonese: `確認一下——您希望的時間是 ${displayLabel} 嗎?`, + Arabic: `فقط للتأكيد — هل تفضل ${displayLabel}؟`, + "Haitian Creole": `Jis pou konfime — èske ou prefere ${displayLabel}?`, + }; + const confirmReply = await llmReply( + `You are a friendly dental office AI assistant. The patient mentioned a date/time that you interpreted as "${displayLabel}". Ask them in ${lang} to confirm. 1 sentence, natural and friendly. No formatting.`, + `Patient said: "${message}"`, fallbacks[lang] ?? fallbacks["English"]!, apiKey, + ); + return { reply: confirmReply, nextStage: "asked_reschedule_confirm_datetime" }; + } + } + + // Try to parse a date-only from the message + const parsedDate = await parseDateOnlyFromMessage(message, apiKey); + if (parsedDate) { + const { date, dateLabel } = parsedDate; + const dayCheck = await isOfficeDayOpen(date, userId); + if (!dayCheck.open) { + const fallbacks: Record = { + English: `Our office is closed on ${dateLabel} (${dayCheck.displayDay}). Can you please choose another day?`, + Spanish: `Nuestra oficina está cerrada el ${dateLabel} (${dayCheck.displayDay}). ¿Puede elegir otro día?`, + Portuguese: `Nosso consultório está fechado em ${dateLabel} (${dayCheck.displayDay}). Pode escolher outro dia?`, + Mandarin: `我们诊所在 ${dateLabel}(${dayCheck.displayDay})不开放。请您选择另一天好吗?`, + Cantonese: `我們診所在 ${dateLabel}(${dayCheck.displayDay})不開放。請您選擇另一天好嗎?`, + Arabic: `مكتبنا مغلق في ${dateLabel} (${dayCheck.displayDay}). هل يمكنك اختيار يوم آخر؟`, + "Haitian Creole": `Biwo nou fèmen nan ${dateLabel} (${dayCheck.displayDay}). Tanpri chwazi yon lòt jou?`, + }; + return { reply: fallbacks[lang] ?? fallbacks["English"]!, nextStage: "asked_reschedule_datetime" }; + } + // Day is open — save and ask for time + setPendingReschedule(userId, patientId, { newDate: date, dayLabel: dateLabel }); + const fallbacks: Record = { + English: `What time do you prefer on ${dateLabel}?`, + Spanish: `¿A qué hora prefiere el ${dateLabel}?`, + Portuguese: `Que horário você prefere em ${dateLabel}?`, + Mandarin: `您希望在 ${dateLabel} 几点?`, + Cantonese: `您希望在 ${dateLabel} 幾點?`, + Arabic: `ما الوقت الذي تفضله في ${dateLabel}؟`, + "Haitian Creole": `Ki lè ou prefere nan ${dateLabel}?`, + }; + const askTimeReply = await llmReply( + `You are a friendly dental office assistant. The patient wants ${dateLabel}. Ask them in ${lang} what time they prefer on that day. 1 sentence, no formatting.`, + `Patient wants ${dateLabel} but gave no time.`, fallbacks[lang] ?? fallbacks["English"]!, apiKey, + ); + return { reply: askTimeReply, nextStage: "asked_reschedule_time_for_date" }; + } + + // No date found — ask for day and time const fallbacks: Record = { English: "What day and time would you like? For example: 'Monday at 10am' or 'next Tuesday afternoon'.", Spanish: "¿Qué día y hora prefiere? Por ejemplo: 'lunes a las 10am' o 'martes por la tarde'.", diff --git a/apps/Backend/src/routes/twilio-webhooks.ts b/apps/Backend/src/routes/twilio-webhooks.ts index 125c3fa5..23a6f80b 100644 --- a/apps/Backend/src/routes/twilio-webhooks.ts +++ b/apps/Backend/src/routes/twilio-webhooks.ts @@ -293,6 +293,20 @@ router.post("/webhook/sms", async (req: Request, res: Response): Promise => await saveOutbound(patient.id, introText); } + // If patient said "no" but already included a date (e.g. "no, 5/18"), + // skip "when to reschedule?" and go straight to date processing + if (intent === "no") { + const hasDateInMessage = + /\b\d{1,2}[\/\-]\d{1,2}\b/.test(Body) || + /\b(monday|tuesday|wednesday|thursday|friday|saturday|sunday|tomorrow|next week)\b/i.test(Body); + if (hasDateInMessage) { + const { reply: rescheduleReply, nextStage: rescheduleNextStage } = await runRescheduleStep( + Body, "asked_reschedule_datetime", language, patient.id, aiSettings.apiKey, patient.userId + ); + return reply(rescheduleReply, rescheduleNextStage); + } + } + // Send message 2 (yes/no response) via TwiML — queued SECOND return reply(intentReply, nextStage); } @@ -313,6 +327,20 @@ router.post("/webhook/sms", async (req: Request, res: Response): Promise => if (intent === "no") nextStage = "asked_reschedule_datetime"; else if (intent === "wants_appointment") nextStage = "asked_new_or_existing"; else nextStage = "done"; + + // If patient said "no" but already included a date, skip straight to date processing + if (intent === "no") { + const hasDateInMessage = + /\b\d{1,2}[\/\-]\d{1,2}\b/.test(Body) || + /\b(monday|tuesday|wednesday|thursday|friday|saturday|sunday|tomorrow|next week)\b/i.test(Body); + if (hasDateInMessage) { + const { reply: rescheduleReply, nextStage: rescheduleNextStage } = await runRescheduleStep( + Body, "asked_reschedule_datetime", language, patient.id, aiSettings.apiKey, patient.userId + ); + return reply(rescheduleReply, rescheduleNextStage); + } + } + return reply(aiReply, nextStage); } } diff --git a/apps/Backend/src/routes/twilio.ts b/apps/Backend/src/routes/twilio.ts index 8f0a0a49..7d47a103 100644 --- a/apps/Backend/src/routes/twilio.ts +++ b/apps/Backend/src/routes/twilio.ts @@ -132,7 +132,7 @@ router.post("/send-reminders-batch", async (req: Request, res: Response): Promis return res.status(400).json({ message: "Twilio is not configured. Please add your Twilio credentials in Settings." }); } - // Resolve office name, address, and reminder SMS template + // Resolve office name, address, phone, and reminder SMS template const officeContact = await storage.getOfficeContact(userId); const officeName = (officeContact as any)?.officeName?.trim() || ""; const officeAddress = [ @@ -141,6 +141,7 @@ router.post("/send-reminders-batch", async (req: Request, res: Response): Promis (officeContact as any)?.state?.trim(), (officeContact as any)?.zipCode?.trim(), ].filter(Boolean).join(", "); + const officePhone = (officeContact as any)?.phoneNumber?.trim() || ""; const chatTemplates = await storage.getAiChatTemplates(userId); const DEFAULT_REMINDER_SMS = @@ -194,6 +195,8 @@ router.post("/send-reminders-batch", async (req: Request, res: Response): Promis .replace(/\{firstName\}/g, patient.firstName ?? "") .replace(/\{officeName\}/g, officeName) .replace(/\{officeAddress\}/g, officeAddress) + .replace(/\{officePhone\}/g, officePhone) + .replace(/\{twilioPhone\}/g, settings.phoneNumber) .replace(/\{appointmentDate\}/g, apptDate) .replace(/\{appointmentTime\}/g, apptTime) .replace(/\{date\}/g, apptDate) @@ -229,6 +232,122 @@ router.post("/send-reminders-batch", async (req: Request, res: Response): Promis } }); +// POST /api/twilio/send-reschedule-batch +router.post("/send-reschedule-batch", async (req: Request, res: Response): Promise => { + 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." }); + } + + // Find the "Reschedule by office" template from the SMS template list + const templateList = await storage.getSmsTemplateList(userId); + const rescheduleTemplate = templateList.find((t) => + t.name.toLowerCase().includes("reschedule") && t.name.toLowerCase().includes("office") + ) || templateList.find((t) => t.name.toLowerCase().includes("reschedule")); + + const DEFAULT_RESCHEDULE_SMS = + "Hi {firstName}, this is {officeName}. We need to reschedule your appointment. Please reply to let us know your availability. Thank you!"; + const templateBody = rescheduleTemplate?.body?.trim() || DEFAULT_RESCHEDULE_SMS; + + const officeContact = await storage.getOfficeContact(userId); + const officeName = (officeContact as any)?.officeName?.trim() || ""; + const officeAddress = [ + (officeContact as any)?.streetAddress?.trim(), + (officeContact as any)?.city?.trim(), + (officeContact as any)?.state?.trim(), + (officeContact as any)?.zipCode?.trim(), + ].filter(Boolean).join(", "); + const officePhone = (officeContact as any)?.phoneNumber?.trim() || ""; + + 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" }, + }); + + 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; + const seen = new Set(); + + 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(/\{officeAddress\}/g, officeAddress) + .replace(/\{officePhone\}/g, officePhone) + .replace(/\{twilioPhone\}/g, settings.phoneNumber) + .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 startRescheduleConversation(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 reschedule messages" }); + } +}); + // GET /api/twilio/sms-template-list router.get("/sms-template-list", async (req: Request, res: Response): Promise => { try { diff --git a/apps/Frontend/src/components/settings/ai-chat-settings-card.tsx b/apps/Frontend/src/components/settings/ai-chat-settings-card.tsx index 80883949..9334eda9 100644 --- a/apps/Frontend/src/components/settings/ai-chat-settings-card.tsx +++ b/apps/Frontend/src/components/settings/ai-chat-settings-card.tsx @@ -57,64 +57,61 @@ function newId() { return Date.now().toString(36) + Math.random().toString(36).slice(2); } -// ─── LangGraph flow diagram (SVG) ───────────────────────────────────────────── +// ─── Combined Reminder & Reschedule flow diagram (SVG) ──────────────────────── function LangGraphFlow() { - const W = 640; - const cx = 320; // top-section center + const W = 700; + const cx = 350; const nW = 210; - const nx = cx - nW / 2; + const nx = cx - nW / 2; // 245 - // ── Top sequence ────────────────────────────────────────────────────────── - const n1y = 16, n1h = 52; - const n2y = 84, n2h = 52; - const n3y = 152, n3h = 76; // AI classifies + sends MSG 1 + // ── Dual entry nodes ────────────────────────────────────────────────────── + const entryW = 192; + const lEntryCx = 155; + const rEntryCx = 545; + const e1y = 14, e1h = 56; - const forkHY = n3y + n3h + 22; // 250 + // Merge connector (horizontal line where both branches meet) + const mergeY = e1y + e1h + 14; // 84 - // Branch centers - const lcx = 138; // YES (left) - const rcx = 490; // NO (right) + // ── Center sequence ─────────────────────────────────────────────────────── + const n2y = mergeY + 14, n2h = 52; // Patient replies y=98 + const n3y = n2y + n2h + 14, n3h = 84; // AI classifies y=164 + + const forkHY = n3y + n3h + 22; // 270 + + // ── Branch centers ──────────────────────────────────────────────────────── + const lcx = 140; // YES (left) + const rcx = 490; // NO / Reschedule (right) // ── YES branch ──────────────────────────────────────────────────────────── - const yes1y = forkHY + 50; // 300 - const yes1h = 78; - const yes2y = yes1y + yes1h + 14; // 392 - const yes2h = 52; + const yesW = 195; + const yes1y = forkHY + 50, yes1h = 88; // y=320 + const yes2y = yes1y + yes1h + 12, yes2h = 52; // y=460 - // ── NO branch – step-by-step ────────────────────────────────────────────── - const noW = 226; - const no1y = forkHY + 50; // 300 — "When would you like to reschedule?" - const no1h = 62; - const no2y = no1y + no1h + 14; // 376 — patient gives date + day-open check - const no2h = 80; - const no3y = no2y + no2h + 14; // 470 — "What time on [date]?" - const no3h = 62; - const no4y = no3y + no3h + 14; // 546 — patient gives time + hours check - const no4h = 80; - const no5y = no4y + no4h + 14; // 640 — "Just to confirm — [date at time]?" - const no5h = 62; - const no6y = no5y + no5h + 14; // 716 — patient confirms YES / NO - const no6h = 72; - const no7y = no6y + no6h + 14; // 802 — slot availability check - const no7h = 80; - const no8y = no7y + no7h + 14; // 896 — DB move + AI badge - const no8h = 78; - const no9y = no8y + no8h + 14; // 988 — patient thanks / closing - const no9h = 54; + // ── NO / Reschedule branch ──────────────────────────────────────────────── + const noW = 208; + const failX = rcx + noW / 2 + 8; // 602 + const failW = 92; - const totalH = Math.max(yes2y + yes2h, no9y + no9h) + 24; + const no1y = forkHY + 50, no1h = 84; // y=320 ask date / shortcut + const no2y = no1y + no1h + 14, no2h = 82; // y=418 day check + const no3y = no2y + no2h + 14, no3h = 64; // y=514 ask time + const no4y = no3y + no3h + 14, no4h = 82; // y=592 time check + const no5y = no4y + no4h + 14, no5h = 64; // y=688 confirm datetime + const no6y = no5y + no5h + 14, no6h = 74; // y=766 patient YES/NO + const no7y = no6y + no6h + 14, no7h = 82; // y=854 slot check + const no8y = no7y + no7h + 14, no8h = 80; // y=950 move appointment + const no9y = no8y + no8h + 14, no9h = 54; // y=1044 patient thanks - // Helper: failure annotation box to the right of a check node - const failX = rcx + noW / 2 + 8; - const failW = 122; + const totalH = Math.max(yes2y + yes2h, no9y + no9h) + 28; return ( + + + - {/* ══ TOP SEQUENCE ═══════════════════════════════════════════════ */} + {/* ══ DUAL ENTRY NODES ════════════════════════════════════════════ */} - {/* N1: Office sends reminder */} - - Office sends reminder SMS - Staff triggers the batch send - + {/* Left: Reminder SMS */} + + Office sends Reminder SMS + Staff triggers batch send + + {/* Right: Reschedule by Office SMS */} + + Office sends Reschedule SMS + "Reschedule by Office" template + + {/* Converging lines → horizontal merge → Patient replies */} + + + + + + {/* ══ CENTER SEQUENCE ══════════════════════════════════════════════ */} {/* N2: Patient replies */} @@ -141,18 +154,18 @@ function LangGraphFlow() { Any SMS triggers the AI - {/* N3: AI classifies + sends MSG 1 */} + {/* N3: AI classifies */} Google AI classifies YES / NO - MSG 1 → AI self-introduction sent - "Hi! My name is Lisa at {"{officeName}"}…" - MSG 2 → intent response (below) + Reminder: intro (MSG 1) + reply (MSG 2) + Reschedule: reply sent directly + Date in reply → AI skips to Day Check {/* Fork lines */} - - - - + + + + {/* YES / NO badges */} @@ -162,35 +175,42 @@ function LangGraphFlow() { {/* ══ YES BRANCH ══════════════════════════════════════════════════ */} - {/* Yes1: Thank you */} - - MSG 2: Thank you for confirming! - "See you on [date & time]" - Appointment confirmed ✓ + + Reminder: Thank you for confirming! + Reschedule: What day & time? + "See you on [date & time]" + date in reply → Day Check ↘ - {/* Yes2: Patient thanks → closing */} - + {/* Yes2: Patient thanks */} + Patient: "Thank you / OK" - AI: "Thank you for choosing us! - See you on [date]" + AI: "Thank you for choosing us! See you on [date]" + + {/* Dashed shortcut: YES with date → Day Check */} + {/* ══ NO / RESCHEDULE BRANCH ══════════════════════════════════════ */} - {/* no1: When would you like to reschedule? */} + {/* no1: Understandable / ask date (with date-shortcut note) */} - MSG 2: It is understandable! - When would you like to reschedule? - Patient replies with a preferred date + MSG 2: It is understandable! + When would you like to reschedule? + Patient replies with a preferred date + date in reply → skips to Day Check ↓ - {/* no2: Date received + day-open check */} + {/* no2: Day check */} Check: Is office open on that day? (reads Office Hours settings) ✓ Open → ask what time - ✗ Closed → "Office closed on [day]. Choose another day?" - {/* fail annotation */} + ✗ Closed → "Office closed on [day]. Choose another?" ↩ loops back to ask date @@ -203,7 +223,7 @@ function LangGraphFlow() { Patient replies with a time (e.g. "1 pm") - {/* no4: Time received + hours check */} + {/* no4: Time check */} Check: Is time within office hours? (e.g. not during lunch 12–1pm) @@ -244,7 +264,7 @@ function LangGraphFlow() { - DB Update + DB Update Appointment moved! "Appt moved to [date at time]. Receptionist will confirm tomorrow." @@ -703,6 +723,8 @@ export function AiChatSettingsCard() { {"{firstName}"}{" "} {"{officeName}"}{" "} {"{officeAddress}"}{" "} + {"{officePhone}"}{" "} + {"{twilioPhone}"}{" "} {"{appointmentDate}"}{" "} {"{appointmentTime}"}

@@ -892,7 +914,7 @@ export function AiChatSettingsCard() { - Appointment Reminder Flow + Reminder & Reschedule Flow New Patient / After-Hours Flow diff --git a/apps/Frontend/src/pages/appointments-page.tsx b/apps/Frontend/src/pages/appointments-page.tsx index cb7798e2..4a53cec8 100755 --- a/apps/Frontend/src/pages/appointments-page.tsx +++ b/apps/Frontend/src/pages/appointments-page.tsx @@ -166,6 +166,8 @@ export default function AppointmentsPage() { const [isSendingReminders, setIsSendingReminders] = useState(false); const [reminderAiFollowUp, setReminderAiFollowUp] = useState(true); const [selectedRescheduleColumns, setSelectedRescheduleColumns] = useState>(new Set()); + const [isSendingReschedule, setIsSendingReschedule] = useState(false); + const [rescheduleAiFollowUp, setRescheduleAiFollowUp] = useState(true); const [selectedDownloadPdfColumns, setSelectedDownloadPdfColumns] = useState>(new Set()); const [isDownloadingClaimPdfs, setIsDownloadingClaimPdfs] = useState(false); const [columnLabels, setColumnLabels] = useState>({}); @@ -1260,6 +1262,27 @@ export default function AppointmentsPage() { } }; + const handleSendRescheduleForColumn = async () => { + if (!user || selectedRescheduleColumns.size === 0) return; + setIsSendingReschedule(true); + try { + const res = await apiRequest("POST", "/api/twilio/send-reschedule-batch", { + date: formattedSelectedDate, + staffIds: Array.from(selectedRescheduleColumns), + aiFollowUp: rescheduleAiFollowUp, + }); + const { sent, skipped } = await res.json(); + toast({ + title: "Reschedule Messages Sent", + description: `Sent ${sent} message${sent !== 1 ? "s" : ""}${skipped > 0 ? `, skipped ${skipped} (no phone)` : ""}.`, + }); + } catch (err: any) { + toast({ title: "Failed to Send Reschedule Messages", description: err?.message ?? String(err), variant: "destructive" }); + } finally { + setIsSendingReschedule(false); + } + }; + const handleDownloadClaimPdfs = async () => { if (!user || selectedDownloadPdfColumns.size === 0) return; const staffIdsParam = Array.from(selectedDownloadPdfColumns).join(","); @@ -1458,10 +1481,21 @@ export default function AppointmentsPage() { {/* Reschedule for Column section */}
{staffMembers.map((staff, index) => ( ))} +
+ + AI follow up +
{/* Download Claim PDF for Column section */}