feat: AI SMS reminder flow with two-message intro, smart reschedule with availability checks
- Reminder flow: send AI self-introduction as message 1 (Twilio REST API), intent response as message 2 (TwiML) so intro always arrives first - LangGraph reminder graph: classify yes/no/other from patient reply; 'no' now asks 'When would you like to reschedule?' directly - Reschedule flow: new asked_reschedule_datetime stage replaces multi-step ASAP/next-week flow - Date-only reply (e.g. '5/18'): ask for time separately, then confirm - Date+time reply (e.g. '5/18 at 10am'): go straight to confirmation - new asked_reschedule_time_for_date and asked_reschedule_confirm_datetime stages - Date/time parsing: regex handles M/D and am/pm formats first; falls back to Gemini for natural language - Day-level office hours check: if requested day is closed (e.g. Sunday), reply 'Our office is closed on [date]. Choose another day?' - Time-level office hours check: if requested time is outside working hours (e.g. 12pm during lunch), reply with actual hours (e.g. '9:00 am – 12:00 pm and 1:00 pm – 5:00 pm') - Slot availability check: verifies no conflicting appointment for same staff member - After appointment confirmed: patient thank-you reply triggers warm closing with upcoming appointment time - Schedule page: office hours summary bar above grid showing today's configured hours with link to settings Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -17,7 +17,10 @@ export type ConversationStage =
|
|||||||
| "asked_reschedule_preference"
|
| "asked_reschedule_preference"
|
||||||
| "asked_reschedule_asap"
|
| "asked_reschedule_asap"
|
||||||
| "asked_reschedule_next_week"
|
| "asked_reschedule_next_week"
|
||||||
| "asked_reschedule_time";
|
| "asked_reschedule_time"
|
||||||
|
| "asked_reschedule_datetime"
|
||||||
|
| "asked_reschedule_time_for_date"
|
||||||
|
| "asked_reschedule_confirm_datetime";
|
||||||
|
|
||||||
// ── Conversation stage + AI handoff per patient (DB-persisted) ────────────────
|
// ── Conversation stage + AI handoff per patient (DB-persisted) ────────────────
|
||||||
|
|
||||||
@@ -75,8 +78,9 @@ export async function startRescheduleConversation(userId: number, patientId: num
|
|||||||
// ── Pending reschedule (in-memory — seconds-lived within a single exchange) ───
|
// ── Pending reschedule (in-memory — seconds-lived within a single exchange) ───
|
||||||
|
|
||||||
interface PendingReschedule {
|
interface PendingReschedule {
|
||||||
newDate: Date;
|
newDate: Date;
|
||||||
dayLabel: string;
|
dayLabel: string;
|
||||||
|
startTime?: string; // "HH:MM" — set when full datetime was parsed together
|
||||||
}
|
}
|
||||||
|
|
||||||
const pendingRescheduleStore = new Map<string, PendingReschedule>();
|
const pendingRescheduleStore = new Map<string, PendingReschedule>();
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ function classifyNode(state: GraphStateType) {
|
|||||||
const text = state.message.toLowerCase().trim();
|
const text = state.message.toLowerCase().trim();
|
||||||
|
|
||||||
const yesPatterns = /\b(yes|yeah|yep|yup|sure|ok|okay|confirmed|confirm|will be there|sounds good|see you|great|perfect|absolutely|definitely|sí|si|claro|por supuesto|confirmo|de acuerdo|seguro|estaré|sim|confirmado|com certeza|好的|确认|可以|好|明白|نعم|حسنا|موافق|wi|dakò|oke)\b/;
|
const yesPatterns = /\b(yes|yeah|yep|yup|sure|ok|okay|confirmed|confirm|will be there|sounds good|see you|great|perfect|absolutely|definitely|sí|si|claro|por supuesto|confirmo|de acuerdo|seguro|estaré|sim|confirmado|com certeza|好的|确认|可以|好|明白|نعم|حسنا|موافق|wi|dakò|oke)\b/;
|
||||||
const noPatterns = /\b(no|nope|can't|cannot|won't|not available|unavailable|cancel|reschedule|busy|sorry|unable|not coming|not going|no puedo|no podré|cancelar|reprogramar|ocupado|lo siento|não posso|não vou|reagendar|占线|无法|取消|لا|لا أستطيع|إلغاء|pa kapab|pa ka)\b/;
|
const noPatterns = /\b(no|nope|can't|cannot|won't|not available|unavailable|cancel|reschedule|busy|sorry|unable|not coming|not going|no puedo|no podré|cancelar|reprogramar|ocupado|lo siento|não posso|não vou|reagendar|占线|无法|取消|لا|لا أستطيع|إلغاء|pa kapab|pa ka|can not make|cannot make|not make|make it)\b/;
|
||||||
|
|
||||||
if (yesPatterns.test(text)) return { intent: "yes" };
|
if (yesPatterns.test(text)) return { intent: "yes" };
|
||||||
if (noPatterns.test(text)) return { intent: "no" };
|
if (noPatterns.test(text)) return { intent: "no" };
|
||||||
@@ -51,13 +51,13 @@ function buildConfirmFallback(lang: string, apptDatetime: string): string {
|
|||||||
// ── Reschedule fallbacks ──────────────────────────────────────────────────────
|
// ── Reschedule fallbacks ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
const RESCHEDULE_FALLBACKS: Record<string, string> = {
|
const RESCHEDULE_FALLBACKS: Record<string, string> = {
|
||||||
English: "It is understandable! Would you like to reschedule?",
|
English: "It is understandable! When would you like to reschedule?",
|
||||||
Spanish: "¡Lo entendemos! ¿Le gustaría reprogramar su cita?",
|
Spanish: "¡Lo entendemos! ¿Cuándo le gustaría reprogramar su cita?",
|
||||||
Portuguese: "Entendemos! Gostaria de reagendar a sua consulta?",
|
Portuguese: "Entendemos! Quando gostaria de reagendar a sua consulta?",
|
||||||
Mandarin: "我们理解!您想重新安排预约吗?",
|
Mandarin: "我们理解!您想什么时候重新安排预约?",
|
||||||
Cantonese: "我們理解!您想重新安排預約嗎?",
|
Cantonese: "我們理解!您想幾時重新安排預約?",
|
||||||
Arabic: "نتفهم ذلك! هل تود إعادة جدولة موعدك؟",
|
Arabic: "نتفهم ذلك! متى تود إعادة جدولة موعدك؟",
|
||||||
"Haitian Creole": "Nou konprann! Èske ou ta renmen repwograme randevou ou?",
|
"Haitian Creole": "Nou konprann! Ki lè ou ta renmen repwograme randevou ou?",
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── New-appointment fallbacks (other intent, appointment keywords detected) ───
|
// ── New-appointment fallbacks (other intent, appointment keywords detected) ───
|
||||||
@@ -101,7 +101,7 @@ async function confirmNode(state: GraphStateType, config: any) {
|
|||||||
{
|
{
|
||||||
role: "system",
|
role: "system",
|
||||||
content:
|
content:
|
||||||
`You are a friendly dental office assistant. Write a short, warm SMS reply (1-2 sentences max) thanking the patient for confirming their appointment and reminding them of the date and time.${apptClause} You MUST reply in ${lang} regardless of the language the patient used. Do not add any formatting or extra text.`,
|
`You are a friendly dental office assistant. Write a short, warm SMS reply (1-2 sentences max) thanking the patient for confirming their appointment and reminding them of the date and time.${apptClause} You MUST reply in ${lang}. Do not add any formatting or extra text.`,
|
||||||
},
|
},
|
||||||
{ role: "user", content: `Patient replied: "${state.message}"` },
|
{ role: "user", content: `Patient replied: "${state.message}"` },
|
||||||
]);
|
]);
|
||||||
@@ -113,7 +113,7 @@ async function confirmNode(state: GraphStateType, config: any) {
|
|||||||
|
|
||||||
async function rescheduleNode(state: GraphStateType, config: any) {
|
async function rescheduleNode(state: GraphStateType, config: any) {
|
||||||
const apiKey: string | undefined = config?.configurable?.apiKey;
|
const apiKey: string | undefined = config?.configurable?.apiKey;
|
||||||
const lang = state.language || "English";
|
const lang = state.language || "English";
|
||||||
const fallback = state.rescheduleGreeting || (RESCHEDULE_FALLBACKS[lang] ?? RESCHEDULE_FALLBACKS["English"]!);
|
const fallback = state.rescheduleGreeting || (RESCHEDULE_FALLBACKS[lang] ?? RESCHEDULE_FALLBACKS["English"]!);
|
||||||
|
|
||||||
if (!apiKey) return { reply: fallback };
|
if (!apiKey) return { reply: fallback };
|
||||||
@@ -124,7 +124,7 @@ async function rescheduleNode(state: GraphStateType, config: any) {
|
|||||||
{
|
{
|
||||||
role: "system",
|
role: "system",
|
||||||
content:
|
content:
|
||||||
`You are a friendly dental office assistant. The patient cannot make their appointment. Write a short, empathetic SMS reply (1 sentence max) that says it is understandable and asks if they would like to reschedule. You MUST reply in ${lang} regardless of the language the patient used. Do not add any formatting or extra text.`,
|
`You are a friendly dental office assistant. The patient cannot make their appointment. Write a short, empathetic SMS reply (1-2 sentences max) acknowledging they can't make it and asking when they would like to reschedule. You MUST reply in ${lang}. Do not add any formatting or extra text.`,
|
||||||
},
|
},
|
||||||
{ role: "user", content: `Patient replied: "${state.message}"` },
|
{ role: "user", content: `Patient replied: "${state.message}"` },
|
||||||
]);
|
]);
|
||||||
@@ -149,7 +149,7 @@ async function otherNode(state: GraphStateType, config: any) {
|
|||||||
const response = await llm.invoke([
|
const response = await llm.invoke([
|
||||||
{
|
{
|
||||||
role: "system",
|
role: "system",
|
||||||
content: `You are a friendly dental office AI assistant named Lisa. The patient wants to schedule an appointment. Ask them in ${lang} whether they are a new patient or an existing patient. One sentence, no formatting.`,
|
content: `You are a friendly dental office AI assistant. The patient wants to schedule an appointment. Ask them in ${lang} whether they are a new patient or an existing patient. One sentence, no formatting.`,
|
||||||
},
|
},
|
||||||
{ role: "user", content: `Patient said: "${state.message}"` },
|
{ role: "user", content: `Patient said: "${state.message}"` },
|
||||||
]);
|
]);
|
||||||
@@ -166,7 +166,7 @@ async function otherNode(state: GraphStateType, config: any) {
|
|||||||
const response = await llm.invoke([
|
const response = await llm.invoke([
|
||||||
{
|
{
|
||||||
role: "system",
|
role: "system",
|
||||||
content: `You are a friendly dental office AI assistant named Lisa. Respond helpfully to the patient's message in ${lang}. Keep it to 1-2 sentences, no formatting. For non-dental questions, let them know our office staff can assist.`,
|
content: `You are a friendly dental office AI assistant. Respond helpfully to the patient's message in ${lang}. Keep it to 1-2 sentences, no formatting. For non-dental questions, let them know our office staff can assist.`,
|
||||||
},
|
},
|
||||||
{ role: "user", content: `Patient said: "${state.message}"` },
|
{ role: "user", content: `Patient said: "${state.message}"` },
|
||||||
]);
|
]);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -266,12 +266,39 @@ router.post("/webhook/sms", async (req: Request, res: Response): Promise<any> =>
|
|||||||
return res.send(twimlReply(text));
|
return res.send(twimlReply(text));
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Stage: reminder_initial → send reminder greeting ─────────────────
|
// ── Stage: reminder_initial → two messages: 1) AI intro, 2) intent response ──
|
||||||
if (stage === "reminder_initial") {
|
if (stage === "reminder_initial") {
|
||||||
const rawGreeting = chatTemplates.reminderGreeting ||
|
const rawGreeting = chatTemplates.reminderGreeting ||
|
||||||
`Hi! My name is Lisa, the dedicated AI assistant at {officeName}. I can confirm or reschedule your appointment and answer general questions 24/7. I will reply your message at any time you need.`;
|
`Hi! My name is Lisa, the dedicated AI assistant at {officeName}. I can confirm or reschedule your appointment and answer general questions 24/7.`;
|
||||||
|
const introText = applyOfficeName(rawGreeting, officeName);
|
||||||
|
|
||||||
return reply(applyOfficeName(rawGreeting, officeName), "greeted");
|
// Use Google AI (LangGraph) to read the patient's reply and classify yes/no
|
||||||
|
const apptDatetime = await getAppointmentDatetime(patient.id);
|
||||||
|
const { reply: intentReply, intent } = await runReminderGraph(
|
||||||
|
Body, aiSettings.apiKey, language, apptDatetime,
|
||||||
|
chatTemplates.rescheduleGreeting, chatTemplates.generalFallback
|
||||||
|
);
|
||||||
|
|
||||||
|
if (intentReply) {
|
||||||
|
let nextStage: ConversationStage;
|
||||||
|
if (intent === "no") nextStage = "asked_reschedule_datetime";
|
||||||
|
else if (intent === "wants_appointment") nextStage = "asked_new_or_existing";
|
||||||
|
else nextStage = "done";
|
||||||
|
|
||||||
|
// Send message 1 (AI intro) via REST API — queued FIRST in Twilio so it arrives first
|
||||||
|
const twilioSettings = await storage.getTwilioSettings(patient.userId);
|
||||||
|
if (twilioSettings) {
|
||||||
|
const client = twilio(twilioSettings.accountSid, twilioSettings.authToken);
|
||||||
|
await client.messages.create({ body: introText, from: twilioSettings.phoneNumber, to: From });
|
||||||
|
await saveOutbound(patient.id, introText);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send message 2 (yes/no response) via TwiML — queued SECOND
|
||||||
|
return reply(intentReply, nextStage);
|
||||||
|
}
|
||||||
|
|
||||||
|
// No clear intent detected — send only the intro and wait for next reply
|
||||||
|
return reply(introText, "greeted");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Stage: greeted → classify yes/no for appointment reminder ────────
|
// ── Stage: greeted → classify yes/no for appointment reminder ────────
|
||||||
@@ -283,7 +310,7 @@ router.post("/webhook/sms", async (req: Request, res: Response): Promise<any> =>
|
|||||||
);
|
);
|
||||||
if (aiReply) {
|
if (aiReply) {
|
||||||
let nextStage: ConversationStage;
|
let nextStage: ConversationStage;
|
||||||
if (intent === "no") nextStage = "asked_reschedule_confirm";
|
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";
|
||||||
return reply(aiReply, nextStage);
|
return reply(aiReply, nextStage);
|
||||||
@@ -292,9 +319,10 @@ router.post("/webhook/sms", async (req: Request, res: Response): Promise<any> =>
|
|||||||
|
|
||||||
// ── Rescheduling flow stages ───────────────────────────────────────────
|
// ── Rescheduling flow stages ───────────────────────────────────────────
|
||||||
const rescheduleStages: ConversationStage[] = [
|
const rescheduleStages: ConversationStage[] = [
|
||||||
"asked_reschedule_confirm", "asked_reschedule_preference",
|
"asked_reschedule_confirm", "asked_reschedule_preference",
|
||||||
"asked_reschedule_asap", "asked_reschedule_next_week",
|
"asked_reschedule_asap", "asked_reschedule_next_week",
|
||||||
"asked_reschedule_time",
|
"asked_reschedule_time", "asked_reschedule_datetime",
|
||||||
|
"asked_reschedule_time_for_date", "asked_reschedule_confirm_datetime",
|
||||||
];
|
];
|
||||||
if (rescheduleStages.includes(stage)) {
|
if (rescheduleStages.includes(stage)) {
|
||||||
const { reply: aiReply, nextStage } = await runRescheduleStep(
|
const { reply: aiReply, nextStage } = await runRescheduleStep(
|
||||||
@@ -412,6 +440,56 @@ router.post("/webhook/sms", async (req: Request, res: Response): Promise<any> =>
|
|||||||
return reply(aiReply, nextStage);
|
return reply(aiReply, nextStage);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Stage: done → closing thank-you reply ────────────────────────────
|
||||||
|
// When the patient sends a thank-you / acknowledgement after the conversation
|
||||||
|
// is complete, reply warmly with their upcoming appointment time.
|
||||||
|
if (stage === "done") {
|
||||||
|
const isThanks = /\b(thank|thanks|thank you|ty|ok|okay|great|perfect|sounds good|got it|understood|alright|appreciate|wonderful|excellent|awesome|cool|nice|good)\b/i.test(Body);
|
||||||
|
if (isThanks) {
|
||||||
|
const apptDatetime = await getAppointmentDatetime(patient.id);
|
||||||
|
const CLOSING: Record<string, string> = {
|
||||||
|
English: apptDatetime
|
||||||
|
? `Thank you for choosing our office! We look forward to seeing you on ${apptDatetime}.`
|
||||||
|
: `Thank you for choosing our office! We look forward to seeing you soon.`,
|
||||||
|
Spanish: apptDatetime
|
||||||
|
? `¡Gracias por elegirnos! Le esperamos el ${apptDatetime}.`
|
||||||
|
: `¡Gracias por elegirnos! Le esperamos pronto.`,
|
||||||
|
Portuguese: apptDatetime
|
||||||
|
? `Obrigado por nos escolher! Aguardamos sua visita em ${apptDatetime}.`
|
||||||
|
: `Obrigado por nos escolher! Aguardamos sua visita em breve.`,
|
||||||
|
Mandarin: apptDatetime
|
||||||
|
? `感谢您选择我们!期待在 ${apptDatetime} 见到您。`
|
||||||
|
: `感谢您选择我们!期待很快见到您。`,
|
||||||
|
Cantonese: apptDatetime
|
||||||
|
? `感謝您選擇我們!期待在 ${apptDatetime} 見到您。`
|
||||||
|
: `感謝您選擇我們!期待很快見到您。`,
|
||||||
|
Arabic: apptDatetime
|
||||||
|
? `شكراً لاختيارك عيادتنا! نتطلع إلى رؤيتك في ${apptDatetime}.`
|
||||||
|
: `شكراً لاختيارك عيادتنا! نتطلع إلى رؤيتك قريباً.`,
|
||||||
|
"Haitian Creole": apptDatetime
|
||||||
|
? `Mèsi dèske ou chwazi nou! N'ap tann ou ${apptDatetime}.`
|
||||||
|
: `Mèsi dèske ou chwazi nou! N'ap tann ou byento.`,
|
||||||
|
};
|
||||||
|
const fallback = CLOSING[language] ?? CLOSING["English"]!;
|
||||||
|
if (aiSettings?.apiKey && apptDatetime) {
|
||||||
|
try {
|
||||||
|
const { ChatGoogleGenerativeAI } = await import("@langchain/google-genai");
|
||||||
|
const llm = new ChatGoogleGenerativeAI({ model: "gemini-1.5-flash", apiKey: aiSettings.apiKey });
|
||||||
|
const res = await llm.invoke([
|
||||||
|
{
|
||||||
|
role: "system",
|
||||||
|
content: `You are a friendly dental office AI assistant. The patient just said "${Body}" after completing a conversation. Reply warmly in ${language}, thanking them for choosing the office and reminding them of their upcoming appointment on ${apptDatetime}. 1-2 sentences, no formatting.`,
|
||||||
|
},
|
||||||
|
{ role: "user", content: Body },
|
||||||
|
]);
|
||||||
|
const aiMsg = String(res.content).trim();
|
||||||
|
if (aiMsg) return reply(aiMsg, "done");
|
||||||
|
} catch { /* fall through to fallback */ }
|
||||||
|
}
|
||||||
|
return reply(fallback, "done");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Stage: initial (no active conversation) ───────────────────────────
|
// ── Stage: initial (no active conversation) ───────────────────────────
|
||||||
// Check after-hours: if enabled and currently outside office hours → start new-patient flow
|
// Check after-hours: if enabled and currently outside office hours → start new-patient flow
|
||||||
if (stage === "initial" || stage === "done") {
|
if (stage === "initial" || stage === "done") {
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ import {
|
|||||||
Stethoscope,
|
Stethoscope,
|
||||||
Download,
|
Download,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
|
Clock,
|
||||||
|
ExternalLink,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { Calendar } from "@/components/ui/calendar";
|
import { Calendar } from "@/components/ui/calendar";
|
||||||
@@ -1681,6 +1683,53 @@ export default function AppointmentsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Office Hours Summary */}
|
||||||
|
{(() => {
|
||||||
|
const dayNames = ["sunday","monday","tuesday","wednesday","thursday","friday","saturday"] as const;
|
||||||
|
const dayName = dayNames[selectedDate.getDay()]!;
|
||||||
|
const fmt = (t: string) => {
|
||||||
|
const [hh, mm] = t.split(":").map(Number);
|
||||||
|
const period = (hh ?? 0) >= 12 ? "PM" : "AM";
|
||||||
|
const h12 = (hh ?? 0) > 12 ? (hh ?? 0) - 12 : (hh ?? 0) === 0 ? 12 : (hh ?? 0);
|
||||||
|
return `${h12}:${String(mm ?? 0).padStart(2,"0")} ${period}`;
|
||||||
|
};
|
||||||
|
const doctorHours = officeHours?.doctors?.[dayName];
|
||||||
|
const hygHours = officeHours?.hygienists?.[dayName];
|
||||||
|
const isOverride = officeHours?.overrideDates?.includes(selectedDate.toLocaleDateString("en-CA"));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="px-4 pb-3 flex items-center gap-4 flex-wrap text-xs text-gray-500 border-b">
|
||||||
|
<div className="flex items-center gap-1.5 font-medium text-gray-700">
|
||||||
|
<Clock className="h-3.5 w-3.5 text-teal-600" />
|
||||||
|
<span>Office Hours</span>
|
||||||
|
<button onClick={() => setLocation("/settings/officehours")} className="ml-1 text-teal-600 hover:text-teal-700" title="Edit office hours">
|
||||||
|
<ExternalLink className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{!officeHours ? (
|
||||||
|
<span className="italic text-gray-400">Not configured — <button onClick={() => setLocation("/settings/officehours")} className="text-teal-600 underline">set up office hours</button></span>
|
||||||
|
) : isOverride ? (
|
||||||
|
<span className="text-teal-600 font-medium">Override active — all slots open today</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span>
|
||||||
|
<span className="font-medium text-gray-600">Doctors (A–C):</span>{" "}
|
||||||
|
{doctorHours?.enabled
|
||||||
|
? `${fmt(doctorHours.amStart)}–${fmt(doctorHours.amEnd)}, ${fmt(doctorHours.pmStart)}–${fmt(doctorHours.pmEnd)}`
|
||||||
|
: <span className="text-gray-400">Closed</span>}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<span className="font-medium text-gray-600">Hygienists (D–F):</span>{" "}
|
||||||
|
{hygHours?.enabled
|
||||||
|
? `${fmt(hygHours.amStart)}–${fmt(hygHours.amEnd)}, ${fmt(hygHours.pmStart)}–${fmt(hygHours.pmEnd)}`
|
||||||
|
: <span className="text-gray-400">Closed</span>}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
{/* Schedule Grid with Drag and Drop */}
|
{/* Schedule Grid with Drag and Drop */}
|
||||||
<DndProvider backend={HTML5Backend}>
|
<DndProvider backend={HTML5Backend}>
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
|
|||||||
Reference in New Issue
Block a user