goctests0 commited on
Commit
43fad69
·
verified ·
1 Parent(s): 22afef7

Upload 2 files

Browse files
Files changed (2) hide show
  1. static/js/patient.js +58 -12
  2. 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
- renderFullHistoryList(historySearchInput.value);
 
 
 
 
 
 
 
 
 
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
- const filtered = query
476
- ? cachedHistoryItems.filter(function (item) {
477
- return (item.input_text || "").toLowerCase().indexOf(query) !== -1;
478
- })
479
- : cachedHistoryItems;
 
 
 
 
 
 
 
 
 
480
 
481
  historyFullList.innerHTML = "";
 
 
 
482
 
483
  if (!cachedHistoryItems.length) {
484
- historyEmptyText.textContent = "You haven't sent any messages yet.";
485
- historyEmptyText.style.display = "block";
 
486
  return;
487
  }
488
 
489
  if (!filtered.length) {
490
- historyEmptyText.textContent = "No messages match your search.";
491
- historyEmptyText.style.display = "block";
 
 
 
 
 
492
  return;
493
  }
494
 
495
- historyEmptyText.style.display = "none";
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 (separate instance from the patient page's, same
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
- return completedItems;
 
 
 
 
 
 
 
 
 
 
 
 
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
- emptyPending.textContent = emptyStateText(query);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- // ---- Workspace (respond step) ----
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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, not the critical path - fail quietly
276
- // rather than blocking the provider from responding.
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
- backToListBtn.addEventListener("click", function () {
339
  document.getElementById("detail-placeholder").style.display = "block";
340
  document.getElementById("detail-content").style.display = "none";
341
  showStep("list");
342
  loadPending();
343
- });
344
 
345
- // ---- Response composer: preview, then send ----
 
 
 
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
- // If this exact text was just previewed, tell the backend to reuse
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
- showStep("list");
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.");