| |
| |
| |
| |
|
|
| (function () { |
| let selectedInteractionId = null; |
| let activeItem = null; |
| let currentTab = "waiting"; |
| let pendingItems = []; |
| let completedItems = []; |
| let completedLoaded = false; |
| |
| |
| |
| let lastPreviewText = null; |
| let lastPreviewTranslation = null; |
| |
| let completedLanguageFilter = "all"; |
| let completedSortAsc = false; |
|
|
| const statusBanner = document.getElementById("status-banner"); |
| const pendingList = document.getElementById("pending-list"); |
| const emptyPending = document.getElementById("empty-pending"); |
| const emptyPendingTitle = document.getElementById("empty-pending-title"); |
| const emptyPendingBody = document.getElementById("empty-pending-body"); |
| const queueHeading = document.getElementById("queue-heading"); |
| const queueSearchInput = document.getElementById("queue-search"); |
| const completedControls = document.getElementById("completed-controls"); |
| const sortToggleBtn = document.getElementById("sort-toggle-btn"); |
| const sortLabel = document.getElementById("sort-label"); |
| const sortIconAsc = document.getElementById("sort-icon-asc"); |
| const sortIconDesc = document.getElementById("sort-icon-desc"); |
| const backToListBtn = document.getElementById("back-to-list"); |
| const backToListReadonlyBtn = document.getElementById("back-to-list-readonly"); |
| const submitResponseBtn = document.getElementById("submit-response"); |
| const previewBtn = document.getElementById("preview-response-btn"); |
| const previewBlock = document.getElementById("preview-block"); |
| const previewTextBubble = document.getElementById("preview-text-bubble"); |
| const responseTextInput = document.getElementById("response-text-input"); |
| const patientHistoryCard = document.getElementById("patient-history-card"); |
| const patientHistoryList = document.getElementById("patient-history-list"); |
| const composeArea = document.getElementById("compose-area"); |
| const readonlyArea = document.getElementById("readonly-area"); |
| const respondProviderBubble = document.getElementById("respond-provider-bubble"); |
| const respondProviderReplyText = document.getElementById("respond-provider-reply-text"); |
| const toast = document.getElementById("toast"); |
|
|
| |
| const previewAudioEl = document.getElementById("preview-audio"); |
| const previewPlayBtn = document.getElementById("preview-audio-play-btn"); |
| const previewReplayBtn = document.getElementById("preview-audio-replay-btn"); |
| const previewSeek = document.getElementById("preview-audio-seek"); |
| const previewCurrentTimeEl = document.getElementById("preview-audio-current-time"); |
| const previewDurationEl = document.getElementById("preview-audio-duration"); |
| const previewIconPlay = previewPlayBtn.querySelector(".icon-play"); |
| const previewIconPause = previewPlayBtn.querySelector(".icon-pause"); |
|
|
| function showStep(stepName) { |
| document.querySelectorAll(".step").forEach(function (section) { |
| section.classList.toggle("active", section.dataset.step === stepName); |
| }); |
| } |
|
|
| function showError(message) { |
| statusBanner.innerHTML = '<div class="status-banner error">' + escapeHtml(message) + "</div>"; |
| } |
|
|
| function clearBanner() { |
| statusBanner.innerHTML = ""; |
| } |
|
|
| function escapeHtml(text) { |
| const div = document.createElement("div"); |
| div.textContent = text == null ? "" : String(text); |
| return div.innerHTML; |
| } |
|
|
| function showToast(message) { |
| toast.textContent = message; |
| toast.classList.add("visible"); |
| setTimeout(function () { toast.classList.remove("visible"); }, 2200); |
| } |
|
|
| function setButtonLoading(button, isLoading, loadingText) { |
| const spinner = button.querySelector(".btn-spinner"); |
| const icon = button.querySelector(".btn-icon"); |
| const label = button.querySelector(".btn-label"); |
| button.disabled = isLoading; |
| if (spinner) spinner.style.display = isLoading ? "inline-block" : "none"; |
| if (icon) icon.style.display = isLoading ? "none" : "inline-flex"; |
| if (label && loadingText) label.textContent = isLoading ? loadingText : label.dataset.defaultText; |
| } |
|
|
| document.querySelectorAll(".btn-label").forEach(function (el) { |
| el.dataset.defaultText = el.textContent; |
| }); |
|
|
| function capitalize(text) { |
| return text ? text.charAt(0).toUpperCase() + text.slice(1) : text; |
| } |
|
|
| |
|
|
| function getTabItems() { |
| if (currentTab === "waiting") { |
| return pendingItems.filter(function (item) { |
| return !activeItem || item.id !== activeItem.id; |
| }); |
| } |
| if (currentTab === "active") { |
| return activeItem ? [activeItem] : []; |
| } |
| |
| let items = completedItems; |
| if (completedLanguageFilter !== "all") { |
| items = items.filter(function (item) { |
| return (item.detected_language || "").toLowerCase() === completedLanguageFilter; |
| }); |
| } |
| if (completedSortAsc) { |
| items = items.slice().sort(function (a, b) { |
| return a.timestamp < b.timestamp ? -1 : 1; |
| }); |
| } |
| return items; |
| } |
|
|
| function matchesQuery(item, query) { |
| if (!query) return true; |
| const haystack = ( |
| (item.detected_language || "") + " " + |
| (item.translated_text || "") + " " + |
| (item.input_text || "") |
| ).toLowerCase(); |
| return haystack.indexOf(query) !== -1; |
| } |
|
|
| function buildQueueRow(item, index) { |
| const row = document.createElement("button"); |
| row.type = "button"; |
| row.className = "queue-card list-item-in"; |
| row.style.animationDelay = (index * 50) + "ms"; |
| const timeLabel = window.formatRelativeTime ? window.formatRelativeTime(item.timestamp) : ""; |
| const answered = Boolean(item.translated_response); |
| const isActive = Boolean(activeItem) && activeItem.id === item.id; |
| const badgeClass = answered ? "answered" : "waiting"; |
| const badgeLabel = answered ? "Answered" : (isActive ? "In progress" : "#" + item.id); |
| |
| |
| const ariaLabel = [ |
| capitalize(item.detected_language), |
| timeLabel, |
| answered ? "Answered" : "Waiting for reply", |
| item.translated_text |
| ].filter(Boolean).join(", "); |
| row.setAttribute("aria-label", ariaLabel); |
| row.innerHTML = |
| '<span class="queue-avatar" aria-hidden="true">' + escapeHtml(item.detected_language.charAt(0).toUpperCase()) + '</span>' + |
| '<span class="queue-body">' + |
| '<span class="queue-top-row">' + |
| '<span class="queue-lang">' + escapeHtml(item.detected_language) + '</span>' + |
| '<span class="queue-top-right">' + |
| '<span class="timestamp">' + escapeHtml(timeLabel) + '</span>' + |
| '<span class="status-badge ' + badgeClass + '" aria-hidden="true">' + escapeHtml(badgeLabel) + '</span>' + |
| '</span>' + |
| '</span>' + |
| '<span class="queue-preview">' + escapeHtml(item.translated_text) + '</span>' + |
| '</span>'; |
| row.addEventListener("click", function () { |
| openInteraction(item); |
| }); |
| return row; |
| } |
|
|
| function renderQueueList() { |
| const query = queueSearchInput.value.trim().toLowerCase(); |
| const items = getTabItems().filter(function (item) { |
| return matchesQuery(item, query); |
| }); |
|
|
| |
| |
| |
| completedControls.style.display = currentTab === "completed" ? "block" : "none"; |
|
|
| pendingList.innerHTML = ""; |
| const isEmpty = items.length === 0; |
| emptyPending.style.display = isEmpty ? "block" : "none"; |
|
|
| if (isEmpty) { |
| const hasFilter = query || (currentTab === "completed" && completedLanguageFilter !== "all"); |
| if (hasFilter) { |
| emptyPendingTitle.textContent = "No matches"; |
| emptyPendingBody.textContent = "Try a different search or filter."; |
| } else if (currentTab === "waiting") { |
| emptyPendingTitle.textContent = "All caught up"; |
| emptyPendingBody.textContent = "Nothing waiting right now."; |
| } else if (currentTab === "active") { |
| emptyPendingTitle.textContent = "Nothing active"; |
| emptyPendingBody.textContent = "Open a message from the Waiting tab to start on it."; |
| } else { |
| emptyPendingTitle.textContent = "No completed consultations"; |
| emptyPendingBody.textContent = "Completed replies will appear here."; |
| } |
| return; |
| } |
|
|
| items.forEach(function (item, index) { |
| pendingList.appendChild(buildQueueRow(item, index)); |
| }); |
| } |
|
|
| function updateTabCounts() { |
| const waitingCount = pendingItems.filter(function (item) { |
| return !activeItem || item.id !== activeItem.id; |
| }).length; |
| document.getElementById("tab-count-waiting").textContent = waitingCount; |
| document.getElementById("tab-count-active").textContent = activeItem ? "1" : "0"; |
| if (completedLoaded) { |
| document.getElementById("tab-count-completed").textContent = completedItems.length; |
| } |
| } |
|
|
| async function loadPending() { |
| if (currentTab === "waiting") { |
| pendingList.innerHTML = '<div class="skeleton-row"></div><div class="skeleton-row"></div><div class="skeleton-row"></div>'; |
| } |
|
|
| try { |
| const response = await fetch("/pending-interactions"); |
| const interactions = await response.json(); |
| pendingItems = interactions; |
|
|
| const newHeadingText = interactions.length === 0 |
| ? "All caught up" |
| : interactions.length === 1 |
| ? "1 message waiting for you" |
| : interactions.length + " messages waiting for you"; |
| queueHeading.textContent = newHeadingText; |
| queueHeading.style.animation = "none"; |
| void queueHeading.offsetWidth; |
| queueHeading.style.animation = ""; |
|
|
| renderQueueList(); |
| updateTabCounts(); |
| } catch (error) { |
| showError("Couldn't load pending messages. Check your connection."); |
| } |
| } |
|
|
| async function loadCompleted() { |
| pendingList.innerHTML = '<div class="skeleton-row"></div><div class="skeleton-row"></div>'; |
| try { |
| const response = await fetch("/completed-interactions"); |
| completedItems = await response.json(); |
| completedLoaded = true; |
| renderQueueList(); |
| updateTabCounts(); |
| } catch (error) { |
| showError("Couldn't load completed messages. Check your connection."); |
| } |
| } |
|
|
| document.querySelectorAll(".queue-tab").forEach(function (tabBtn) { |
| tabBtn.addEventListener("click", function () { |
| currentTab = tabBtn.dataset.tab; |
| document.querySelectorAll(".queue-tab").forEach(function (t) { |
| t.classList.toggle("active", t === tabBtn); |
| t.setAttribute("aria-selected", t === tabBtn ? "true" : "false"); |
| }); |
| if (currentTab === "completed" && !completedLoaded) { |
| loadCompleted(); |
| } else { |
| renderQueueList(); |
| } |
| }); |
| }); |
|
|
| queueSearchInput.addEventListener("input", function () { |
| renderQueueList(); |
| }); |
|
|
| |
| document.querySelectorAll("#language-filter-chips .filter-chip").forEach(function (c) { |
| c.setAttribute("aria-pressed", c.classList.contains("active") ? "true" : "false"); |
| }); |
|
|
| document.getElementById("language-filter-chips").addEventListener("click", function (event) { |
| const chip = event.target.closest(".filter-chip"); |
| if (!chip) return; |
| completedLanguageFilter = chip.dataset.filter; |
| document.querySelectorAll("#language-filter-chips .filter-chip").forEach(function (c) { |
| const active = c === chip; |
| c.classList.toggle("active", active); |
| c.setAttribute("aria-pressed", active ? "true" : "false"); |
| }); |
| renderQueueList(); |
| }); |
|
|
| |
| sortToggleBtn.addEventListener("click", function () { |
| completedSortAsc = !completedSortAsc; |
| sortLabel.textContent = completedSortAsc ? "Oldest first" : "Newest first"; |
| sortIconAsc.style.display = completedSortAsc ? "inline" : "none"; |
| sortIconDesc.style.display = completedSortAsc ? "none" : "inline"; |
| renderQueueList(); |
| }); |
|
|
| |
|
|
| async function loadPatientHistoryForWorkspace(item) { |
| patientHistoryCard.style.display = "none"; |
| try { |
| const response = await fetch("/patient-history/" + item.user_id); |
| const items = await response.json(); |
| const others = items.filter(function (h) { return h.id !== item.id; }); |
| if (!others.length) return; |
| patientHistoryList.innerHTML = ""; |
| others.forEach(function (h, index) { |
| const row = document.createElement("div"); |
| row.className = "result-block history-row-static list-item-in"; |
| row.style.animationDelay = (index * 40) + "ms"; |
| const answered = Boolean(h.translated_response); |
| const badgeClass = answered ? "answered" : "waiting"; |
| const badgeLabel = answered ? "Answered" : "Waiting for reply"; |
| const timeLabel = window.formatRelativeTime ? window.formatRelativeTime(h.timestamp) : ""; |
| row.innerHTML = |
| '<div class="result-label">#' + escapeHtml(h.id) + |
| ' <span class="status-badge ' + badgeClass + '">' + badgeLabel + "</span>" + |
| ' <span class="timestamp">' + escapeHtml(timeLabel) + "</span></div>" + |
| '<div class="result-text">' + escapeHtml(h.translated_text || h.input_text) + "</div>"; |
| patientHistoryList.appendChild(row); |
| }); |
| patientHistoryCard.style.display = "block"; |
| } catch (error) { |
| |
| |
| } |
| } |
|
|
| function invalidatePreview() { |
| previewBlock.style.display = "none"; |
| previewAudioEl.pause(); |
| lastPreviewText = null; |
| lastPreviewTranslation = null; |
| } |
|
|
| function openInteraction(item) { |
| clearBanner(); |
| selectedInteractionId = item.id; |
| activeItem = item; |
|
|
| document.getElementById("detail-placeholder").style.display = "none"; |
| const detailContent = document.getElementById("detail-content"); |
| detailContent.style.display = "block"; |
| detailContent.classList.remove("list-item-in"); |
| void detailContent.offsetWidth; |
| detailContent.classList.add("list-item-in"); |
|
|
| document.getElementById("respond-interaction-id").textContent = item.id; |
| document.getElementById("respond-language-label").textContent = |
| "Patient (" + capitalize(item.detected_language) + ")"; |
| document.getElementById("respond-translated-text").textContent = item.translated_text; |
| document.getElementById("respond-nlu-summary").textContent = item.nlu_summary || "No summary available."; |
| document.getElementById("respond-original-language").textContent = capitalize(item.detected_language); |
| document.getElementById("respond-original-text").textContent = item.input_text; |
|
|
| const timeLabel = window.formatRelativeTime ? window.formatRelativeTime(item.timestamp) : item.timestamp; |
| document.getElementById("respond-meta-line").textContent = "Submitted " + timeLabel; |
|
|
| const answered = Boolean(item.translated_response); |
| const statusBadge = document.getElementById("respond-status-badge"); |
| statusBadge.className = "status-badge " + (answered ? "answered" : "waiting"); |
| statusBadge.textContent = answered ? "Answered" : "Waiting for your reply"; |
|
|
| |
| |
| |
| if (answered) { |
| respondProviderBubble.style.display = "block"; |
| respondProviderReplyText.textContent = item.translated_response; |
| composeArea.style.display = "none"; |
| readonlyArea.style.display = "block"; |
| } else { |
| respondProviderBubble.style.display = "none"; |
| composeArea.style.display = "block"; |
| readonlyArea.style.display = "none"; |
| } |
|
|
| responseTextInput.value = ""; |
| invalidatePreview(); |
| loadPatientHistoryForWorkspace(item); |
|
|
| showStep("respond"); |
| renderQueueList(); |
| updateTabCounts(); |
| } |
|
|
| document.getElementById("quick-reply-chips").addEventListener("click", function (event) { |
| const chip = event.target.closest(".chip"); |
| if (!chip) return; |
| responseTextInput.value = responseTextInput.value |
| ? responseTextInput.value + " " + chip.dataset.text |
| : chip.dataset.text; |
| responseTextInput.focus(); |
| invalidatePreview(); |
| }); |
|
|
| responseTextInput.addEventListener("input", function () { |
| invalidatePreview(); |
| }); |
|
|
| function goBackToList() { |
| document.getElementById("detail-placeholder").style.display = "block"; |
| document.getElementById("detail-content").style.display = "none"; |
| showStep("list"); |
| loadPending(); |
| } |
|
|
| backToListBtn.addEventListener("click", goBackToList); |
| backToListReadonlyBtn.addEventListener("click", goBackToList); |
|
|
| |
|
|
| function formatAudioTime(totalSeconds) { |
| if (!isFinite(totalSeconds) || totalSeconds < 0) return "0:00"; |
| const minutes = Math.floor(totalSeconds / 60); |
| const seconds = Math.floor(totalSeconds % 60); |
| return minutes + ":" + String(seconds).padStart(2, "0"); |
| } |
|
|
| function setPreviewAudioSource(url) { |
| previewAudioEl.src = url; |
| previewSeek.value = 0; |
| previewCurrentTimeEl.textContent = "0:00"; |
| previewDurationEl.textContent = "0:00"; |
| previewIconPlay.style.display = "inline"; |
| previewIconPause.style.display = "none"; |
| } |
|
|
| previewPlayBtn.addEventListener("click", function () { |
| if (previewAudioEl.paused) { previewAudioEl.play(); } else { previewAudioEl.pause(); } |
| }); |
|
|
| previewReplayBtn.addEventListener("click", function () { |
| previewAudioEl.currentTime = 0; |
| previewAudioEl.play(); |
| }); |
|
|
| previewAudioEl.addEventListener("play", function () { |
| previewIconPlay.style.display = "none"; |
| previewIconPause.style.display = "inline"; |
| }); |
|
|
| previewAudioEl.addEventListener("pause", function () { |
| previewIconPlay.style.display = "inline"; |
| previewIconPause.style.display = "none"; |
| }); |
|
|
| previewAudioEl.addEventListener("ended", function () { |
| previewIconPlay.style.display = "inline"; |
| previewIconPause.style.display = "none"; |
| }); |
|
|
| previewAudioEl.addEventListener("loadedmetadata", function () { |
| previewSeek.max = previewAudioEl.duration || 0; |
| previewDurationEl.textContent = formatAudioTime(previewAudioEl.duration); |
| }); |
|
|
| previewAudioEl.addEventListener("timeupdate", function () { |
| previewSeek.value = previewAudioEl.currentTime; |
| previewCurrentTimeEl.textContent = formatAudioTime(previewAudioEl.currentTime); |
| }); |
|
|
| previewSeek.addEventListener("input", function () { |
| previewAudioEl.currentTime = Number(previewSeek.value); |
| }); |
|
|
| previewBtn.addEventListener("click", async function () { |
| const responseText = responseTextInput.value.trim(); |
| if (!responseText) { showError("Type a reply before previewing."); return; } |
| clearBanner(); |
| setButtonLoading(previewBtn, true, "Generating preview..."); |
| const formData = new FormData(); |
| formData.append("interaction_id", selectedInteractionId); |
| formData.append("response_text", responseText); |
| try { |
| const response = await fetch("/preview-response", { method: "POST", body: formData }); |
| const data = await response.json(); |
| if (!response.ok) { showError(data.error || "Couldn't generate a preview."); return; } |
| previewTextBubble.innerHTML = '<span class="chat-label">Preview</span>' + escapeHtml(data.translated_response); |
| setPreviewAudioSource(data.audio_url); |
| previewBlock.style.display = "block"; |
| lastPreviewText = responseText; |
| lastPreviewTranslation = data.translated_response; |
| } catch (error) { |
| showError("Couldn't reach the server. Check your connection and try again."); |
| } finally { |
| setButtonLoading(previewBtn, false, "Generating preview..."); |
| } |
| }); |
|
|
| submitResponseBtn.addEventListener("click", async function () { |
| clearBanner(); |
| const responseText = responseTextInput.value.trim(); |
| if (!responseText) { showError("Type a reply before sending."); return; } |
| setButtonLoading(submitResponseBtn, true, "Sending..."); |
| const formData = new FormData(); |
| formData.append("interaction_id", selectedInteractionId); |
| formData.append("response_text", responseText); |
| |
| |
| if (lastPreviewText === responseText && lastPreviewTranslation) { |
| formData.append("translated_response", lastPreviewTranslation); |
| formData.append("reuse_audio", "true"); |
| } |
| try { |
| const response = await fetch("/provider-response", { method: "POST", body: formData }); |
| const data = await response.json(); |
| if (!response.ok) { showError(data.error || "Something went wrong, please try again."); return; } |
| activeItem = null; |
| completedLoaded = false; |
| goBackToList(); |
| showToast("Reply sent to patient"); |
| } catch (error) { |
| showError("Couldn't reach the server. Check your connection and try again."); |
| } finally { |
| setButtonLoading(submitResponseBtn, false, "Sending..."); |
| } |
| }); |
|
|
| loadPending(); |
| })(); |
|
|