// whatsapp.jsx — WhatsApp conversation inbox for clinic staff (redesigned). const { useState: useStateWA, useEffect: useEffectWA, useRef: useRefWA, useCallback: useCallbackWA, useMemo: useMemoWA } = React; function WhatsAppInbox() { const t = window.MSStore.useT(); const store = window.MSStore.useStore(); const [conversations, setConversations] = useStateWA([]); const [activeConvId, setActiveConvId] = useStateWA(null); const [messages, setMessages] = useStateWA([]); const [draft, setDraft] = useStateWA(""); const [sending, setSending] = useStateWA(false); const [loadingConvs, setLoadingConvs] = useStateWA(true); const [loadingMsgs, setLoadingMsgs] = useStateWA(false); const [error, setError] = useStateWA(""); const [searchQuery, setSearchQuery] = useStateWA(""); const [pendingAppts, setPendingAppts] = useStateWA([]); const [showPending, setShowPending] = useStateWA(false); const [deleteConfirmId, setDeleteConfirmId] = useStateWA(null); const [reminderSettings, setReminderSettings] = useStateWA(null); const [showSettings, setShowSettings] = useStateWA(false); const [actionLoading, setActionLoading] = useStateWA(""); const [templates, setTemplates] = useStateWA([]); const [showTemplates, setShowTemplates] = useStateWA(false); const [templateVars, setTemplateVars] = useStateWA({}); const [selectedTemplate, setSelectedTemplate] = useStateWA(null); const [activeTab, setActiveTab] = useStateWA("conversations"); const [bundleInputMode, setBundleInputMode] = useStateWA("upload"); const [bundleFile, setBundleFile] = useStateWA(null); const [bundleManualText, setBundleManualText] = useStateWA(""); const [bundlePreview, setBundlePreview] = useStateWA(null); const [bundleMessage, setBundleMessage] = useStateWA("Hola {name}, "); const [bundleUploading, setBundleUploading] = useStateWA(false); const [bundleSending, setBundleSending] = useStateWA(false); const [bundleConsent, setBundleConsent] = useStateWA(false); const [bundleResults, setBundleResults] = useStateWA(null); const messagesEndRef = useRefWA(null); const pollRef = useRefWA(null); const activeConv = conversations.find(c => c.id === activeConvId) || null; // Load conversations const loadConversations = useCallbackWA(async () => { try { const r = await window.MSApi.apiFetch("/api/whatsapp/conversations"); setConversations((r && r.conversations) || []); } catch (e) { if (!e.message?.includes("workspace")) setError(e?.message || "Failed to load conversations"); } finally { setLoadingConvs(false); } }, []); // Load pending appointments const loadPending = useCallbackWA(async () => { try { const r = await window.MSApi.apiFetch("/api/whatsapp/pending-appointments"); setPendingAppts((r && r.appointments) || []); } catch (_) {} }, []); useEffectWA(() => { loadConversations(); loadPending(); const iv = setInterval(() => { loadConversations(); loadPending(); }, 15000); return () => clearInterval(iv); }, [loadConversations, loadPending]); // Load messages for active conversation const loadMessages = useCallbackWA(async (convId) => { if (!convId) return; setLoadingMsgs(true); try { const r = await window.MSApi.apiFetch(`/api/whatsapp/conversations/${convId}/messages`); setMessages((r && r.messages) || []); } catch (e) { setError(e?.message || "Failed to load messages"); } finally { setLoadingMsgs(false); } }, []); useEffectWA(() => { if (activeConvId) { loadMessages(activeConvId); clearInterval(pollRef.current); pollRef.current = setInterval(() => loadMessages(activeConvId), 5000); } return () => clearInterval(pollRef.current); }, [activeConvId, loadMessages]); // Scroll to bottom when messages change useEffectWA(() => { const node = messagesEndRef.current; if (node) node.scrollIntoView({ behavior: "smooth" }); }, [messages]); // Send message async function handleSend() { if (!draft.trim() || !activeConv || sending) return; setSending(true); setError(""); try { await window.MSApi.apiFetch("/api/whatsapp/send", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ patient_id: activeConv.patient_id, body: draft.trim() }), }); setDraft(""); await loadMessages(activeConvId); await loadConversations(); } catch (e) { setError(e?.message || "Failed to send message"); } finally { setSending(false); } } // Delete conversation async function handleDelete(convId) { try { await window.MSApi.apiFetch(`/api/whatsapp/conversations/${convId}`, { method: "DELETE" }); setConversations(prev => prev.filter(c => c.id !== convId)); if (activeConvId === convId) { setActiveConvId(null); setMessages([]); } setDeleteConfirmId(null); } catch (e) { setError(e?.message || "Failed to delete"); } } // Accept/reject appointment async function handleAppointmentAction(apptId, action) { setActionLoading(apptId); try { await window.MSApi.apiFetch(`/api/whatsapp/appointments/${apptId}/${action}`, { method: "POST" }); setPendingAppts(prev => prev.filter(a => a.id !== apptId)); await loadMessages(activeConvId); await loadPending(); // Refresh calendar and notifications so accepted/rejected appointments appear if (store.loadAppointments) store.loadAppointments(); if (store.loadNotifications) store.loadNotifications(); } catch (e) { setError(e?.message || `Failed to ${action}`); } finally { setActionLoading(""); } } // Load templates useEffectWA(() => { (async () => { try { const r = await window.MSApi.apiFetch("/api/whatsapp/templates"); setTemplates((r && r.templates) || []); } catch (_) {} })(); }, []); function selectTemplate(tpl) { if (tpl.id === "custom") { setSelectedTemplate(null); setShowTemplates(false); return; } setSelectedTemplate(tpl); const vars = {}; if (activeConv && activeConv.patient_name) vars.patient_name = activeConv.patient_name; setTemplateVars(vars); } function insertTemplate() { if (!selectedTemplate) return; const lang = window.MSi18n.getLang() === "es" ? "es" : "en"; let body = lang === "es" ? selectedTemplate.body_es : selectedTemplate.body_en; for (const [k, v] of Object.entries(templateVars)) { body = body.replace(new RegExp("\\{" + k + "\\}", "g"), v); } body = body.replace(/\{[a-z_]+\}/g, "___"); setDraft(body); setSelectedTemplate(null); setShowTemplates(false); setTemplateVars({}); } // Load reminder settings useEffectWA(() => { (async () => { try { const r = await window.MSApi.apiFetch("/api/whatsapp/reminders/settings"); setReminderSettings(r && r.settings); } catch (_) {} })(); }, []); async function saveReminderSettings(updates) { try { const r = await window.MSApi.apiFetch("/api/whatsapp/reminders/settings", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ...reminderSettings, ...updates }), }); setReminderSettings(r && r.settings); } catch (e) { setError(e?.message || "Failed to save settings"); } } function formatTime(iso) { if (!iso) return ""; try { const d = new Date(iso); const lang = window.MSi18n.getLang() === "es" ? "es-ES" : "en-US"; const now = new Date(); if (d.toDateString() === now.toDateString()) { return d.toLocaleTimeString(lang, { hour: "2-digit", minute: "2-digit" }); } return d.toLocaleDateString(lang, { month: "short", day: "numeric" }) + " " + d.toLocaleTimeString(lang, { hour: "2-digit", minute: "2-digit" }); } catch (_) { return iso; } } function formatDateTime(iso) { if (!iso) return ""; try { const d = new Date(iso); const lang = window.MSi18n.getLang() === "es" ? "es-ES" : "en-US"; return d.toLocaleDateString(lang, { weekday: "short", month: "short", day: "numeric" }) + " " + d.toLocaleTimeString(lang, { hour: "2-digit", minute: "2-digit" }); } catch (_) { return iso; } } function statusBadge(status) { const map = { delivered: "✓✓", read: "✓✓", sent: "✓", queued: "⏳", failed: "✗", undelivered: "✗" }; const cls = status === "failed" || status === "undelivered" ? "is-error" : status === "read" ? "is-read" : ""; return React.createElement("span", { className: "ms-wa-status " + cls }, map[status] || ""); } // Filter conversations const filteredConvs = useMemoWA(() => { if (!searchQuery.trim()) return conversations; const q = searchQuery.toLowerCase(); return conversations.filter(c => (c.patient_name || "").toLowerCase().includes(q) || (c.from_number || "").includes(q) ); }, [conversations, searchQuery]); // Quick replies const quickReplies = [ { key: "quickConfirm", icon: Icon.Check }, { key: "quickDirections", icon: Icon.Pin }, { key: "quickPreVisit", icon: Icon.Clipboard }, ]; const bundleValidRows = ((bundlePreview && bundlePreview.rows) || []).filter(r => r.valid); const bundleInvalidRows = ((bundlePreview && bundlePreview.rows) || []).filter(r => !r.valid); function normalizeBundlePhone(raw) { let value = String(raw || "").trim().replace(/^whatsapp:/i, ""); if (!value) return ""; if (value.startsWith("00")) value = "+" + value.slice(2); const plus = value.startsWith("+"); const digits = value.replace(/\D/g, ""); if (digits.length < 8 || digits.length > 15) return ""; return plus ? "+" + digits : ""; } function buildBundlePreview(rows) { const seen = new Set(); const normalized = rows.map((item, idx) => { const phone = item.phone || ""; const normalizedPhone = normalizeBundlePhone(phone); const errors = []; let duplicate = false; if (!normalizedPhone) errors.push(t("wa.invalidPhone")); if (normalizedPhone && seen.has(normalizedPhone)) { duplicate = true; errors.push(t("wa.duplicatePhone")); } if (normalizedPhone && !duplicate) seen.add(normalizedPhone); return { row: idx + 1, name: item.name || "", phone, normalized_phone: normalizedPhone, language: item.language || "", fields: item.fields || {}, valid: errors.length === 0, duplicate, errors, }; }); const valid = normalized.filter(r => r.valid).length; const duplicates = normalized.filter(r => r.duplicate).length; setBundlePreview({ rows: normalized, summary: { total: normalized.length, valid, invalid: normalized.length - valid, duplicates, }, }); } function handleManualPreview() { const lines = bundleManualText.split(/\r?\n/).map(v => v.trim()).filter(Boolean); const rows = lines.map(line => { const parts = line.split(/[,;\t]/).map(v => v.trim()).filter(Boolean); if (parts.length >= 2) { return { name: parts[0], phone: parts[1], language: parts[2] || "", fields: {} }; } return { name: "", phone: parts[0] || line, language: "", fields: {} }; }); buildBundlePreview(rows); setBundleResults(null); } function renderBundleMessage(row) { const fields = Object.assign({}, row.fields || {}, { name: row.name || "", patient_name: row.name || "", phone: row.normalized_phone || row.phone || "", language: row.language || "", }); return (bundleMessage || "").replace(/\{([a-zA-Z0-9_]+)\}/g, (_, key) => fields[key] == null ? "" : fields[key]); } async function handleBundlePreview() { if (!bundleFile || bundleUploading) return; setBundleUploading(true); setError(""); setBundleResults(null); try { const fd = new FormData(); fd.append("file", bundleFile); const r = await window.MSApi.apiFetch("/api/whatsapp/bundle/preview", { method: "POST", body: fd }); setBundlePreview(r); } catch (e) { setError(e?.message || "Failed to upload bundle list"); } finally { setBundleUploading(false); } } async function handleBundleSend() { if (!bundleValidRows.length || !bundleMessage.trim() || !bundleConsent || bundleSending) return; setBundleSending(true); setError(""); setBundleResults(null); try { const recipients = bundleValidRows.map(r => ({ name: r.name || "", phone: r.normalized_phone, language: r.language || "", fields: r.fields || {}, })); const r = await window.MSApi.apiFetch("/api/whatsapp/bundle/send", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ body: bundleMessage.trim(), recipients, consent_confirmed: bundleConsent }), }); setBundleResults(r); await loadConversations(); } catch (e) { setError(e?.message || "Failed to send bundle messages"); } finally { setBundleSending(false); } } return (

{t("wa.title")}

{t("wa.subtitle")}
{pendingAppts.length > 0 && ( )}
{error && (
{error}
)}
{activeTab === "bundle" && (

{t("wa.bundleUpload")}

{t("wa.bundleUploadHelp")}

{bundleInputMode === "upload" ? ( <>
{ setBundleFile(e.target.files && e.target.files[0]); setBundlePreview(null); setBundleResults(null); }}/>
{t("wa.bundleFormat")}
) : ( <>