Upload 2 files
Browse files- static/js/patient.js +58 -12
- static/js/provider.js +106 -76
static/js/patient.js
CHANGED
|
@@ -34,7 +34,6 @@
|
|
| 34 |
const openHistoryBtn = document.getElementById("open-history-btn");
|
| 35 |
const historyBackBtn = document.getElementById("history-back-btn");
|
| 36 |
const historyFullList = document.getElementById("history-full-list");
|
| 37 |
-
const historyEmptyText = document.getElementById("history-empty-text");
|
| 38 |
const historySearchInput = document.getElementById("history-search");
|
| 39 |
const chatThread = document.getElementById("chat-thread");
|
| 40 |
const waitingCard = document.getElementById("waiting-card");
|
|
@@ -411,6 +410,8 @@
|
|
| 411 |
|
| 412 |
// Past messages for this session.
|
| 413 |
let cachedHistoryItems = [];
|
|
|
|
|
|
|
| 414 |
|
| 415 |
function buildHistoryRow(item, index) {
|
| 416 |
const row = document.createElement("div");
|
|
@@ -465,34 +466,62 @@
|
|
| 465 |
cachedHistoryItems = [];
|
| 466 |
}
|
| 467 |
|
| 468 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 469 |
}
|
| 470 |
|
| 471 |
// Dedicated history view: renders from the cache above (instant), so
|
| 472 |
// opening it never flashes empty while a fresh fetch is in flight.
|
|
|
|
| 473 |
function renderFullHistoryList(filterText) {
|
| 474 |
const query = (filterText || "").trim().toLowerCase();
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 480 |
|
| 481 |
historyFullList.innerHTML = "";
|
|
|
|
|
|
|
|
|
|
| 482 |
|
| 483 |
if (!cachedHistoryItems.length) {
|
| 484 |
-
|
| 485 |
-
|
|
|
|
| 486 |
return;
|
| 487 |
}
|
| 488 |
|
| 489 |
if (!filtered.length) {
|
| 490 |
-
|
| 491 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 492 |
return;
|
| 493 |
}
|
| 494 |
|
| 495 |
-
|
| 496 |
filtered.forEach(function (item, index) {
|
| 497 |
historyFullList.appendChild(buildHistoryRow(item, index));
|
| 498 |
});
|
|
@@ -546,11 +575,17 @@
|
|
| 546 |
|
| 547 |
openHistoryBtn.addEventListener("click", function () {
|
| 548 |
clearBanner();
|
|
|
|
| 549 |
showStep("history");
|
| 550 |
loadHistory();
|
|
|
|
|
|
|
|
|
|
|
|
|
| 551 |
});
|
| 552 |
|
| 553 |
historyBackBtn.addEventListener("click", function () {
|
|
|
|
| 554 |
showStep(1);
|
| 555 |
});
|
| 556 |
|
|
@@ -558,6 +593,17 @@
|
|
| 558 |
renderFullHistoryList(historySearchInput.value);
|
| 559 |
});
|
| 560 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 561 |
// Start a new conversation: ends the current session server-side and
|
| 562 |
// resets the page back to language selection.
|
| 563 |
newConversationBtn.addEventListener("click", async function () {
|
|
|
|
| 34 |
const openHistoryBtn = document.getElementById("open-history-btn");
|
| 35 |
const historyBackBtn = document.getElementById("history-back-btn");
|
| 36 |
const historyFullList = document.getElementById("history-full-list");
|
|
|
|
| 37 |
const historySearchInput = document.getElementById("history-search");
|
| 38 |
const chatThread = document.getElementById("chat-thread");
|
| 39 |
const waitingCard = document.getElementById("waiting-card");
|
|
|
|
| 410 |
|
| 411 |
// Past messages for this session.
|
| 412 |
let cachedHistoryItems = [];
|
| 413 |
+
let historyStatusFilter = "all"; // "all" | "waiting" | "answered"
|
| 414 |
+
let historyScrollY = 0; // remembered scroll offset for Back navigation
|
| 415 |
|
| 416 |
function buildHistoryRow(item, index) {
|
| 417 |
const row = document.createElement("div");
|
|
|
|
| 466 |
cachedHistoryItems = [];
|
| 467 |
}
|
| 468 |
|
| 469 |
+
// If the full history view is currently visible (step === "history"),
|
| 470 |
+
// re-render it in place so status badges update immediately when a
|
| 471 |
+
// reply arrives via polling, without requiring the patient to close
|
| 472 |
+
// and reopen the view.
|
| 473 |
+
const historyStep = document.querySelector(".step[data-step='history']");
|
| 474 |
+
if (historyStep && historyStep.classList.contains("active")) {
|
| 475 |
+
renderFullHistoryList(historySearchInput.value);
|
| 476 |
+
} else {
|
| 477 |
+
renderFullHistoryList(historySearchInput.value);
|
| 478 |
+
}
|
| 479 |
}
|
| 480 |
|
| 481 |
// Dedicated history view: renders from the cache above (instant), so
|
| 482 |
// opening it never flashes empty while a fresh fetch is in flight.
|
| 483 |
+
// Applies both the active status filter and the text search query.
|
| 484 |
function renderFullHistoryList(filterText) {
|
| 485 |
const query = (filterText || "").trim().toLowerCase();
|
| 486 |
+
|
| 487 |
+
let filtered = cachedHistoryItems;
|
| 488 |
+
|
| 489 |
+
if (historyStatusFilter === "waiting") {
|
| 490 |
+
filtered = filtered.filter(function (item) { return !item.translated_response; });
|
| 491 |
+
} else if (historyStatusFilter === "answered") {
|
| 492 |
+
filtered = filtered.filter(function (item) { return Boolean(item.translated_response); });
|
| 493 |
+
}
|
| 494 |
+
|
| 495 |
+
if (query) {
|
| 496 |
+
filtered = filtered.filter(function (item) {
|
| 497 |
+
return (item.input_text || "").toLowerCase().indexOf(query) !== -1;
|
| 498 |
+
});
|
| 499 |
+
}
|
| 500 |
|
| 501 |
historyFullList.innerHTML = "";
|
| 502 |
+
const emptyState = document.getElementById("history-empty-state");
|
| 503 |
+
const emptyTitle = document.getElementById("history-empty-title");
|
| 504 |
+
const emptyBody = document.getElementById("history-empty-body");
|
| 505 |
|
| 506 |
if (!cachedHistoryItems.length) {
|
| 507 |
+
emptyTitle.textContent = "No messages yet";
|
| 508 |
+
emptyBody.textContent = "Your consultations will appear here once you've sent one.";
|
| 509 |
+
emptyState.style.display = "block";
|
| 510 |
return;
|
| 511 |
}
|
| 512 |
|
| 513 |
if (!filtered.length) {
|
| 514 |
+
emptyTitle.textContent = query ? "No matches" : "Nothing here";
|
| 515 |
+
emptyBody.textContent = query
|
| 516 |
+
? "No messages match that search."
|
| 517 |
+
: historyStatusFilter === "waiting"
|
| 518 |
+
? "No consultations are still waiting for a reply."
|
| 519 |
+
: "No answered consultations yet.";
|
| 520 |
+
emptyState.style.display = "block";
|
| 521 |
return;
|
| 522 |
}
|
| 523 |
|
| 524 |
+
emptyState.style.display = "none";
|
| 525 |
filtered.forEach(function (item, index) {
|
| 526 |
historyFullList.appendChild(buildHistoryRow(item, index));
|
| 527 |
});
|
|
|
|
| 575 |
|
| 576 |
openHistoryBtn.addEventListener("click", function () {
|
| 577 |
clearBanner();
|
| 578 |
+
historySearchInput.value = "";
|
| 579 |
showStep("history");
|
| 580 |
loadHistory();
|
| 581 |
+
// Restore scroll position from the last visit to this view
|
| 582 |
+
requestAnimationFrame(function () {
|
| 583 |
+
window.scrollTo(0, historyScrollY);
|
| 584 |
+
});
|
| 585 |
});
|
| 586 |
|
| 587 |
historyBackBtn.addEventListener("click", function () {
|
| 588 |
+
historyScrollY = window.scrollY;
|
| 589 |
showStep(1);
|
| 590 |
});
|
| 591 |
|
|
|
|
| 593 |
renderFullHistoryList(historySearchInput.value);
|
| 594 |
});
|
| 595 |
|
| 596 |
+
// Status filter chips
|
| 597 |
+
document.getElementById("history-filter-chips").addEventListener("click", function (event) {
|
| 598 |
+
const chip = event.target.closest(".filter-chip");
|
| 599 |
+
if (!chip) return;
|
| 600 |
+
historyStatusFilter = chip.dataset.filter;
|
| 601 |
+
document.querySelectorAll("#history-filter-chips .filter-chip").forEach(function (c) {
|
| 602 |
+
c.classList.toggle("active", c === chip);
|
| 603 |
+
});
|
| 604 |
+
renderFullHistoryList(historySearchInput.value);
|
| 605 |
+
});
|
| 606 |
+
|
| 607 |
// Start a new conversation: ends the current session server-side and
|
| 608 |
// resets the page back to language selection.
|
| 609 |
newConversationBtn.addEventListener("click", async function () {
|
static/js/provider.js
CHANGED
|
@@ -5,11 +5,6 @@
|
|
| 5 |
|
| 6 |
(function () {
|
| 7 |
let selectedInteractionId = null;
|
| 8 |
-
// The single interaction currently open in the workspace, if any. This
|
| 9 |
-
// is the only honest meaning "Active" can have here: there's no
|
| 10 |
-
// multi-provider login or claim system, so it's a client-side slot,
|
| 11 |
-
// not a backend state. It survives "Back" (so a provider can return to
|
| 12 |
-
// an in-progress reply) and is only cleared once that reply is sent.
|
| 13 |
let activeItem = null;
|
| 14 |
let currentTab = "waiting";
|
| 15 |
let pendingItems = [];
|
|
@@ -20,13 +15,24 @@
|
|
| 20 |
// from, so Send only reuses it when nothing has been edited since.
|
| 21 |
let lastPreviewText = null;
|
| 22 |
let lastPreviewTranslation = null;
|
|
|
|
|
|
|
|
|
|
| 23 |
|
| 24 |
const statusBanner = document.getElementById("status-banner");
|
| 25 |
const pendingList = document.getElementById("pending-list");
|
| 26 |
const emptyPending = document.getElementById("empty-pending");
|
|
|
|
|
|
|
| 27 |
const queueHeading = document.getElementById("queue-heading");
|
| 28 |
const queueSearchInput = document.getElementById("queue-search");
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
const backToListBtn = document.getElementById("back-to-list");
|
|
|
|
| 30 |
const submitResponseBtn = document.getElementById("submit-response");
|
| 31 |
const previewBtn = document.getElementById("preview-response-btn");
|
| 32 |
const previewBlock = document.getElementById("preview-block");
|
|
@@ -34,11 +40,13 @@
|
|
| 34 |
const responseTextInput = document.getElementById("response-text-input");
|
| 35 |
const patientHistoryCard = document.getElementById("patient-history-card");
|
| 36 |
const patientHistoryList = document.getElementById("patient-history-list");
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
const toast = document.getElementById("toast");
|
| 38 |
|
| 39 |
-
// Preview audio player
|
| 40 |
-
// pattern: play/pause/replay/seek/time, no download - a preview isn't
|
| 41 |
-
// the final artifact worth keeping).
|
| 42 |
const previewAudioEl = document.getElementById("preview-audio");
|
| 43 |
const previewPlayBtn = document.getElementById("preview-audio-play-btn");
|
| 44 |
const previewReplayBtn = document.getElementById("preview-audio-replay-btn");
|
|
@@ -71,9 +79,7 @@
|
|
| 71 |
function showToast(message) {
|
| 72 |
toast.textContent = message;
|
| 73 |
toast.classList.add("visible");
|
| 74 |
-
setTimeout(function () {
|
| 75 |
-
toast.classList.remove("visible");
|
| 76 |
-
}, 2200);
|
| 77 |
}
|
| 78 |
|
| 79 |
function setButtonLoading(button, isLoading, loadingText) {
|
|
@@ -105,7 +111,19 @@
|
|
| 105 |
if (currentTab === "active") {
|
| 106 |
return activeItem ? [activeItem] : [];
|
| 107 |
}
|
| 108 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
}
|
| 110 |
|
| 111 |
function matchesQuery(item, query) {
|
|
@@ -118,13 +136,6 @@
|
|
| 118 |
return haystack.indexOf(query) !== -1;
|
| 119 |
}
|
| 120 |
|
| 121 |
-
function emptyStateText(query) {
|
| 122 |
-
if (query) return "No messages match your search.";
|
| 123 |
-
if (currentTab === "waiting") return "Nothing waiting right now.";
|
| 124 |
-
if (currentTab === "active") return "Nothing active right now, open a message below to start on it.";
|
| 125 |
-
return "No completed consultations yet.";
|
| 126 |
-
}
|
| 127 |
-
|
| 128 |
function buildQueueRow(item, index) {
|
| 129 |
const row = document.createElement("button");
|
| 130 |
row.type = "button";
|
|
@@ -159,10 +170,32 @@
|
|
| 159 |
return matchesQuery(item, query);
|
| 160 |
});
|
| 161 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 162 |
pendingList.innerHTML = "";
|
| 163 |
const isEmpty = items.length === 0;
|
| 164 |
emptyPending.style.display = isEmpty ? "block" : "none";
|
| 165 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 166 |
|
| 167 |
items.forEach(function (item, index) {
|
| 168 |
pendingList.appendChild(buildQueueRow(item, index));
|
|
@@ -196,8 +229,6 @@
|
|
| 196 |
? "1 message waiting for you"
|
| 197 |
: interactions.length + " messages waiting for you";
|
| 198 |
queueHeading.textContent = newHeadingText;
|
| 199 |
-
// Restart the count-change animation, changing textContent alone
|
| 200 |
-
// does not replay a CSS animation on its own.
|
| 201 |
queueHeading.style.animation = "none";
|
| 202 |
void queueHeading.offsetWidth;
|
| 203 |
queueHeading.style.animation = "";
|
|
@@ -241,19 +272,35 @@
|
|
| 241 |
renderQueueList();
|
| 242 |
});
|
| 243 |
|
| 244 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 245 |
|
| 246 |
async function loadPatientHistoryForWorkspace(item) {
|
| 247 |
patientHistoryCard.style.display = "none";
|
| 248 |
try {
|
| 249 |
const response = await fetch("/patient-history/" + item.user_id);
|
| 250 |
const items = await response.json();
|
| 251 |
-
const others = items.filter(function (h) {
|
| 252 |
-
return h.id !== item.id;
|
| 253 |
-
});
|
| 254 |
-
|
| 255 |
if (!others.length) return;
|
| 256 |
-
|
| 257 |
patientHistoryList.innerHTML = "";
|
| 258 |
others.forEach(function (h, index) {
|
| 259 |
const row = document.createElement("div");
|
|
@@ -272,8 +319,8 @@
|
|
| 272 |
});
|
| 273 |
patientHistoryCard.style.display = "block";
|
| 274 |
} catch (error) {
|
| 275 |
-
// Supplementary context only
|
| 276 |
-
//
|
| 277 |
}
|
| 278 |
}
|
| 279 |
|
|
@@ -307,11 +354,25 @@
|
|
| 307 |
const timeLabel = window.formatRelativeTime ? window.formatRelativeTime(item.timestamp) : item.timestamp;
|
| 308 |
document.getElementById("respond-meta-line").textContent = "Submitted " + timeLabel;
|
| 309 |
|
| 310 |
-
const statusBadge = document.getElementById("respond-status-badge");
|
| 311 |
const answered = Boolean(item.translated_response);
|
|
|
|
| 312 |
statusBadge.className = "status-badge " + (answered ? "answered" : "waiting");
|
| 313 |
statusBadge.textContent = answered ? "Answered" : "Waiting for your reply";
|
| 314 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 315 |
responseTextInput.value = "";
|
| 316 |
invalidatePreview();
|
| 317 |
loadPatientHistoryForWorkspace(item);
|
|
@@ -335,14 +396,17 @@
|
|
| 335 |
invalidatePreview();
|
| 336 |
});
|
| 337 |
|
| 338 |
-
|
| 339 |
document.getElementById("detail-placeholder").style.display = "block";
|
| 340 |
document.getElementById("detail-content").style.display = "none";
|
| 341 |
showStep("list");
|
| 342 |
loadPending();
|
| 343 |
-
}
|
| 344 |
|
| 345 |
-
|
|
|
|
|
|
|
|
|
|
| 346 |
|
| 347 |
function formatAudioTime(totalSeconds) {
|
| 348 |
if (!isFinite(totalSeconds) || totalSeconds < 0) return "0:00";
|
|
@@ -361,11 +425,7 @@
|
|
| 361 |
}
|
| 362 |
|
| 363 |
previewPlayBtn.addEventListener("click", function () {
|
| 364 |
-
if (previewAudioEl.paused) {
|
| 365 |
-
previewAudioEl.play();
|
| 366 |
-
} else {
|
| 367 |
-
previewAudioEl.pause();
|
| 368 |
-
}
|
| 369 |
});
|
| 370 |
|
| 371 |
previewReplayBtn.addEventListener("click", function () {
|
|
@@ -404,27 +464,16 @@
|
|
| 404 |
|
| 405 |
previewBtn.addEventListener("click", async function () {
|
| 406 |
const responseText = responseTextInput.value.trim();
|
| 407 |
-
if (!responseText) {
|
| 408 |
-
showError("Type a reply before previewing.");
|
| 409 |
-
return;
|
| 410 |
-
}
|
| 411 |
-
|
| 412 |
clearBanner();
|
| 413 |
setButtonLoading(previewBtn, true, "Generating preview...");
|
| 414 |
-
|
| 415 |
const formData = new FormData();
|
| 416 |
formData.append("interaction_id", selectedInteractionId);
|
| 417 |
formData.append("response_text", responseText);
|
| 418 |
-
|
| 419 |
try {
|
| 420 |
const response = await fetch("/preview-response", { method: "POST", body: formData });
|
| 421 |
const data = await response.json();
|
| 422 |
-
|
| 423 |
-
if (!response.ok) {
|
| 424 |
-
showError(data.error || "Couldn't generate a preview.");
|
| 425 |
-
return;
|
| 426 |
-
}
|
| 427 |
-
|
| 428 |
previewTextBubble.innerHTML = '<span class="chat-label">Preview</span>' + escapeHtml(data.translated_response);
|
| 429 |
setPreviewAudioSource(data.audio_url);
|
| 430 |
previewBlock.style.display = "block";
|
|
@@ -440,43 +489,24 @@
|
|
| 440 |
submitResponseBtn.addEventListener("click", async function () {
|
| 441 |
clearBanner();
|
| 442 |
const responseText = responseTextInput.value.trim();
|
| 443 |
-
|
| 444 |
-
if (!responseText) {
|
| 445 |
-
showError("Type a reply before sending.");
|
| 446 |
-
return;
|
| 447 |
-
}
|
| 448 |
-
|
| 449 |
setButtonLoading(submitResponseBtn, true, "Sending...");
|
| 450 |
-
|
| 451 |
const formData = new FormData();
|
| 452 |
formData.append("interaction_id", selectedInteractionId);
|
| 453 |
formData.append("response_text", responseText);
|
| 454 |
-
|
| 455 |
-
//
|
| 456 |
-
// that translation + audio instead of generating them again - same
|
| 457 |
-
// result, half the API cost. If the text was edited since the last
|
| 458 |
-
// preview (or there was no preview at all), this is skipped and the
|
| 459 |
-
// backend does the full work, same as before.
|
| 460 |
if (lastPreviewText === responseText && lastPreviewTranslation) {
|
| 461 |
formData.append("translated_response", lastPreviewTranslation);
|
| 462 |
formData.append("reuse_audio", "true");
|
| 463 |
}
|
| 464 |
-
|
| 465 |
try {
|
| 466 |
const response = await fetch("/provider-response", { method: "POST", body: formData });
|
| 467 |
const data = await response.json();
|
| 468 |
-
|
| 469 |
-
if (!response.ok) {
|
| 470 |
-
showError(data.error || "Something went wrong, please try again.");
|
| 471 |
-
return;
|
| 472 |
-
}
|
| 473 |
-
|
| 474 |
activeItem = null;
|
| 475 |
completedLoaded = false;
|
| 476 |
-
|
| 477 |
-
document.getElementById("detail-placeholder").style.display = "block";
|
| 478 |
-
document.getElementById("detail-content").style.display = "none";
|
| 479 |
-
loadPending();
|
| 480 |
showToast("Reply sent to patient");
|
| 481 |
} catch (error) {
|
| 482 |
showError("Couldn't reach the server. Check your connection and try again.");
|
|
|
|
| 5 |
|
| 6 |
(function () {
|
| 7 |
let selectedInteractionId = null;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
let activeItem = null;
|
| 9 |
let currentTab = "waiting";
|
| 10 |
let pendingItems = [];
|
|
|
|
| 15 |
// from, so Send only reuses it when nothing has been edited since.
|
| 16 |
let lastPreviewText = null;
|
| 17 |
let lastPreviewTranslation = null;
|
| 18 |
+
// Completed tab filters — client-side only, no new API calls needed
|
| 19 |
+
let completedLanguageFilter = "all";
|
| 20 |
+
let completedSortAsc = false; // false = newest first (default)
|
| 21 |
|
| 22 |
const statusBanner = document.getElementById("status-banner");
|
| 23 |
const pendingList = document.getElementById("pending-list");
|
| 24 |
const emptyPending = document.getElementById("empty-pending");
|
| 25 |
+
const emptyPendingTitle = document.getElementById("empty-pending-title");
|
| 26 |
+
const emptyPendingBody = document.getElementById("empty-pending-body");
|
| 27 |
const queueHeading = document.getElementById("queue-heading");
|
| 28 |
const queueSearchInput = document.getElementById("queue-search");
|
| 29 |
+
const completedControls = document.getElementById("completed-controls");
|
| 30 |
+
const sortToggleBtn = document.getElementById("sort-toggle-btn");
|
| 31 |
+
const sortLabel = document.getElementById("sort-label");
|
| 32 |
+
const sortIconAsc = document.getElementById("sort-icon-asc");
|
| 33 |
+
const sortIconDesc = document.getElementById("sort-icon-desc");
|
| 34 |
const backToListBtn = document.getElementById("back-to-list");
|
| 35 |
+
const backToListReadonlyBtn = document.getElementById("back-to-list-readonly");
|
| 36 |
const submitResponseBtn = document.getElementById("submit-response");
|
| 37 |
const previewBtn = document.getElementById("preview-response-btn");
|
| 38 |
const previewBlock = document.getElementById("preview-block");
|
|
|
|
| 40 |
const responseTextInput = document.getElementById("response-text-input");
|
| 41 |
const patientHistoryCard = document.getElementById("patient-history-card");
|
| 42 |
const patientHistoryList = document.getElementById("patient-history-list");
|
| 43 |
+
const composeArea = document.getElementById("compose-area");
|
| 44 |
+
const readonlyArea = document.getElementById("readonly-area");
|
| 45 |
+
const respondProviderBubble = document.getElementById("respond-provider-bubble");
|
| 46 |
+
const respondProviderReplyText = document.getElementById("respond-provider-reply-text");
|
| 47 |
const toast = document.getElementById("toast");
|
| 48 |
|
| 49 |
+
// Preview audio player
|
|
|
|
|
|
|
| 50 |
const previewAudioEl = document.getElementById("preview-audio");
|
| 51 |
const previewPlayBtn = document.getElementById("preview-audio-play-btn");
|
| 52 |
const previewReplayBtn = document.getElementById("preview-audio-replay-btn");
|
|
|
|
| 79 |
function showToast(message) {
|
| 80 |
toast.textContent = message;
|
| 81 |
toast.classList.add("visible");
|
| 82 |
+
setTimeout(function () { toast.classList.remove("visible"); }, 2200);
|
|
|
|
|
|
|
| 83 |
}
|
| 84 |
|
| 85 |
function setButtonLoading(button, isLoading, loadingText) {
|
|
|
|
| 111 |
if (currentTab === "active") {
|
| 112 |
return activeItem ? [activeItem] : [];
|
| 113 |
}
|
| 114 |
+
// Completed tab: apply language filter then sort
|
| 115 |
+
let items = completedItems;
|
| 116 |
+
if (completedLanguageFilter !== "all") {
|
| 117 |
+
items = items.filter(function (item) {
|
| 118 |
+
return (item.detected_language || "").toLowerCase() === completedLanguageFilter;
|
| 119 |
+
});
|
| 120 |
+
}
|
| 121 |
+
if (completedSortAsc) {
|
| 122 |
+
items = items.slice().sort(function (a, b) {
|
| 123 |
+
return a.timestamp < b.timestamp ? -1 : 1;
|
| 124 |
+
});
|
| 125 |
+
}
|
| 126 |
+
return items;
|
| 127 |
}
|
| 128 |
|
| 129 |
function matchesQuery(item, query) {
|
|
|
|
| 136 |
return haystack.indexOf(query) !== -1;
|
| 137 |
}
|
| 138 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 139 |
function buildQueueRow(item, index) {
|
| 140 |
const row = document.createElement("button");
|
| 141 |
row.type = "button";
|
|
|
|
| 170 |
return matchesQuery(item, query);
|
| 171 |
});
|
| 172 |
|
| 173 |
+
// Show completed-controls (language filter + sort) only on the
|
| 174 |
+
// Completed tab — filtering a short live Waiting/Active queue
|
| 175 |
+
// adds friction rather than value.
|
| 176 |
+
completedControls.style.display = currentTab === "completed" ? "block" : "none";
|
| 177 |
+
|
| 178 |
pendingList.innerHTML = "";
|
| 179 |
const isEmpty = items.length === 0;
|
| 180 |
emptyPending.style.display = isEmpty ? "block" : "none";
|
| 181 |
+
|
| 182 |
+
if (isEmpty) {
|
| 183 |
+
const hasFilter = query || (currentTab === "completed" && completedLanguageFilter !== "all");
|
| 184 |
+
if (hasFilter) {
|
| 185 |
+
emptyPendingTitle.textContent = "No matches";
|
| 186 |
+
emptyPendingBody.textContent = "Try a different search or filter.";
|
| 187 |
+
} else if (currentTab === "waiting") {
|
| 188 |
+
emptyPendingTitle.textContent = "All caught up";
|
| 189 |
+
emptyPendingBody.textContent = "Nothing waiting right now.";
|
| 190 |
+
} else if (currentTab === "active") {
|
| 191 |
+
emptyPendingTitle.textContent = "Nothing active";
|
| 192 |
+
emptyPendingBody.textContent = "Open a message from the Waiting tab to start on it.";
|
| 193 |
+
} else {
|
| 194 |
+
emptyPendingTitle.textContent = "No completed consultations";
|
| 195 |
+
emptyPendingBody.textContent = "Completed replies will appear here.";
|
| 196 |
+
}
|
| 197 |
+
return;
|
| 198 |
+
}
|
| 199 |
|
| 200 |
items.forEach(function (item, index) {
|
| 201 |
pendingList.appendChild(buildQueueRow(item, index));
|
|
|
|
| 229 |
? "1 message waiting for you"
|
| 230 |
: interactions.length + " messages waiting for you";
|
| 231 |
queueHeading.textContent = newHeadingText;
|
|
|
|
|
|
|
| 232 |
queueHeading.style.animation = "none";
|
| 233 |
void queueHeading.offsetWidth;
|
| 234 |
queueHeading.style.animation = "";
|
|
|
|
| 272 |
renderQueueList();
|
| 273 |
});
|
| 274 |
|
| 275 |
+
// Language filter chips (completed tab only)
|
| 276 |
+
document.getElementById("language-filter-chips").addEventListener("click", function (event) {
|
| 277 |
+
const chip = event.target.closest(".filter-chip");
|
| 278 |
+
if (!chip) return;
|
| 279 |
+
completedLanguageFilter = chip.dataset.filter;
|
| 280 |
+
document.querySelectorAll("#language-filter-chips .filter-chip").forEach(function (c) {
|
| 281 |
+
c.classList.toggle("active", c === chip);
|
| 282 |
+
});
|
| 283 |
+
renderQueueList();
|
| 284 |
+
});
|
| 285 |
+
|
| 286 |
+
// Sort toggle (completed tab only)
|
| 287 |
+
sortToggleBtn.addEventListener("click", function () {
|
| 288 |
+
completedSortAsc = !completedSortAsc;
|
| 289 |
+
sortLabel.textContent = completedSortAsc ? "Oldest first" : "Newest first";
|
| 290 |
+
sortIconAsc.style.display = completedSortAsc ? "inline" : "none";
|
| 291 |
+
sortIconDesc.style.display = completedSortAsc ? "none" : "inline";
|
| 292 |
+
renderQueueList();
|
| 293 |
+
});
|
| 294 |
+
|
| 295 |
+
// ---- Workspace ----
|
| 296 |
|
| 297 |
async function loadPatientHistoryForWorkspace(item) {
|
| 298 |
patientHistoryCard.style.display = "none";
|
| 299 |
try {
|
| 300 |
const response = await fetch("/patient-history/" + item.user_id);
|
| 301 |
const items = await response.json();
|
| 302 |
+
const others = items.filter(function (h) { return h.id !== item.id; });
|
|
|
|
|
|
|
|
|
|
| 303 |
if (!others.length) return;
|
|
|
|
| 304 |
patientHistoryList.innerHTML = "";
|
| 305 |
others.forEach(function (h, index) {
|
| 306 |
const row = document.createElement("div");
|
|
|
|
| 319 |
});
|
| 320 |
patientHistoryCard.style.display = "block";
|
| 321 |
} catch (error) {
|
| 322 |
+
// Supplementary context only — fail silently rather than blocking
|
| 323 |
+
// the provider from responding to the patient.
|
| 324 |
}
|
| 325 |
}
|
| 326 |
|
|
|
|
| 354 |
const timeLabel = window.formatRelativeTime ? window.formatRelativeTime(item.timestamp) : item.timestamp;
|
| 355 |
document.getElementById("respond-meta-line").textContent = "Submitted " + timeLabel;
|
| 356 |
|
|
|
|
| 357 |
const answered = Boolean(item.translated_response);
|
| 358 |
+
const statusBadge = document.getElementById("respond-status-badge");
|
| 359 |
statusBadge.className = "status-badge " + (answered ? "answered" : "waiting");
|
| 360 |
statusBadge.textContent = answered ? "Answered" : "Waiting for your reply";
|
| 361 |
|
| 362 |
+
// Completed consultations open read-only: show the provider's reply
|
| 363 |
+
// in the thread and hide the composer entirely. Unanswered ones open
|
| 364 |
+
// the composer as normal.
|
| 365 |
+
if (answered) {
|
| 366 |
+
respondProviderBubble.style.display = "block";
|
| 367 |
+
respondProviderReplyText.textContent = item.translated_response;
|
| 368 |
+
composeArea.style.display = "none";
|
| 369 |
+
readonlyArea.style.display = "block";
|
| 370 |
+
} else {
|
| 371 |
+
respondProviderBubble.style.display = "none";
|
| 372 |
+
composeArea.style.display = "block";
|
| 373 |
+
readonlyArea.style.display = "none";
|
| 374 |
+
}
|
| 375 |
+
|
| 376 |
responseTextInput.value = "";
|
| 377 |
invalidatePreview();
|
| 378 |
loadPatientHistoryForWorkspace(item);
|
|
|
|
| 396 |
invalidatePreview();
|
| 397 |
});
|
| 398 |
|
| 399 |
+
function goBackToList() {
|
| 400 |
document.getElementById("detail-placeholder").style.display = "block";
|
| 401 |
document.getElementById("detail-content").style.display = "none";
|
| 402 |
showStep("list");
|
| 403 |
loadPending();
|
| 404 |
+
}
|
| 405 |
|
| 406 |
+
backToListBtn.addEventListener("click", goBackToList);
|
| 407 |
+
backToListReadonlyBtn.addEventListener("click", goBackToList);
|
| 408 |
+
|
| 409 |
+
// ---- Response composer: preview then send ----
|
| 410 |
|
| 411 |
function formatAudioTime(totalSeconds) {
|
| 412 |
if (!isFinite(totalSeconds) || totalSeconds < 0) return "0:00";
|
|
|
|
| 425 |
}
|
| 426 |
|
| 427 |
previewPlayBtn.addEventListener("click", function () {
|
| 428 |
+
if (previewAudioEl.paused) { previewAudioEl.play(); } else { previewAudioEl.pause(); }
|
|
|
|
|
|
|
|
|
|
|
|
|
| 429 |
});
|
| 430 |
|
| 431 |
previewReplayBtn.addEventListener("click", function () {
|
|
|
|
| 464 |
|
| 465 |
previewBtn.addEventListener("click", async function () {
|
| 466 |
const responseText = responseTextInput.value.trim();
|
| 467 |
+
if (!responseText) { showError("Type a reply before previewing."); return; }
|
|
|
|
|
|
|
|
|
|
|
|
|
| 468 |
clearBanner();
|
| 469 |
setButtonLoading(previewBtn, true, "Generating preview...");
|
|
|
|
| 470 |
const formData = new FormData();
|
| 471 |
formData.append("interaction_id", selectedInteractionId);
|
| 472 |
formData.append("response_text", responseText);
|
|
|
|
| 473 |
try {
|
| 474 |
const response = await fetch("/preview-response", { method: "POST", body: formData });
|
| 475 |
const data = await response.json();
|
| 476 |
+
if (!response.ok) { showError(data.error || "Couldn't generate a preview."); return; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 477 |
previewTextBubble.innerHTML = '<span class="chat-label">Preview</span>' + escapeHtml(data.translated_response);
|
| 478 |
setPreviewAudioSource(data.audio_url);
|
| 479 |
previewBlock.style.display = "block";
|
|
|
|
| 489 |
submitResponseBtn.addEventListener("click", async function () {
|
| 490 |
clearBanner();
|
| 491 |
const responseText = responseTextInput.value.trim();
|
| 492 |
+
if (!responseText) { showError("Type a reply before sending."); return; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 493 |
setButtonLoading(submitResponseBtn, true, "Sending...");
|
|
|
|
| 494 |
const formData = new FormData();
|
| 495 |
formData.append("interaction_id", selectedInteractionId);
|
| 496 |
formData.append("response_text", responseText);
|
| 497 |
+
// Reuse the already-computed preview translation + audio if the text
|
| 498 |
+
// hasn't changed since the last preview — saves one translate + TTS call.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 499 |
if (lastPreviewText === responseText && lastPreviewTranslation) {
|
| 500 |
formData.append("translated_response", lastPreviewTranslation);
|
| 501 |
formData.append("reuse_audio", "true");
|
| 502 |
}
|
|
|
|
| 503 |
try {
|
| 504 |
const response = await fetch("/provider-response", { method: "POST", body: formData });
|
| 505 |
const data = await response.json();
|
| 506 |
+
if (!response.ok) { showError(data.error || "Something went wrong, please try again."); return; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 507 |
activeItem = null;
|
| 508 |
completedLoaded = false;
|
| 509 |
+
goBackToList();
|
|
|
|
|
|
|
|
|
|
| 510 |
showToast("Reply sent to patient");
|
| 511 |
} catch (error) {
|
| 512 |
showError("Couldn't reach the server. Check your connection and try again.");
|