feat: reschedule-by-office batch SMS, AI follow-up toggle, date-shortcut fix, combined flow diagram
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -837,8 +837,84 @@ export async function runRescheduleStep(
|
|||||||
return { reply, nextStage: "done" };
|
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)) {
|
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<string, string> = {
|
||||||
|
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<string, string> = {
|
||||||
|
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<string, string> = {
|
||||||
|
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<string, string> = {
|
||||||
|
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<string, string> = {
|
const fallbacks: Record<string, string> = {
|
||||||
English: "What day and time would you like? For example: 'Monday at 10am' or 'next Tuesday afternoon'.",
|
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'.",
|
Spanish: "¿Qué día y hora prefiere? Por ejemplo: 'lunes a las 10am' o 'martes por la tarde'.",
|
||||||
|
|||||||
@@ -293,6 +293,20 @@ router.post("/webhook/sms", async (req: Request, res: Response): Promise<any> =>
|
|||||||
await saveOutbound(patient.id, introText);
|
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
|
// Send message 2 (yes/no response) via TwiML — queued SECOND
|
||||||
return reply(intentReply, nextStage);
|
return reply(intentReply, nextStage);
|
||||||
}
|
}
|
||||||
@@ -313,6 +327,20 @@ router.post("/webhook/sms", async (req: Request, res: Response): Promise<any> =>
|
|||||||
if (intent === "no") nextStage = "asked_reschedule_datetime";
|
if (intent === "no") nextStage = "asked_reschedule_datetime";
|
||||||
else if (intent === "wants_appointment") nextStage = "asked_new_or_existing";
|
else if (intent === "wants_appointment") nextStage = "asked_new_or_existing";
|
||||||
else nextStage = "done";
|
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);
|
return reply(aiReply, nextStage);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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." });
|
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 officeContact = await storage.getOfficeContact(userId);
|
||||||
const officeName = (officeContact as any)?.officeName?.trim() || "";
|
const officeName = (officeContact as any)?.officeName?.trim() || "";
|
||||||
const officeAddress = [
|
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)?.state?.trim(),
|
||||||
(officeContact as any)?.zipCode?.trim(),
|
(officeContact as any)?.zipCode?.trim(),
|
||||||
].filter(Boolean).join(", ");
|
].filter(Boolean).join(", ");
|
||||||
|
const officePhone = (officeContact as any)?.phoneNumber?.trim() || "";
|
||||||
const chatTemplates = await storage.getAiChatTemplates(userId);
|
const chatTemplates = await storage.getAiChatTemplates(userId);
|
||||||
|
|
||||||
const DEFAULT_REMINDER_SMS =
|
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(/\{firstName\}/g, patient.firstName ?? "")
|
||||||
.replace(/\{officeName\}/g, officeName)
|
.replace(/\{officeName\}/g, officeName)
|
||||||
.replace(/\{officeAddress\}/g, officeAddress)
|
.replace(/\{officeAddress\}/g, officeAddress)
|
||||||
|
.replace(/\{officePhone\}/g, officePhone)
|
||||||
|
.replace(/\{twilioPhone\}/g, settings.phoneNumber)
|
||||||
.replace(/\{appointmentDate\}/g, apptDate)
|
.replace(/\{appointmentDate\}/g, apptDate)
|
||||||
.replace(/\{appointmentTime\}/g, apptTime)
|
.replace(/\{appointmentTime\}/g, apptTime)
|
||||||
.replace(/\{date\}/g, apptDate)
|
.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<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." });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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<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(/\{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
|
// GET /api/twilio/sms-template-list
|
||||||
router.get("/sms-template-list", async (req: Request, res: Response): Promise<any> => {
|
router.get("/sms-template-list", async (req: Request, res: Response): Promise<any> => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -57,64 +57,61 @@ function newId() {
|
|||||||
return Date.now().toString(36) + Math.random().toString(36).slice(2);
|
return Date.now().toString(36) + Math.random().toString(36).slice(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── LangGraph flow diagram (SVG) ─────────────────────────────────────────────
|
// ─── Combined Reminder & Reschedule flow diagram (SVG) ────────────────────────
|
||||||
|
|
||||||
function LangGraphFlow() {
|
function LangGraphFlow() {
|
||||||
const W = 640;
|
const W = 700;
|
||||||
const cx = 320; // top-section center
|
const cx = 350;
|
||||||
const nW = 210;
|
const nW = 210;
|
||||||
const nx = cx - nW / 2;
|
const nx = cx - nW / 2; // 245
|
||||||
|
|
||||||
// ── Top sequence ──────────────────────────────────────────────────────────
|
// ── Dual entry nodes ──────────────────────────────────────────────────────
|
||||||
const n1y = 16, n1h = 52;
|
const entryW = 192;
|
||||||
const n2y = 84, n2h = 52;
|
const lEntryCx = 155;
|
||||||
const n3y = 152, n3h = 76; // AI classifies + sends MSG 1
|
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
|
// ── Center sequence ───────────────────────────────────────────────────────
|
||||||
const lcx = 138; // YES (left)
|
const n2y = mergeY + 14, n2h = 52; // Patient replies y=98
|
||||||
const rcx = 490; // NO (right)
|
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 ────────────────────────────────────────────────────────────
|
// ── YES branch ────────────────────────────────────────────────────────────
|
||||||
const yes1y = forkHY + 50; // 300
|
const yesW = 195;
|
||||||
const yes1h = 78;
|
const yes1y = forkHY + 50, yes1h = 88; // y=320
|
||||||
const yes2y = yes1y + yes1h + 14; // 392
|
const yes2y = yes1y + yes1h + 12, yes2h = 52; // y=460
|
||||||
const yes2h = 52;
|
|
||||||
|
|
||||||
// ── NO branch – step-by-step ──────────────────────────────────────────────
|
// ── NO / Reschedule branch ────────────────────────────────────────────────
|
||||||
const noW = 226;
|
const noW = 208;
|
||||||
const no1y = forkHY + 50; // 300 — "When would you like to reschedule?"
|
const failX = rcx + noW / 2 + 8; // 602
|
||||||
const no1h = 62;
|
const failW = 92;
|
||||||
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;
|
|
||||||
|
|
||||||
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 totalH = Math.max(yes2y + yes2h, no9y + no9h) + 28;
|
||||||
const failX = rcx + noW / 2 + 8;
|
|
||||||
const failW = 122;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
viewBox={`0 0 ${W} ${totalH}`}
|
viewBox={`0 0 ${W} ${totalH}`}
|
||||||
className="w-full max-w-2xl mx-auto"
|
className="w-full max-w-2xl mx-auto"
|
||||||
role="img"
|
role="img"
|
||||||
aria-label="LangGraph appointment reminder conversation flow"
|
aria-label="LangGraph Reminder and Reschedule conversation flow"
|
||||||
>
|
>
|
||||||
<defs>
|
<defs>
|
||||||
<marker id="rg2ah" markerWidth="10" markerHeight="7"
|
<marker id="rg2ah" markerWidth="10" markerHeight="7"
|
||||||
@@ -125,15 +122,31 @@ function LangGraphFlow() {
|
|||||||
refX="7" refY="3" orient="auto" markerUnits="userSpaceOnUse">
|
refX="7" refY="3" orient="auto" markerUnits="userSpaceOnUse">
|
||||||
<polygon points="0 0,8 3,0 6" fill="#EF4444" />
|
<polygon points="0 0,8 3,0 6" fill="#EF4444" />
|
||||||
</marker>
|
</marker>
|
||||||
|
<marker id="rg2short" markerWidth="8" markerHeight="6"
|
||||||
|
refX="7" refY="3" orient="auto" markerUnits="userSpaceOnUse">
|
||||||
|
<polygon points="0 0,8 3,0 6" fill="#047857" />
|
||||||
|
</marker>
|
||||||
</defs>
|
</defs>
|
||||||
|
|
||||||
{/* ══ TOP SEQUENCE ═══════════════════════════════════════════════ */}
|
{/* ══ DUAL ENTRY NODES ════════════════════════════════════════════ */}
|
||||||
|
|
||||||
{/* N1: Office sends reminder */}
|
{/* Left: Reminder SMS */}
|
||||||
<rect x={nx} y={n1y} width={nW} height={n1h} rx={8} fill="#EFF6FF" stroke="#3B82F6" strokeWidth={1.5} />
|
<rect x={lEntryCx-entryW/2} y={e1y} width={entryW} height={e1h} rx={8} fill="#EFF6FF" stroke="#3B82F6" strokeWidth={1.5} />
|
||||||
<text x={cx} y={n1y+22} textAnchor="middle" fontSize={13} fontWeight="600" fill="#1D4ED8">Office sends reminder SMS</text>
|
<text x={lEntryCx} y={e1y+22} textAnchor="middle" fontSize={12} fontWeight="600" fill="#1D4ED8">Office sends Reminder SMS</text>
|
||||||
<text x={cx} y={n1y+40} textAnchor="middle" fontSize={10} fill="#93C5FD">Staff triggers the batch send</text>
|
<text x={lEntryCx} y={e1y+40} textAnchor="middle" fontSize={9} fill="#93C5FD">Staff triggers batch send</text>
|
||||||
<line x1={cx} y1={n1y+n1h} x2={cx} y2={n2y-2} stroke="#9CA3AF" strokeWidth={1.5} markerEnd="url(#rg2ah)" />
|
|
||||||
|
{/* Right: Reschedule by Office SMS */}
|
||||||
|
<rect x={rEntryCx-entryW/2} y={e1y} width={entryW} height={e1h} rx={8} fill="#FFF7ED" stroke="#F97316" strokeWidth={1.5} />
|
||||||
|
<text x={rEntryCx} y={e1y+22} textAnchor="middle" fontSize={12} fontWeight="600" fill="#C2410C">Office sends Reschedule SMS</text>
|
||||||
|
<text x={rEntryCx} y={e1y+40} textAnchor="middle" fontSize={9} fill="#FDBA74">"Reschedule by Office" template</text>
|
||||||
|
|
||||||
|
{/* Converging lines → horizontal merge → Patient replies */}
|
||||||
|
<line x1={lEntryCx} y1={e1y+e1h} x2={lEntryCx} y2={mergeY} stroke="#9CA3AF" strokeWidth={1.5} />
|
||||||
|
<line x1={rEntryCx} y1={e1y+e1h} x2={rEntryCx} y2={mergeY} stroke="#9CA3AF" strokeWidth={1.5} />
|
||||||
|
<line x1={lEntryCx} y1={mergeY} x2={rEntryCx} y2={mergeY} stroke="#9CA3AF" strokeWidth={1.5} />
|
||||||
|
<line x1={cx} y1={mergeY} x2={cx} y2={n2y-2} stroke="#9CA3AF" strokeWidth={1.5} markerEnd="url(#rg2ah)" />
|
||||||
|
|
||||||
|
{/* ══ CENTER SEQUENCE ══════════════════════════════════════════════ */}
|
||||||
|
|
||||||
{/* N2: Patient replies */}
|
{/* N2: Patient replies */}
|
||||||
<rect x={nx} y={n2y} width={nW} height={n2h} rx={8} fill="#F9FAFB" stroke="#D1D5DB" strokeWidth={1.5} />
|
<rect x={nx} y={n2y} width={nW} height={n2h} rx={8} fill="#F9FAFB" stroke="#D1D5DB" strokeWidth={1.5} />
|
||||||
@@ -141,18 +154,18 @@ function LangGraphFlow() {
|
|||||||
<text x={cx} y={n2y+40} textAnchor="middle" fontSize={10} fill="#9CA3AF">Any SMS triggers the AI</text>
|
<text x={cx} y={n2y+40} textAnchor="middle" fontSize={10} fill="#9CA3AF">Any SMS triggers the AI</text>
|
||||||
<line x1={cx} y1={n2y+n2h} x2={cx} y2={n3y-2} stroke="#9CA3AF" strokeWidth={1.5} markerEnd="url(#rg2ah)" />
|
<line x1={cx} y1={n2y+n2h} x2={cx} y2={n3y-2} stroke="#9CA3AF" strokeWidth={1.5} markerEnd="url(#rg2ah)" />
|
||||||
|
|
||||||
{/* N3: AI classifies + sends MSG 1 */}
|
{/* N3: AI classifies */}
|
||||||
<rect x={nx} y={n3y} width={nW} height={n3h} rx={8} fill="#ECFDF5" stroke="#10B981" strokeWidth={1.5} />
|
<rect x={nx} y={n3y} width={nW} height={n3h} rx={8} fill="#ECFDF5" stroke="#10B981" strokeWidth={1.5} />
|
||||||
<text x={cx} y={n3y+20} textAnchor="middle" fontSize={12} fontWeight="700" fill="#065F46">Google AI classifies YES / NO</text>
|
<text x={cx} y={n3y+20} textAnchor="middle" fontSize={12} fontWeight="700" fill="#065F46">Google AI classifies YES / NO</text>
|
||||||
<text x={cx} y={n3y+36} textAnchor="middle" fontSize={10} fontWeight="600" fill="#047857">MSG 1 → AI self-introduction sent</text>
|
<text x={cx} y={n3y+36} textAnchor="middle" fontSize={10} fontWeight="600" fill="#1D4ED8">Reminder: intro (MSG 1) + reply (MSG 2)</text>
|
||||||
<text x={cx} y={n3y+52} textAnchor="middle" fontSize={9} fill="#6B7280" fontStyle="italic">"Hi! My name is Lisa at {"{officeName}"}…"</text>
|
<text x={cx} y={n3y+52} textAnchor="middle" fontSize={10} fontWeight="600" fill="#C2410C">Reschedule: reply sent directly</text>
|
||||||
<text x={cx} y={n3y+66} textAnchor="middle" fontSize={9} fill="#6B7280" fontStyle="italic">MSG 2 → intent response (below)</text>
|
<text x={cx} y={n3y+70} textAnchor="middle" fontSize={9} fill="#6B7280" fontStyle="italic">Date in reply → AI skips to Day Check</text>
|
||||||
|
|
||||||
{/* Fork lines */}
|
{/* Fork lines */}
|
||||||
<line x1={cx} y1={n3y+n3h} x2={cx} y2={forkHY} stroke="#9CA3AF" strokeWidth={1.5} />
|
<line x1={cx} y1={n3y+n3h} x2={cx} y2={forkHY} stroke="#9CA3AF" strokeWidth={1.5} />
|
||||||
<line x1={lcx} y1={forkHY} x2={rcx} y2={forkHY} stroke="#9CA3AF" strokeWidth={1.5} />
|
<line x1={lcx} y1={forkHY} x2={rcx} y2={forkHY} stroke="#9CA3AF" strokeWidth={1.5} />
|
||||||
<line x1={lcx} y1={forkHY} x2={lcx} y2={yes1y-2} stroke="#9CA3AF" strokeWidth={1.5} markerEnd="url(#rg2ah)" />
|
<line x1={lcx} y1={forkHY} x2={lcx} y2={yes1y-2} stroke="#9CA3AF" strokeWidth={1.5} markerEnd="url(#rg2ah)" />
|
||||||
<line x1={rcx} y1={forkHY} x2={rcx} y2={no1y-2} stroke="#9CA3AF" strokeWidth={1.5} markerEnd="url(#rg2ah)" />
|
<line x1={rcx} y1={forkHY} x2={rcx} y2={no1y-2} stroke="#9CA3AF" strokeWidth={1.5} markerEnd="url(#rg2ah)" />
|
||||||
|
|
||||||
{/* YES / NO badges */}
|
{/* YES / NO badges */}
|
||||||
<rect x={lcx-26} y={forkHY-12} width={52} height={24} rx={12} fill="#F0FDF4" stroke="#22C55E" strokeWidth={1.5} />
|
<rect x={lcx-26} y={forkHY-12} width={52} height={24} rx={12} fill="#F0FDF4" stroke="#22C55E" strokeWidth={1.5} />
|
||||||
@@ -162,35 +175,42 @@ function LangGraphFlow() {
|
|||||||
|
|
||||||
{/* ══ YES BRANCH ══════════════════════════════════════════════════ */}
|
{/* ══ YES BRANCH ══════════════════════════════════════════════════ */}
|
||||||
|
|
||||||
{/* Yes1: Thank you */}
|
<rect x={lcx-yesW/2} y={yes1y} width={yesW} height={yes1h} rx={8} fill="#F0FDF4" stroke="#22C55E" strokeWidth={1.5} />
|
||||||
<rect x={lcx-nW/2} y={yes1y} width={nW} height={yes1h} rx={8} fill="#F0FDF4" stroke="#22C55E" strokeWidth={1.5} />
|
<text x={lcx} y={yes1y+18} textAnchor="middle" fontSize={10} fontWeight="600" fill="#15803D">Reminder: Thank you for confirming!</text>
|
||||||
<text x={lcx} y={yes1y+20} textAnchor="middle" fontSize={11} fontWeight="600" fill="#15803D">MSG 2: Thank you for confirming!</text>
|
<text x={lcx} y={yes1y+34} textAnchor="middle" fontSize={10} fontWeight="600" fill="#C2410C">Reschedule: What day & time?</text>
|
||||||
<text x={lcx} y={yes1y+38} textAnchor="middle" fontSize={9} fill="#6B7280" fontStyle="italic">"See you on [date & time]"</text>
|
<text x={lcx} y={yes1y+52} textAnchor="middle" fontSize={9} fill="#6B7280" fontStyle="italic">"See you on [date & time]"</text>
|
||||||
<text x={lcx} y={yes1y+56} textAnchor="middle" fontSize={9} fill="#6B7280" fontStyle="italic">Appointment confirmed ✓</text>
|
<text x={lcx} y={yes1y+70} textAnchor="middle" fontSize={8} fill="#047857">date in reply → Day Check ↘</text>
|
||||||
<line x1={lcx} y1={yes1y+yes1h} x2={lcx} y2={yes2y-2} stroke="#9CA3AF" strokeWidth={1.5} markerEnd="url(#rg2ah)" />
|
<line x1={lcx} y1={yes1y+yes1h} x2={lcx} y2={yes2y-2} stroke="#9CA3AF" strokeWidth={1.5} markerEnd="url(#rg2ah)" />
|
||||||
|
|
||||||
{/* Yes2: Patient thanks → closing */}
|
{/* Yes2: Patient thanks */}
|
||||||
<rect x={lcx-nW/2} y={yes2y} width={nW} height={yes2h} rx={8} fill="#F9FAFB" stroke="#D1D5DB" strokeWidth={1.5} />
|
<rect x={lcx-yesW/2} y={yes2y} width={yesW} height={yes2h} rx={8} fill="#F9FAFB" stroke="#D1D5DB" strokeWidth={1.5} />
|
||||||
<text x={lcx} y={yes2y+20} textAnchor="middle" fontSize={11} fontWeight="600" fill="#374151">Patient: "Thank you / OK"</text>
|
<text x={lcx} y={yes2y+20} textAnchor="middle" fontSize={11} fontWeight="600" fill="#374151">Patient: "Thank you / OK"</text>
|
||||||
<text x={lcx} y={yes2y+38} textAnchor="middle" fontSize={9} fill="#9CA3AF" fontStyle="italic">AI: "Thank you for choosing us!</text>
|
<text x={lcx} y={yes2y+38} textAnchor="middle" fontSize={9} fill="#9CA3AF" fontStyle="italic">AI: "Thank you for choosing us! See you on [date]"</text>
|
||||||
<text x={lcx} y={yes2y+50} textAnchor="middle" fontSize={9} fill="#9CA3AF" fontStyle="italic">See you on [date]"</text>
|
|
||||||
|
{/* Dashed shortcut: YES with date → Day Check */}
|
||||||
|
<line
|
||||||
|
x1={lcx + yesW/2} y1={yes1y + 52}
|
||||||
|
x2={rcx - noW/2 - 2} y2={no2y + 34}
|
||||||
|
stroke="#047857" strokeWidth={1} strokeDasharray="4 3"
|
||||||
|
markerEnd="url(#rg2short)"
|
||||||
|
/>
|
||||||
|
|
||||||
{/* ══ NO / RESCHEDULE BRANCH ══════════════════════════════════════ */}
|
{/* ══ NO / RESCHEDULE BRANCH ══════════════════════════════════════ */}
|
||||||
|
|
||||||
{/* no1: When would you like to reschedule? */}
|
{/* no1: Understandable / ask date (with date-shortcut note) */}
|
||||||
<rect x={rcx-noW/2} y={no1y} width={noW} height={no1h} rx={8} fill="#FFF7ED" stroke="#F97316" strokeWidth={1.5} />
|
<rect x={rcx-noW/2} y={no1y} width={noW} height={no1h} rx={8} fill="#FFF7ED" stroke="#F97316" strokeWidth={1.5} />
|
||||||
<text x={rcx} y={no1y+20} textAnchor="middle" fontSize={11} fontWeight="600" fill="#C2410C">MSG 2: It is understandable!</text>
|
<text x={rcx} y={no1y+18} textAnchor="middle" fontSize={11} fontWeight="600" fill="#C2410C">MSG 2: It is understandable!</text>
|
||||||
<text x={rcx} y={no1y+38} textAnchor="middle" fontSize={11} fontWeight="600" fill="#C2410C">When would you like to reschedule?</text>
|
<text x={rcx} y={no1y+34} textAnchor="middle" fontSize={11} fontWeight="600" fill="#C2410C">When would you like to reschedule?</text>
|
||||||
<text x={rcx} y={no1y+54} textAnchor="middle" fontSize={9} fill="#9CA3AF" fontStyle="italic">Patient replies with a preferred date</text>
|
<text x={rcx} y={no1y+52} textAnchor="middle" fontSize={9} fill="#9CA3AF" fontStyle="italic">Patient replies with a preferred date</text>
|
||||||
|
<text x={rcx} y={no1y+70} textAnchor="middle" fontSize={8} fill="#047857" fontStyle="italic">date in reply → skips to Day Check ↓</text>
|
||||||
<line x1={rcx} y1={no1y+no1h} x2={rcx} y2={no2y-2} stroke="#9CA3AF" strokeWidth={1.5} markerEnd="url(#rg2ah)" />
|
<line x1={rcx} y1={no1y+no1h} x2={rcx} y2={no2y-2} stroke="#9CA3AF" strokeWidth={1.5} markerEnd="url(#rg2ah)" />
|
||||||
|
|
||||||
{/* no2: Date received + day-open check */}
|
{/* no2: Day check */}
|
||||||
<rect x={rcx-noW/2} y={no2y} width={noW} height={no2h} rx={8} fill="#ECFDF5" stroke="#10B981" strokeWidth={1.5} />
|
<rect x={rcx-noW/2} y={no2y} width={noW} height={no2h} rx={8} fill="#ECFDF5" stroke="#10B981" strokeWidth={1.5} />
|
||||||
<text x={rcx} y={no2y+18} textAnchor="middle" fontSize={11} fontWeight="700" fill="#065F46">Check: Is office open on that day?</text>
|
<text x={rcx} y={no2y+18} textAnchor="middle" fontSize={11} fontWeight="700" fill="#065F46">Check: Is office open on that day?</text>
|
||||||
<text x={rcx} y={no2y+34} textAnchor="middle" fontSize={9} fill="#6B7280">(reads Office Hours settings)</text>
|
<text x={rcx} y={no2y+34} textAnchor="middle" fontSize={9} fill="#6B7280">(reads Office Hours settings)</text>
|
||||||
<text x={rcx} y={no2y+52} textAnchor="middle" fontSize={9} fill="#15803D" fontWeight="600">✓ Open → ask what time</text>
|
<text x={rcx} y={no2y+52} textAnchor="middle" fontSize={9} fill="#15803D" fontWeight="600">✓ Open → ask what time</text>
|
||||||
<text x={rcx} y={no2y+68} textAnchor="middle" fontSize={9} fill="#DC2626" fontWeight="600">✗ Closed → "Office closed on [day]. Choose another day?"</text>
|
<text x={rcx} y={no2y+68} textAnchor="middle" fontSize={9} fill="#DC2626" fontWeight="600">✗ Closed → "Office closed on [day]. Choose another?"</text>
|
||||||
{/* fail annotation */}
|
|
||||||
<line x1={rcx+noW/2} y1={no2y+no2h-16} x2={failX-1} y2={no2y+no2h-16} stroke="#EF4444" strokeWidth={1} strokeDasharray="3 2" markerEnd="url(#rg2fail)" />
|
<line x1={rcx+noW/2} y1={no2y+no2h-16} x2={failX-1} y2={no2y+no2h-16} stroke="#EF4444" strokeWidth={1} strokeDasharray="3 2" markerEnd="url(#rg2fail)" />
|
||||||
<rect x={failX} y={no2y+no2h-28} width={failW} height={24} rx={5} fill="#FEF2F2" stroke="#FCA5A5" strokeWidth={1} />
|
<rect x={failX} y={no2y+no2h-28} width={failW} height={24} rx={5} fill="#FEF2F2" stroke="#FCA5A5" strokeWidth={1} />
|
||||||
<text x={failX+failW/2} y={no2y+no2h-12} textAnchor="middle" fontSize={8} fill="#DC2626">↩ loops back to ask date</text>
|
<text x={failX+failW/2} y={no2y+no2h-12} textAnchor="middle" fontSize={8} fill="#DC2626">↩ loops back to ask date</text>
|
||||||
@@ -203,7 +223,7 @@ function LangGraphFlow() {
|
|||||||
<text x={rcx} y={no3y+52} textAnchor="middle" fontSize={9} fill="#9CA3AF" fontStyle="italic">Patient replies with a time (e.g. "1 pm")</text>
|
<text x={rcx} y={no3y+52} textAnchor="middle" fontSize={9} fill="#9CA3AF" fontStyle="italic">Patient replies with a time (e.g. "1 pm")</text>
|
||||||
<line x1={rcx} y1={no3y+no3h} x2={rcx} y2={no4y-2} stroke="#9CA3AF" strokeWidth={1.5} markerEnd="url(#rg2ah)" />
|
<line x1={rcx} y1={no3y+no3h} x2={rcx} y2={no4y-2} stroke="#9CA3AF" strokeWidth={1.5} markerEnd="url(#rg2ah)" />
|
||||||
|
|
||||||
{/* no4: Time received + hours check */}
|
{/* no4: Time check */}
|
||||||
<rect x={rcx-noW/2} y={no4y} width={noW} height={no4h} rx={8} fill="#ECFDF5" stroke="#10B981" strokeWidth={1.5} />
|
<rect x={rcx-noW/2} y={no4y} width={noW} height={no4h} rx={8} fill="#ECFDF5" stroke="#10B981" strokeWidth={1.5} />
|
||||||
<text x={rcx} y={no4y+18} textAnchor="middle" fontSize={11} fontWeight="700" fill="#065F46">Check: Is time within office hours?</text>
|
<text x={rcx} y={no4y+18} textAnchor="middle" fontSize={11} fontWeight="700" fill="#065F46">Check: Is time within office hours?</text>
|
||||||
<text x={rcx} y={no4y+34} textAnchor="middle" fontSize={9} fill="#6B7280">(e.g. not during lunch 12–1pm)</text>
|
<text x={rcx} y={no4y+34} textAnchor="middle" fontSize={9} fill="#6B7280">(e.g. not during lunch 12–1pm)</text>
|
||||||
@@ -244,7 +264,7 @@ function LangGraphFlow() {
|
|||||||
<rect x={rcx-noW/2} y={no8y} width={noW} height={no8h} rx={8}
|
<rect x={rcx-noW/2} y={no8y} width={noW} height={no8h} rx={8}
|
||||||
fill="#F0F9FF" stroke="#0EA5E9" strokeWidth={1.5} strokeDasharray="5 3" />
|
fill="#F0F9FF" stroke="#0EA5E9" strokeWidth={1.5} strokeDasharray="5 3" />
|
||||||
<rect x={rcx-46} y={no8y-11} width={92} height={22} rx={11} fill="#E0F2FE" stroke="#0EA5E9" strokeWidth={1.5} />
|
<rect x={rcx-46} y={no8y-11} width={92} height={22} rx={11} fill="#E0F2FE" stroke="#0EA5E9" strokeWidth={1.5} />
|
||||||
<text x={rcx} y={no8y+3} textAnchor="middle" fontSize={9} fontWeight="700" fill="#0369A1">DB Update</text>
|
<text x={rcx} y={no8y+3} textAnchor="middle" fontSize={9} fontWeight="700" fill="#0369A1">DB Update</text>
|
||||||
<text x={rcx} y={no8y+22} textAnchor="middle" fontSize={11} fontWeight="600" fill="#0369A1">Appointment moved!</text>
|
<text x={rcx} y={no8y+22} textAnchor="middle" fontSize={11} fontWeight="600" fill="#0369A1">Appointment moved!</text>
|
||||||
<text x={rcx} y={no8y+38} textAnchor="middle" fontSize={9} fill="#6B7280" fontStyle="italic">"Appt moved to [date at time].</text>
|
<text x={rcx} y={no8y+38} textAnchor="middle" fontSize={9} fill="#6B7280" fontStyle="italic">"Appt moved to [date at time].</text>
|
||||||
<text x={rcx} y={no8y+52} textAnchor="middle" fontSize={9} fill="#6B7280" fontStyle="italic">Receptionist will confirm tomorrow."</text>
|
<text x={rcx} y={no8y+52} textAnchor="middle" fontSize={9} fill="#6B7280" fontStyle="italic">Receptionist will confirm tomorrow."</text>
|
||||||
@@ -703,6 +723,8 @@ export function AiChatSettingsCard() {
|
|||||||
<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">{"{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>{" "}
|
||||||
<code className="bg-muted px-1 py-0.5 rounded text-xs font-mono">{"{officeAddress}"}</code>{" "}
|
<code className="bg-muted px-1 py-0.5 rounded text-xs font-mono">{"{officeAddress}"}</code>{" "}
|
||||||
|
<code className="bg-muted px-1 py-0.5 rounded text-xs font-mono" title="Office call-in phone number">{"{officePhone}"}</code>{" "}
|
||||||
|
<code className="bg-muted px-1 py-0.5 rounded text-xs font-mono" title="Twilio SMS number patients text back to">{"{twilioPhone}"}</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">{"{appointmentDate}"}</code>{" "}
|
||||||
<code className="bg-muted px-1 py-0.5 rounded text-xs font-mono">{"{appointmentTime}"}</code>
|
<code className="bg-muted px-1 py-0.5 rounded text-xs font-mono">{"{appointmentTime}"}</code>
|
||||||
</p>
|
</p>
|
||||||
@@ -892,7 +914,7 @@ export function AiChatSettingsCard() {
|
|||||||
<Tabs defaultValue="reminder">
|
<Tabs defaultValue="reminder">
|
||||||
<TabsList className="w-full">
|
<TabsList className="w-full">
|
||||||
<TabsTrigger value="reminder" className="flex-1 text-xs">
|
<TabsTrigger value="reminder" className="flex-1 text-xs">
|
||||||
Appointment Reminder Flow
|
Reminder & Reschedule Flow
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="new_patient" className="flex-1 text-xs">
|
<TabsTrigger value="new_patient" className="flex-1 text-xs">
|
||||||
New Patient / After-Hours Flow
|
New Patient / After-Hours Flow
|
||||||
|
|||||||
@@ -166,6 +166,8 @@ export default function AppointmentsPage() {
|
|||||||
const [isSendingReminders, setIsSendingReminders] = useState(false);
|
const [isSendingReminders, setIsSendingReminders] = useState(false);
|
||||||
const [reminderAiFollowUp, setReminderAiFollowUp] = useState(true);
|
const [reminderAiFollowUp, setReminderAiFollowUp] = useState(true);
|
||||||
const [selectedRescheduleColumns, setSelectedRescheduleColumns] = useState<Set<number>>(new Set());
|
const [selectedRescheduleColumns, setSelectedRescheduleColumns] = useState<Set<number>>(new Set());
|
||||||
|
const [isSendingReschedule, setIsSendingReschedule] = useState(false);
|
||||||
|
const [rescheduleAiFollowUp, setRescheduleAiFollowUp] = useState(true);
|
||||||
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>>({});
|
||||||
@@ -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 () => {
|
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(",");
|
||||||
@@ -1458,10 +1481,21 @@ export default function AppointmentsPage() {
|
|||||||
{/* Reschedule for Column section */}
|
{/* Reschedule 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={() => handleSendRescheduleForColumn()}
|
||||||
|
disabled={isLoading || isSendingReschedule || selectedRescheduleColumns.size === 0}
|
||||||
size="sm"
|
size="sm"
|
||||||
>
|
>
|
||||||
Reschedule for Column
|
{isSendingReschedule ? (
|
||||||
|
<>
|
||||||
|
<LoaderCircleIcon className="h-4 w-4 mr-1 animate-spin" />
|
||||||
|
Sending...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<MessageSquare className="h-4 w-4 mr-1" />
|
||||||
|
Reschedule for Column
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
{staffMembers.map((staff, index) => (
|
{staffMembers.map((staff, index) => (
|
||||||
<label
|
<label
|
||||||
@@ -1479,6 +1513,20 @@ 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={rescheduleAiFollowUp}
|
||||||
|
onClick={() => setRescheduleAiFollowUp((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 ${rescheduleAiFollowUp ? "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 ${rescheduleAiFollowUp ? "translate-x-4" : "translate-x-0"}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<span className="text-xs text-gray-600 whitespace-nowrap">AI follow up</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Download Claim PDF for Column section */}
|
{/* Download Claim PDF for Column section */}
|
||||||
|
|||||||
Reference in New Issue
Block a user