evaldoc 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.staging.example +19 -0
- package/.firebaserc +6 -0
- package/.github/workflows/auto-merge-to-main.yml +28 -0
- package/.github/workflows/ci.yml +27 -0
- package/.github/workflows/npxhub-publish.yml +170 -0
- package/.prettierignore +4 -0
- package/.prettierrc.json +7 -0
- package/README.md +142 -0
- package/SRS.md +448 -0
- package/apphosting.staging.yaml +48 -0
- package/apphosting.yaml +50 -0
- package/bin/evaldoc.cjs +105 -0
- package/cors.json +8 -0
- package/docs/ARCHITECTURE.md +769 -0
- package/docs/ARCHITECTURE_SUMMARY.md +323 -0
- package/docs/DEPLOYMENT.md +86 -0
- package/docs/DEPLOYMENT_STEPS.md +373 -0
- package/docs/DEPLOY_FIRESTORE_RULES.md +239 -0
- package/docs/DEPLOY_STORAGE_RULES.md +82 -0
- package/docs/DOCTOR_TUTORIAL.md +467 -0
- package/docs/FIREBASE_STORAGE_CORS_FIX.md +131 -0
- package/docs/FIXES_APPLIED.md +128 -0
- package/docs/IMPLEMENTATION.md +498 -0
- package/docs/IMPLEMENTATION_COMPLETE.md +317 -0
- package/docs/LLM-SETUP.md +216 -0
- package/docs/MANUAL_TEST_GUIDE.md +327 -0
- package/docs/PATIENT_GUIDE.md +451 -0
- package/docs/PLATFORM_OVERVIEW.md +194 -0
- package/docs/QUICKSTART.md +252 -0
- package/docs/QUICK_REFERENCE.md +222 -0
- package/docs/READY_TO_TEST.md +383 -0
- package/docs/RUN.md +237 -0
- package/docs/START_TESTING.md +130 -0
- package/docs/STRIPE_CONNECT_SETUP.md +454 -0
- package/docs/SYSTEM_READY.md +345 -0
- package/docs/THREADS_IMPLEMENTATION_COMPLETE.md +431 -0
- package/docs/THREAD_IMPLEMENTATION_GUIDE.md +345 -0
- package/docs/firebasefiles.md +21 -0
- package/docs/homepage.md +1208 -0
- package/docs/payments.md +159 -0
- package/eslint.config.js +76 -0
- package/firebase.json +27 -0
- package/firestore.indexes.json +88 -0
- package/firestore.rules +125 -0
- package/functions/package-lock.json +2884 -0
- package/functions/package.json +20 -0
- package/functions/src/analytics.js +101 -0
- package/functions/src/audit.js +28 -0
- package/functions/src/auth.js +19 -0
- package/functions/src/drafts.js +128 -0
- package/functions/src/feedback.js +164 -0
- package/functions/src/firebase-init.js +29 -0
- package/functions/src/i18n.js +134 -0
- package/functions/src/limits.js +27 -0
- package/functions/src/llm.js +271 -0
- package/functions/src/ner.js +162 -0
- package/functions/src/notifications.js +273 -0
- package/functions/src/orgs.js +127 -0
- package/functions/src/patient-api.js +545 -0
- package/functions/src/payment.js +830 -0
- package/functions/src/safety.js +188 -0
- package/functions/src/stripe-config.js +33 -0
- package/functions/src/topics.js +94 -0
- package/functions/src/triage.js +191 -0
- package/functions/src/utils.js +28 -0
- package/functions/src/validate.js +177 -0
- package/functions/test-firebase.js +36 -0
- package/jest.config.js +18 -0
- package/package.json +52 -0
- package/public/404.html +93 -0
- package/public/app/analytics.html +336 -0
- package/public/app/analytics.js +177 -0
- package/public/app/app.css +1400 -0
- package/public/app/app.js +1754 -0
- package/public/app/config.js +14 -0
- package/public/app/inbox-threads.js +843 -0
- package/public/app/inbox.html +598 -0
- package/public/app/inbox.js +397 -0
- package/public/app/index.html +612 -0
- package/public/assets/logo.jpg +0 -0
- package/public/chat.html +137 -0
- package/public/css/chat.css +870 -0
- package/public/css/landing-enhance.css +298 -0
- package/public/css/styles.css +1198 -0
- package/public/favicon.svg +5 -0
- package/public/find-doctor.html +220 -0
- package/public/index.html +399 -0
- package/public/js/chat-threads.js +513 -0
- package/public/js/chat.js +832 -0
- package/public/js/search.js +41 -0
- package/public/privacy.html +101 -0
- package/public/robots.txt +6 -0
- package/public/site.webmanifest +12 -0
- package/public/terms.html +110 -0
- package/scripts/create-test-provider.js +55 -0
- package/scripts/deploy-staging.sh +51 -0
- package/scripts/fix-provider.js +39 -0
- package/scripts/quick-test.sh +37 -0
- package/scripts/test-chat.sh +54 -0
- package/scripts/test-multi-turn.js +137 -0
- package/scripts/test-ner-simple.js +28 -0
- package/scripts/test-ner.js +60 -0
- package/scripts/test-stripe-connect.js +304 -0
- package/scripts/test-webhook-secret.sh +24 -0
- package/server.js +3864 -0
- package/storage.rules +22 -0
- package/test-api.json +1 -0
- package/tests/emulator/README.md +38 -0
- package/tests/emulator/counters.emulator.test.js +81 -0
- package/tests/integration/api-account-selfserve.test.js +312 -0
- package/tests/integration/api-admin-digest.test.js +202 -0
- package/tests/integration/api-chat-satisfaction.test.js +259 -0
- package/tests/integration/api-doctor-drafts.test.js +236 -0
- package/tests/integration/api-doctor-inbox.test.js +401 -0
- package/tests/integration/api-org.test.js +328 -0
- package/tests/integration/api-payment.test.js +664 -0
- package/tests/integration/api-provider-settings.test.js +475 -0
- package/tests/integration/api-qr.test.js +121 -0
- package/tests/integration/api-security.test.js +241 -0
- package/tests/integration/api-stripe-search.test.js +303 -0
- package/tests/integration/api-webhook-deletion.test.js +231 -0
- package/tests/mocks/firebase-admin.js +220 -0
- package/tests/unit/analytics.test.js +151 -0
- package/tests/unit/audit-bugs.test.js +203 -0
- package/tests/unit/chat-ui-logic.test.js +283 -0
- package/tests/unit/drafts.test.js +104 -0
- package/tests/unit/feedback.test.js +148 -0
- package/tests/unit/i18n.test.js +229 -0
- package/tests/unit/llm-retry.test.js +122 -0
- package/tests/unit/notifications.test.js +155 -0
- package/tests/unit/orgs.test.js +328 -0
- package/tests/unit/payment.test.js +889 -0
- package/tests/unit/safety-edge-cases.test.js +509 -0
- package/tests/unit/safety-srs-compliance.test.js +252 -0
- package/tests/unit/safety.test.js +291 -0
- package/tests/unit/server-helpers.test.js +774 -0
- package/tests/unit/topics-limits.test.js +158 -0
- package/tests/unit/triage.test.js +152 -0
- package/tests/unit/validate.test.js +408 -0
|
@@ -0,0 +1,843 @@
|
|
|
1
|
+
const authRequired = document.getElementById('authRequired');
|
|
2
|
+
const inboxContainer = document.getElementById('inboxContainer');
|
|
3
|
+
const conversationsList = document.getElementById('conversationsList');
|
|
4
|
+
const conversationDetail = document.getElementById('conversationDetail');
|
|
5
|
+
const signOutBtn = document.getElementById('signOutBtn');
|
|
6
|
+
|
|
7
|
+
let currentProvider = null;
|
|
8
|
+
let threads = [];
|
|
9
|
+
let selectedThread = null;
|
|
10
|
+
let currentFilter = 'all';
|
|
11
|
+
|
|
12
|
+
// ============================================
|
|
13
|
+
// AUTH & FETCH HELPERS
|
|
14
|
+
// ============================================
|
|
15
|
+
|
|
16
|
+
async function fetchWithAuth(path, options = {}) {
|
|
17
|
+
const user = firebase.auth().currentUser;
|
|
18
|
+
if (!user) throw new Error('Not authenticated');
|
|
19
|
+
|
|
20
|
+
const token = await user.getIdToken();
|
|
21
|
+
const headers = Object.assign({}, options.headers || {}, {
|
|
22
|
+
Authorization: `Bearer ${token}`
|
|
23
|
+
});
|
|
24
|
+
return fetch(path, { ...options, headers });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function loadProvider() {
|
|
28
|
+
try {
|
|
29
|
+
const res = await fetchWithAuth('/api/me');
|
|
30
|
+
if (res.status === 404) return null;
|
|
31
|
+
if (!res.ok) throw new Error('Unable to load provider');
|
|
32
|
+
return await res.json();
|
|
33
|
+
} catch (err) {
|
|
34
|
+
console.error('Error loading provider:', err);
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ============================================
|
|
40
|
+
// THREADS
|
|
41
|
+
// ============================================
|
|
42
|
+
|
|
43
|
+
async function loadThreads() {
|
|
44
|
+
try {
|
|
45
|
+
// Fetch from both threads (new) and conversations (legacy) in parallel
|
|
46
|
+
const [threadsRes, convosRes] = await Promise.allSettled([
|
|
47
|
+
fetchWithAuth('/api/doctor/threads?limit=100'),
|
|
48
|
+
fetchWithAuth('/api/conversations?limit=100')
|
|
49
|
+
]);
|
|
50
|
+
|
|
51
|
+
let newThreads = [];
|
|
52
|
+
let legacyConvos = [];
|
|
53
|
+
|
|
54
|
+
if (threadsRes.status === 'fulfilled' && threadsRes.value.ok) {
|
|
55
|
+
const data = await threadsRes.value.json();
|
|
56
|
+
newThreads = (data.threads || []).map(t => ({ ...t, _source: 'threads' }));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (convosRes.status === 'fulfilled' && convosRes.value.ok) {
|
|
60
|
+
const data = await convosRes.value.json();
|
|
61
|
+
legacyConvos = (data.conversations || []).map(c => ({
|
|
62
|
+
id: c.id,
|
|
63
|
+
patientName: c.clientName || null,
|
|
64
|
+
patientEmail: c.clientEmail || null,
|
|
65
|
+
clientPhone: c.clientPhone || null,
|
|
66
|
+
patientSource: 'anonymous',
|
|
67
|
+
sessionId: c.sessionId,
|
|
68
|
+
doctorId: c.providerId,
|
|
69
|
+
status: 'active',
|
|
70
|
+
isEscalated: c.isEscalated || false,
|
|
71
|
+
lastMessageAt: c.lastMessageAt || c.updatedAt,
|
|
72
|
+
lastMessageText: c.lastUserMessage || c.lastAssistantMessage || '',
|
|
73
|
+
lastMessageRole: null,
|
|
74
|
+
messageCount: c.messageCount || 0,
|
|
75
|
+
unreadByDoctor: c.unreadByDoctor || 0,
|
|
76
|
+
currentTopic: c.currentTopic,
|
|
77
|
+
createdAt: c.createdAt,
|
|
78
|
+
_source: 'conversations'
|
|
79
|
+
}));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Deduplicate: threads take priority over conversations with same sessionId
|
|
83
|
+
const threadSessionIds = new Set(newThreads.map(t => t.sessionId).filter(Boolean));
|
|
84
|
+
legacyConvos = legacyConvos.filter(c => !c.sessionId || !threadSessionIds.has(c.sessionId));
|
|
85
|
+
|
|
86
|
+
// Merge and sort by most recent
|
|
87
|
+
threads = [...newThreads, ...legacyConvos];
|
|
88
|
+
threads.sort((a, b) => {
|
|
89
|
+
const aTime = getTimestampSeconds(a.lastMessageAt || a.createdAt);
|
|
90
|
+
const bTime = getTimestampSeconds(b.lastMessageAt || b.createdAt);
|
|
91
|
+
return bTime - aTime;
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
renderThreadsList();
|
|
95
|
+
} catch (err) {
|
|
96
|
+
console.error('Error loading threads:', err);
|
|
97
|
+
conversationsList.innerHTML = '<div style="color: #6b7280; text-align: center; margin-top: 2rem;"><p>Unable to load conversations</p><button onclick="loadThreads()" class="filter-btn" style="margin-top:0.5rem;">Retry</button></div>';
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ============================================
|
|
102
|
+
// FORMATTING HELPERS
|
|
103
|
+
// ============================================
|
|
104
|
+
|
|
105
|
+
function escapeHtml(text) {
|
|
106
|
+
const div = document.createElement('div');
|
|
107
|
+
div.textContent = text;
|
|
108
|
+
return div.innerHTML;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function linkifyUrls(text) {
|
|
112
|
+
return text.replace(
|
|
113
|
+
/(https?:\/\/[^\s<]+)/g,
|
|
114
|
+
'<a href="$1" target="_blank" rel="noopener noreferrer" style="color:#297B56;">$1</a>'
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function getTimestampSeconds(ts) {
|
|
119
|
+
if (!ts) return 0;
|
|
120
|
+
if (ts._seconds) return ts._seconds;
|
|
121
|
+
if (ts.seconds) return ts.seconds;
|
|
122
|
+
if (typeof ts === 'number') return ts;
|
|
123
|
+
return 0;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function formatTime(timestamp) {
|
|
127
|
+
if (!timestamp) return 'Unknown';
|
|
128
|
+
const date = timestamp.toDate ? timestamp.toDate() : new Date((timestamp._seconds || 0) * 1000);
|
|
129
|
+
const now = new Date();
|
|
130
|
+
const diff = now - date;
|
|
131
|
+
|
|
132
|
+
const minutes = Math.floor(diff / 60000);
|
|
133
|
+
const hours = Math.floor(diff / 3600000);
|
|
134
|
+
const days = Math.floor(diff / 86400000);
|
|
135
|
+
|
|
136
|
+
if (minutes < 60) return `${minutes}m ago`;
|
|
137
|
+
if (hours < 24) return `${hours}h ago`;
|
|
138
|
+
if (days < 7) return `${days}d ago`;
|
|
139
|
+
|
|
140
|
+
return date.toLocaleDateString();
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// NOTE: these return HTML-escaped strings because patient-controlled values
|
|
144
|
+
// (name/email/message) are interpolated into innerHTML across the inbox.
|
|
145
|
+
function getPatientDisplay(thread) {
|
|
146
|
+
if (thread.patientName) return escapeHtml(thread.patientName);
|
|
147
|
+
if (thread.patientEmail) return escapeHtml(thread.patientEmail);
|
|
148
|
+
if (thread.clientName) return escapeHtml(thread.clientName);
|
|
149
|
+
if (thread.sessionId) return `Session ${escapeHtml(thread.sessionId.substring(0, 8))}`;
|
|
150
|
+
return 'Patient';
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function getMessagePreview(thread) {
|
|
154
|
+
if (!thread.lastMessageText) return 'No messages yet';
|
|
155
|
+
const text = String(thread.lastMessageText);
|
|
156
|
+
return escapeHtml(text.substring(0, 60)) + (text.length > 60 ? '…' : '');
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function getTriageBadgeHTML(category) {
|
|
160
|
+
if (!category) return '';
|
|
161
|
+
const labels = {
|
|
162
|
+
urgent: 'Urgent',
|
|
163
|
+
needs_review: 'Needs Review',
|
|
164
|
+
informational: 'Info',
|
|
165
|
+
resolved: 'Resolved'
|
|
166
|
+
};
|
|
167
|
+
return `<span class="triage-badge ${category}">${labels[category] || category}</span>`;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ============================================
|
|
171
|
+
// FILTERING
|
|
172
|
+
// ============================================
|
|
173
|
+
|
|
174
|
+
let threadSearchQuery = '';
|
|
175
|
+
|
|
176
|
+
function filterThreads() {
|
|
177
|
+
return threads.filter(thread => {
|
|
178
|
+
if (currentFilter !== 'all') {
|
|
179
|
+
if (currentFilter === 'escalated' && thread.isEscalated !== true) return false;
|
|
180
|
+
if (currentFilter !== 'escalated' && thread.triageCategory !== currentFilter) return false;
|
|
181
|
+
}
|
|
182
|
+
if (threadSearchQuery) {
|
|
183
|
+
const q = threadSearchQuery;
|
|
184
|
+
const fields = [
|
|
185
|
+
thread.patientName, thread.clientName, thread.patientEmail,
|
|
186
|
+
thread.lastMessageText, thread.lastUserMessage, thread.currentTopic, thread.topic
|
|
187
|
+
].filter(Boolean).map(f => String(f).toLowerCase());
|
|
188
|
+
return fields.some(f => f.includes(q));
|
|
189
|
+
}
|
|
190
|
+
return true;
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ============================================
|
|
195
|
+
// RENDER THREADS LIST
|
|
196
|
+
// ============================================
|
|
197
|
+
|
|
198
|
+
function renderThreadsList() {
|
|
199
|
+
const filtered = filterThreads();
|
|
200
|
+
|
|
201
|
+
if (filtered.length === 0) {
|
|
202
|
+
conversationsList.innerHTML = '<p style="color: #6b7280; text-align: center; margin-top: 2rem;">No conversations found</p>';
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
conversationsList.innerHTML = '';
|
|
207
|
+
filtered.forEach(thread => {
|
|
208
|
+
const item = document.createElement('div');
|
|
209
|
+
item.className = 'conversation-item';
|
|
210
|
+
if (selectedThread && selectedThread.id === thread.id) {
|
|
211
|
+
item.classList.add('active');
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const patientDisplay = getPatientDisplay(thread);
|
|
215
|
+
const preview = getMessagePreview(thread);
|
|
216
|
+
const time = formatTime(thread.lastMessageAt || thread.createdAt);
|
|
217
|
+
|
|
218
|
+
const unreadDot = thread.unreadByDoctor > 0
|
|
219
|
+
? '<span style="display:inline-block;width:8px;height:8px;background:#4fb3a4;border-radius:50%;margin-right:0.5rem;"></span>'
|
|
220
|
+
: '';
|
|
221
|
+
|
|
222
|
+
const triageBadge = getTriageBadgeHTML(thread.triageCategory);
|
|
223
|
+
const sourceLabel = thread.patientSource === 'anonymous'
|
|
224
|
+
? '<span style="font-size:0.65rem;color:#9ca3af;margin-left:0.25rem;">(anonymous)</span>'
|
|
225
|
+
: '';
|
|
226
|
+
|
|
227
|
+
item.innerHTML = `
|
|
228
|
+
<div class="conversation-header">
|
|
229
|
+
<div class="conversation-client">${unreadDot}${patientDisplay}${sourceLabel}</div>
|
|
230
|
+
<div class="conversation-time">${time}</div>
|
|
231
|
+
</div>
|
|
232
|
+
<div class="conversation-preview">${preview}</div>
|
|
233
|
+
<div style="margin-top: 0.5rem; display: flex; gap: 0.25rem; flex-wrap: wrap;">
|
|
234
|
+
${triageBadge}
|
|
235
|
+
${thread.isEscalated ? '<span class="conversation-badge escalated">Escalated</span>' : ''}
|
|
236
|
+
${thread.aiDraftStatus === 'pending' ? '<span class="conversation-badge" style="background:#fffbeb;color:#92400e;border:1px solid #fde68a;">Draft Ready</span>' : ''}
|
|
237
|
+
${thread.status === 'resolved' ? '<span class="conversation-badge" style="background:#d1fae5;color:#065f46;border:1px solid #6ee7b7;">Resolved</span>' : ''}
|
|
238
|
+
${thread.doctorOverride ? '<span class="conversation-badge" style="background:#eff6ff;color:#2563eb;border:1px solid #93c5fd;">Manual</span>' : ''}
|
|
239
|
+
</div>
|
|
240
|
+
`;
|
|
241
|
+
|
|
242
|
+
item.tabIndex = 0;
|
|
243
|
+
item.setAttribute('role', 'button');
|
|
244
|
+
item.setAttribute('aria-label', `Conversation with ${patientDisplay}`);
|
|
245
|
+
const selectThread = () => {
|
|
246
|
+
selectedThread = thread;
|
|
247
|
+
renderThreadsList();
|
|
248
|
+
renderThreadDetail(thread);
|
|
249
|
+
};
|
|
250
|
+
item.addEventListener('click', selectThread);
|
|
251
|
+
item.addEventListener('keydown', (e) => {
|
|
252
|
+
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); selectThread(); }
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
conversationsList.appendChild(item);
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ============================================
|
|
260
|
+
// RENDER THREAD DETAIL
|
|
261
|
+
// ============================================
|
|
262
|
+
|
|
263
|
+
async function renderThreadDetail(thread) {
|
|
264
|
+
conversationDetail.innerHTML = '<div style="text-align: center; padding: 2rem;">Loading...</div>';
|
|
265
|
+
|
|
266
|
+
try {
|
|
267
|
+
let fullThread, messages;
|
|
268
|
+
|
|
269
|
+
if (thread._source === 'conversations') {
|
|
270
|
+
// Legacy conversation endpoint
|
|
271
|
+
const res = await fetchWithAuth(`/api/conversation/${thread.id}`);
|
|
272
|
+
if (!res.ok) throw new Error('Unable to load conversation details');
|
|
273
|
+
const data = await res.json();
|
|
274
|
+
fullThread = {
|
|
275
|
+
...thread,
|
|
276
|
+
patientName: data.clientName || thread.patientName,
|
|
277
|
+
patientEmail: data.clientEmail || thread.patientEmail,
|
|
278
|
+
clientPhone: data.clientPhone || thread.clientPhone,
|
|
279
|
+
isEscalated: data.isEscalated || false,
|
|
280
|
+
currentTopic: data.currentTopic,
|
|
281
|
+
createdAt: data.createdAt
|
|
282
|
+
};
|
|
283
|
+
messages = (data.messages || []).map(m => ({
|
|
284
|
+
...m,
|
|
285
|
+
message: m.message || m.content
|
|
286
|
+
}));
|
|
287
|
+
} else {
|
|
288
|
+
const res = await fetchWithAuth(`/api/doctor/thread/${thread.id}`);
|
|
289
|
+
if (!res.ok) throw new Error('Unable to load thread details');
|
|
290
|
+
const data = await res.json();
|
|
291
|
+
fullThread = data.thread;
|
|
292
|
+
messages = data.messages || [];
|
|
293
|
+
}
|
|
294
|
+
const patientDisplay = getPatientDisplay(fullThread);
|
|
295
|
+
|
|
296
|
+
let detailHTML = `
|
|
297
|
+
<div class="detail-header">
|
|
298
|
+
<div style="display: flex; justify-content: space-between; align-items: start;">
|
|
299
|
+
<div class="detail-title">${patientDisplay}</div>
|
|
300
|
+
${getTriageBadgeHTML(fullThread.triageCategory)}
|
|
301
|
+
</div>
|
|
302
|
+
<div class="detail-meta">
|
|
303
|
+
<div class="detail-meta-item">
|
|
304
|
+
<span class="detail-meta-label">${messages.length} msgs</span>
|
|
305
|
+
</div>
|
|
306
|
+
<div class="detail-meta-item">
|
|
307
|
+
<span class="detail-meta-value">${formatTime(fullThread.createdAt)}</span>
|
|
308
|
+
</div>
|
|
309
|
+
${fullThread.currentTopic ? `
|
|
310
|
+
<div class="detail-meta-item">
|
|
311
|
+
<span class="detail-meta-value">${(fullThread.currentTopic || '').replace(/_/g, ' ')}</span>
|
|
312
|
+
</div>
|
|
313
|
+
` : ''}
|
|
314
|
+
${fullThread.isEscalated ? `
|
|
315
|
+
<div class="detail-meta-item">
|
|
316
|
+
<span class="detail-meta-value" style="color: #dc2626;">Escalated</span>
|
|
317
|
+
</div>
|
|
318
|
+
` : ''}
|
|
319
|
+
</div>
|
|
320
|
+
|
|
321
|
+
${fullThread.patientEmail || fullThread.patientName || fullThread.clientPhone ? `
|
|
322
|
+
<div style="margin-top: 0.75rem; padding: 0.75rem; background: #e6f7f4; border-radius: 6px; border-left: 3px solid #297B56; font-size: 0.85rem;">
|
|
323
|
+
${fullThread.patientName ? `<div style="font-weight: 600; margin-bottom: 0.25rem;">${escapeHtml(fullThread.patientName)}</div>` : ''}
|
|
324
|
+
${fullThread.patientEmail ? `<div><a href="mailto:${encodeURIComponent(fullThread.patientEmail)}" style="color: #297B56;">${escapeHtml(fullThread.patientEmail)}</a></div>` : ''}
|
|
325
|
+
${fullThread.clientPhone ? `<div><a href="tel:${encodeURIComponent(fullThread.clientPhone)}" style="color: #297B56;">${escapeHtml(fullThread.clientPhone)}</a></div>` : ''}
|
|
326
|
+
</div>
|
|
327
|
+
` : ''}
|
|
328
|
+
|
|
329
|
+
<!-- Control Panel (only for unified threads, not legacy conversations) -->
|
|
330
|
+
${thread._source !== 'conversations' ? `
|
|
331
|
+
<div style="margin-top: 1rem; display: flex; gap: 0.75rem; flex-wrap: wrap; align-items: center;">
|
|
332
|
+
<button id="toggleOverrideBtn" class="filter-btn ${fullThread.doctorOverride ? 'active' : ''}">
|
|
333
|
+
${fullThread.doctorOverride ? 'Enable AI' : 'Take Over (Manual)'}
|
|
334
|
+
</button>
|
|
335
|
+
${fullThread.isEscalated ? '<button id="clearEscalationBtn" class="filter-btn">Clear Escalation</button>' : ''}
|
|
336
|
+
${fullThread.status !== 'resolved' ? '<button id="resolveThreadBtn" class="filter-btn" style="background:#d1fae5;color:#065f46;border-color:#6ee7b7;">Resolve</button>' : '<button id="reopenThreadBtn" class="filter-btn">Reopen</button>'}
|
|
337
|
+
<button id="archiveThreadBtn" class="filter-btn">Archive</button>
|
|
338
|
+
<button id="exportThreadBtn" class="filter-btn" title="Export transcript">Export</button>
|
|
339
|
+
<select id="triageOverride" class="triage-select">
|
|
340
|
+
<option value="">Set Triage...</option>
|
|
341
|
+
<option value="urgent" ${fullThread.triageCategory === 'urgent' ? 'selected' : ''}>Urgent</option>
|
|
342
|
+
<option value="needs_review" ${fullThread.triageCategory === 'needs_review' ? 'selected' : ''}>Needs Review</option>
|
|
343
|
+
<option value="informational" ${fullThread.triageCategory === 'informational' ? 'selected' : ''}>Informational</option>
|
|
344
|
+
<option value="resolved" ${fullThread.triageCategory === 'resolved' ? 'selected' : ''}>Resolved</option>
|
|
345
|
+
</select>
|
|
346
|
+
</div>
|
|
347
|
+
` : ''}
|
|
348
|
+
</div>
|
|
349
|
+
`;
|
|
350
|
+
|
|
351
|
+
// AI Draft Panel (Component 3)
|
|
352
|
+
if (fullThread.aiDraftResponse && fullThread.aiDraftStatus === 'pending') {
|
|
353
|
+
detailHTML += `
|
|
354
|
+
<div class="ai-draft-panel">
|
|
355
|
+
<div class="ai-draft-header">AI Suggested Response</div>
|
|
356
|
+
<div class="ai-draft-content">${fullThread.aiDraftResponse}</div>
|
|
357
|
+
<div class="ai-draft-actions">
|
|
358
|
+
<button id="acceptDraftBtn" class="btn-accept">Accept & Send</button>
|
|
359
|
+
<button id="editDraftBtn" class="btn-edit">Edit & Send</button>
|
|
360
|
+
<button id="rejectDraftBtn" class="btn-reject">Dismiss</button>
|
|
361
|
+
<button id="regenerateDraftBtn" class="btn-regenerate">Regenerate</button>
|
|
362
|
+
</div>
|
|
363
|
+
</div>
|
|
364
|
+
`;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Reply Box
|
|
368
|
+
detailHTML += `
|
|
369
|
+
<div style="margin-bottom: 1.5rem; padding: 1rem; background: #f9fafb; border-radius: 10px; border: 1px solid #e5e7eb;">
|
|
370
|
+
<div style="font-size: 0.85rem; font-weight: 600; margin-bottom: 0.75rem; color: #297B56;">Reply to Patient</div>
|
|
371
|
+
<textarea id="replyTextarea" rows="3" placeholder="Type your reply..." class="reply-textarea" maxlength="5000" aria-label="Reply message"></textarea>
|
|
372
|
+
<div class="reply-toolbar" style="margin-top: 0.5rem;">
|
|
373
|
+
<span id="replyCharCount" style="font-size:0.75rem;color:#9ca3af;" aria-live="polite"></span>
|
|
374
|
+
<span id="replyStatus" class="reply-status" aria-live="assertive"></span>
|
|
375
|
+
<button id="sendReplyBtn" class="filter-btn active" style="background: #297B56; color: white; border-color: #297B56;">Send Reply</button>
|
|
376
|
+
</div>
|
|
377
|
+
</div>
|
|
378
|
+
|
|
379
|
+
<div class="messages-list">
|
|
380
|
+
`;
|
|
381
|
+
|
|
382
|
+
if (messages.length === 0) {
|
|
383
|
+
detailHTML += '<p style="text-align: center; color: #6b7280;">No messages yet</p>';
|
|
384
|
+
} else {
|
|
385
|
+
messages.forEach(msg => {
|
|
386
|
+
let role = 'assistant';
|
|
387
|
+
let label = 'AI Assistant';
|
|
388
|
+
|
|
389
|
+
if (msg.role === 'patient' || msg.role === 'user') {
|
|
390
|
+
role = 'user';
|
|
391
|
+
label = 'Patient';
|
|
392
|
+
} else if (msg.role === 'doctor' || msg.isDoctorManual) {
|
|
393
|
+
role = 'doctor';
|
|
394
|
+
label = 'You (Doctor)';
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const rawText = msg.message || msg.content || '';
|
|
398
|
+
const safeText = escapeHtml(rawText);
|
|
399
|
+
const linkedText = linkifyUrls(safeText);
|
|
400
|
+
|
|
401
|
+
detailHTML += `
|
|
402
|
+
<div class="message-block">
|
|
403
|
+
<div class="message-label">${label} · ${formatTime(msg.createdAt)}</div>
|
|
404
|
+
<div class="message-content ${role}" style="white-space:pre-wrap;">${linkedText}</div>
|
|
405
|
+
</div>
|
|
406
|
+
`;
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
detailHTML += '</div>';
|
|
411
|
+
conversationDetail.innerHTML = detailHTML;
|
|
412
|
+
|
|
413
|
+
// Wire up event listeners
|
|
414
|
+
wireDetailEventListeners(thread, fullThread);
|
|
415
|
+
|
|
416
|
+
} catch (err) {
|
|
417
|
+
console.error('Error loading thread detail:', err);
|
|
418
|
+
conversationDetail.innerHTML = '<div style="text-align: center; color: #dc2626; padding: 2rem;">Unable to load conversation details</div>';
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// ============================================
|
|
423
|
+
// DETAIL EVENT LISTENERS
|
|
424
|
+
// ============================================
|
|
425
|
+
|
|
426
|
+
function wireDetailEventListeners(thread, fullThread) {
|
|
427
|
+
// Toggle AI override
|
|
428
|
+
const toggleBtn = document.getElementById('toggleOverrideBtn');
|
|
429
|
+
if (toggleBtn) {
|
|
430
|
+
toggleBtn.addEventListener('click', async () => {
|
|
431
|
+
const newOverride = !fullThread.doctorOverride;
|
|
432
|
+
await fetchWithAuth(`/api/doctor/thread/${thread.id}`, {
|
|
433
|
+
method: 'PATCH',
|
|
434
|
+
headers: { 'Content-Type': 'application/json' },
|
|
435
|
+
body: JSON.stringify({ doctorOverride: newOverride })
|
|
436
|
+
});
|
|
437
|
+
updateThreadLocally(thread.id, { doctorOverride: newOverride });
|
|
438
|
+
renderThreadsList();
|
|
439
|
+
renderThreadDetail(thread);
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// Clear escalation
|
|
444
|
+
const clearBtn = document.getElementById('clearEscalationBtn');
|
|
445
|
+
if (clearBtn) {
|
|
446
|
+
clearBtn.addEventListener('click', async () => {
|
|
447
|
+
await fetchWithAuth(`/api/doctor/thread/${thread.id}`, {
|
|
448
|
+
method: 'PATCH',
|
|
449
|
+
headers: { 'Content-Type': 'application/json' },
|
|
450
|
+
body: JSON.stringify({ isEscalated: false })
|
|
451
|
+
});
|
|
452
|
+
updateThreadLocally(thread.id, { isEscalated: false });
|
|
453
|
+
renderThreadsList();
|
|
454
|
+
renderThreadDetail(thread);
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// Archive
|
|
459
|
+
const archiveBtn = document.getElementById('archiveThreadBtn');
|
|
460
|
+
if (archiveBtn) {
|
|
461
|
+
archiveBtn.addEventListener('click', async () => {
|
|
462
|
+
if (!confirm('Archive this conversation?')) return;
|
|
463
|
+
await fetchWithAuth(`/api/doctor/thread/${thread.id}`, {
|
|
464
|
+
method: 'PATCH',
|
|
465
|
+
headers: { 'Content-Type': 'application/json' },
|
|
466
|
+
body: JSON.stringify({ status: 'archived' })
|
|
467
|
+
});
|
|
468
|
+
threads = threads.filter(t => t.id !== thread.id);
|
|
469
|
+
selectedThread = null;
|
|
470
|
+
renderThreadsList();
|
|
471
|
+
conversationDetail.innerHTML = '<div class="empty-state"><div class="empty-state-icon">📦</div><h3>Archived</h3><p>Select another conversation</p></div>';
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
const resolveBtn = document.getElementById('resolveThreadBtn');
|
|
476
|
+
if (resolveBtn) {
|
|
477
|
+
resolveBtn.addEventListener('click', async () => {
|
|
478
|
+
await fetchWithAuth(`/api/doctor/thread/${thread.id}`, {
|
|
479
|
+
method: 'PATCH',
|
|
480
|
+
headers: { 'Content-Type': 'application/json' },
|
|
481
|
+
body: JSON.stringify({ status: 'resolved' })
|
|
482
|
+
});
|
|
483
|
+
updateThreadLocally(thread.id, { status: 'resolved' });
|
|
484
|
+
renderThreadsList();
|
|
485
|
+
renderThreadDetail(thread);
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
const reopenBtn = document.getElementById('reopenThreadBtn');
|
|
490
|
+
if (reopenBtn) {
|
|
491
|
+
reopenBtn.addEventListener('click', async () => {
|
|
492
|
+
await fetchWithAuth(`/api/doctor/thread/${thread.id}`, {
|
|
493
|
+
method: 'PATCH',
|
|
494
|
+
headers: { 'Content-Type': 'application/json' },
|
|
495
|
+
body: JSON.stringify({ status: 'active' })
|
|
496
|
+
});
|
|
497
|
+
updateThreadLocally(thread.id, { status: 'active' });
|
|
498
|
+
renderThreadsList();
|
|
499
|
+
renderThreadDetail(thread);
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// Export transcript
|
|
504
|
+
const exportBtn = document.getElementById('exportThreadBtn');
|
|
505
|
+
if (exportBtn) {
|
|
506
|
+
exportBtn.addEventListener('click', async () => {
|
|
507
|
+
try {
|
|
508
|
+
let msgs;
|
|
509
|
+
if (thread._source === 'conversations') {
|
|
510
|
+
const res = await fetchWithAuth(`/api/conversation/${thread.id}`);
|
|
511
|
+
const data = await res.json();
|
|
512
|
+
msgs = data.messages || [];
|
|
513
|
+
} else {
|
|
514
|
+
const res = await fetchWithAuth(`/api/doctor/thread/${thread.id}`);
|
|
515
|
+
const data = await res.json();
|
|
516
|
+
msgs = data.messages || [];
|
|
517
|
+
}
|
|
518
|
+
const lines = msgs.map(m => {
|
|
519
|
+
const role = m.role === 'patient' || m.role === 'user' ? 'Patient' :
|
|
520
|
+
m.role === 'doctor' ? 'Doctor' : 'AI';
|
|
521
|
+
const text = m.message || m.content || '';
|
|
522
|
+
const time = m.createdAt ? new Date(m.createdAt._seconds * 1000).toLocaleString() : '';
|
|
523
|
+
return `[${time}] ${role}: ${text}`;
|
|
524
|
+
});
|
|
525
|
+
const blob = new Blob([lines.join('\n\n')], { type: 'text/plain' });
|
|
526
|
+
const url = URL.createObjectURL(blob);
|
|
527
|
+
const a = document.createElement('a');
|
|
528
|
+
a.href = url;
|
|
529
|
+
a.download = `transcript-${thread.id}.txt`;
|
|
530
|
+
document.body.appendChild(a);
|
|
531
|
+
a.click();
|
|
532
|
+
document.body.removeChild(a);
|
|
533
|
+
URL.revokeObjectURL(url);
|
|
534
|
+
} catch (err) {
|
|
535
|
+
console.error('Export failed:', err);
|
|
536
|
+
}
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// Triage override
|
|
541
|
+
const triageSelect = document.getElementById('triageOverride');
|
|
542
|
+
if (triageSelect) {
|
|
543
|
+
triageSelect.addEventListener('change', async () => {
|
|
544
|
+
const category = triageSelect.value;
|
|
545
|
+
if (!category) return;
|
|
546
|
+
await fetchWithAuth(`/api/doctor/thread/${thread.id}`, {
|
|
547
|
+
method: 'PATCH',
|
|
548
|
+
headers: { 'Content-Type': 'application/json' },
|
|
549
|
+
body: JSON.stringify({ triageCategory: category })
|
|
550
|
+
});
|
|
551
|
+
updateThreadLocally(thread.id, { triageCategory: category });
|
|
552
|
+
renderThreadsList();
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// Send reply
|
|
557
|
+
const sendBtn = document.getElementById('sendReplyBtn');
|
|
558
|
+
const replyTextarea = document.getElementById('replyTextarea');
|
|
559
|
+
const replyStatus = document.getElementById('replyStatus');
|
|
560
|
+
const replyCharCount = document.getElementById('replyCharCount');
|
|
561
|
+
const REPLY_MAX = 5000;
|
|
562
|
+
|
|
563
|
+
if (sendBtn && replyTextarea) {
|
|
564
|
+
replyTextarea.addEventListener('input', () => {
|
|
565
|
+
const len = replyTextarea.value.length;
|
|
566
|
+
if (replyCharCount && len > REPLY_MAX * 0.7) {
|
|
567
|
+
replyCharCount.textContent = `${len}/${REPLY_MAX}`;
|
|
568
|
+
replyCharCount.style.color = len > REPLY_MAX * 0.9 ? '#dc2626' : '#9ca3af';
|
|
569
|
+
} else if (replyCharCount) {
|
|
570
|
+
replyCharCount.textContent = '';
|
|
571
|
+
}
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
sendBtn.addEventListener('click', async () => {
|
|
575
|
+
const message = replyTextarea.value.trim();
|
|
576
|
+
if (!message) return;
|
|
577
|
+
if (message.length > REPLY_MAX) {
|
|
578
|
+
replyStatus.textContent = 'Message too long';
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
sendBtn.disabled = true;
|
|
583
|
+
replyStatus.textContent = 'Sending...';
|
|
584
|
+
|
|
585
|
+
try {
|
|
586
|
+
if (thread._source === 'conversations') {
|
|
587
|
+
await fetchWithAuth(`/api/conversation/${thread.id}/reply`, {
|
|
588
|
+
method: 'POST',
|
|
589
|
+
headers: { 'Content-Type': 'application/json' },
|
|
590
|
+
body: JSON.stringify({ message })
|
|
591
|
+
});
|
|
592
|
+
} else {
|
|
593
|
+
await fetchWithAuth('/api/doctor/message', {
|
|
594
|
+
method: 'POST',
|
|
595
|
+
headers: { 'Content-Type': 'application/json' },
|
|
596
|
+
body: JSON.stringify({ threadId: thread.id, message, override: true })
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
replyTextarea.value = '';
|
|
600
|
+
if (replyCharCount) replyCharCount.textContent = '';
|
|
601
|
+
replyStatus.textContent = 'Sent!';
|
|
602
|
+
setTimeout(() => { replyStatus.textContent = ''; }, 2000);
|
|
603
|
+
await renderThreadDetail(thread);
|
|
604
|
+
await loadThreads();
|
|
605
|
+
} catch (err) {
|
|
606
|
+
replyStatus.textContent = 'Failed to send. Try again.';
|
|
607
|
+
replyTextarea.focus();
|
|
608
|
+
} finally {
|
|
609
|
+
sendBtn.disabled = false;
|
|
610
|
+
}
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// AI Draft actions (Component 3)
|
|
615
|
+
const acceptBtn = document.getElementById('acceptDraftBtn');
|
|
616
|
+
const editBtn = document.getElementById('editDraftBtn');
|
|
617
|
+
const rejectBtn = document.getElementById('rejectDraftBtn');
|
|
618
|
+
const regenerateBtn = document.getElementById('regenerateDraftBtn');
|
|
619
|
+
|
|
620
|
+
if (acceptBtn) {
|
|
621
|
+
acceptBtn.addEventListener('click', async () => {
|
|
622
|
+
acceptBtn.disabled = true;
|
|
623
|
+
acceptBtn.textContent = 'Sending...';
|
|
624
|
+
try {
|
|
625
|
+
await fetchWithAuth('/api/doctor/draft/accept', {
|
|
626
|
+
method: 'POST',
|
|
627
|
+
headers: { 'Content-Type': 'application/json' },
|
|
628
|
+
body: JSON.stringify({ threadId: thread.id })
|
|
629
|
+
});
|
|
630
|
+
await renderThreadDetail(thread);
|
|
631
|
+
await loadThreads();
|
|
632
|
+
} catch (err) {
|
|
633
|
+
alert('Failed to accept draft');
|
|
634
|
+
acceptBtn.disabled = false;
|
|
635
|
+
acceptBtn.textContent = 'Accept & Send';
|
|
636
|
+
}
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
if (editBtn) {
|
|
641
|
+
editBtn.addEventListener('click', () => {
|
|
642
|
+
// Pre-fill reply textarea with draft content
|
|
643
|
+
if (replyTextarea) {
|
|
644
|
+
replyTextarea.value = fullThread.aiDraftResponse || '';
|
|
645
|
+
replyTextarea.focus();
|
|
646
|
+
replyTextarea.scrollIntoView({ behavior: 'smooth' });
|
|
647
|
+
}
|
|
648
|
+
// Reject the draft since doctor is editing
|
|
649
|
+
fetchWithAuth('/api/doctor/draft/reject', {
|
|
650
|
+
method: 'POST',
|
|
651
|
+
headers: { 'Content-Type': 'application/json' },
|
|
652
|
+
body: JSON.stringify({ threadId: thread.id })
|
|
653
|
+
}).catch(() => {});
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
if (rejectBtn) {
|
|
658
|
+
rejectBtn.addEventListener('click', async () => {
|
|
659
|
+
await fetchWithAuth('/api/doctor/draft/reject', {
|
|
660
|
+
method: 'POST',
|
|
661
|
+
headers: { 'Content-Type': 'application/json' },
|
|
662
|
+
body: JSON.stringify({ threadId: thread.id })
|
|
663
|
+
});
|
|
664
|
+
updateThreadLocally(thread.id, { aiDraftStatus: 'rejected', aiDraftResponse: null });
|
|
665
|
+
renderThreadDetail(thread);
|
|
666
|
+
});
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
if (regenerateBtn) {
|
|
670
|
+
regenerateBtn.addEventListener('click', async () => {
|
|
671
|
+
regenerateBtn.disabled = true;
|
|
672
|
+
regenerateBtn.textContent = 'Generating...';
|
|
673
|
+
try {
|
|
674
|
+
const res = await fetchWithAuth('/api/doctor/draft/regenerate', {
|
|
675
|
+
method: 'POST',
|
|
676
|
+
headers: { 'Content-Type': 'application/json' },
|
|
677
|
+
body: JSON.stringify({ threadId: thread.id })
|
|
678
|
+
});
|
|
679
|
+
const data = await res.json();
|
|
680
|
+
if (data.draft) {
|
|
681
|
+
updateThreadLocally(thread.id, { aiDraftResponse: data.draft, aiDraftStatus: 'pending' });
|
|
682
|
+
}
|
|
683
|
+
await renderThreadDetail(thread);
|
|
684
|
+
} catch (err) {
|
|
685
|
+
alert('Failed to regenerate draft');
|
|
686
|
+
regenerateBtn.disabled = false;
|
|
687
|
+
regenerateBtn.textContent = 'Regenerate';
|
|
688
|
+
}
|
|
689
|
+
});
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
function updateThreadLocally(threadId, updates) {
|
|
694
|
+
const idx = threads.findIndex(t => t.id === threadId);
|
|
695
|
+
if (idx >= 0) {
|
|
696
|
+
Object.assign(threads[idx], updates);
|
|
697
|
+
}
|
|
698
|
+
if (selectedThread && selectedThread.id === threadId) {
|
|
699
|
+
Object.assign(selectedThread, updates);
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
// ============================================
|
|
704
|
+
// NOTIFICATIONS (Component 4)
|
|
705
|
+
// ============================================
|
|
706
|
+
|
|
707
|
+
async function loadNotifications() {
|
|
708
|
+
try {
|
|
709
|
+
const res = await fetchWithAuth('/api/doctor/notifications?limit=20');
|
|
710
|
+
if (!res.ok) return;
|
|
711
|
+
const data = await res.json();
|
|
712
|
+
|
|
713
|
+
const countEl = document.getElementById('notificationCount');
|
|
714
|
+
const listEl = document.getElementById('notificationList');
|
|
715
|
+
|
|
716
|
+
if (data.unreadCount > 0) {
|
|
717
|
+
countEl.textContent = data.unreadCount;
|
|
718
|
+
countEl.style.display = 'flex';
|
|
719
|
+
} else {
|
|
720
|
+
countEl.style.display = 'none';
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
if (!data.notifications || data.notifications.length === 0) {
|
|
724
|
+
listEl.innerHTML = '<div style="padding: 1rem; text-align: center; color: #6b7280; font-size: 0.85rem;">No notifications</div>';
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
listEl.innerHTML = data.notifications.map(n => `
|
|
729
|
+
<div class="notification-item ${n.read ? '' : 'unread'}" data-thread-id="${n.threadId || ''}">
|
|
730
|
+
<div class="notification-item-title">${escapeHtml(n.title || '')}</div>
|
|
731
|
+
<div class="notification-item-body">${escapeHtml(n.body || '')}</div>
|
|
732
|
+
</div>
|
|
733
|
+
`).join('');
|
|
734
|
+
|
|
735
|
+
// Click on notification to jump to thread
|
|
736
|
+
listEl.querySelectorAll('.notification-item').forEach(el => {
|
|
737
|
+
el.addEventListener('click', () => {
|
|
738
|
+
const threadId = el.dataset.threadId;
|
|
739
|
+
if (threadId) {
|
|
740
|
+
const thread = threads.find(t => t.id === threadId);
|
|
741
|
+
if (thread) {
|
|
742
|
+
selectedThread = thread;
|
|
743
|
+
renderThreadsList();
|
|
744
|
+
renderThreadDetail(thread);
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
document.getElementById('notificationDropdown').classList.remove('open');
|
|
748
|
+
});
|
|
749
|
+
});
|
|
750
|
+
} catch (err) {
|
|
751
|
+
console.error('Error loading notifications:', err);
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
// Toggle notification dropdown
|
|
756
|
+
document.getElementById('notificationBell').addEventListener('click', (e) => {
|
|
757
|
+
e.stopPropagation();
|
|
758
|
+
const dropdown = document.getElementById('notificationDropdown');
|
|
759
|
+
dropdown.classList.toggle('open');
|
|
760
|
+
if (dropdown.classList.contains('open')) {
|
|
761
|
+
loadNotifications();
|
|
762
|
+
}
|
|
763
|
+
});
|
|
764
|
+
|
|
765
|
+
// Close dropdown when clicking elsewhere
|
|
766
|
+
document.addEventListener('click', () => {
|
|
767
|
+
document.getElementById('notificationDropdown').classList.remove('open');
|
|
768
|
+
});
|
|
769
|
+
|
|
770
|
+
// Mark all as read
|
|
771
|
+
document.getElementById('markAllReadBtn').addEventListener('click', async (e) => {
|
|
772
|
+
e.stopPropagation();
|
|
773
|
+
await fetchWithAuth('/api/doctor/notifications/read', {
|
|
774
|
+
method: 'POST',
|
|
775
|
+
headers: { 'Content-Type': 'application/json' },
|
|
776
|
+
body: JSON.stringify({})
|
|
777
|
+
});
|
|
778
|
+
document.getElementById('notificationCount').style.display = 'none';
|
|
779
|
+
loadNotifications();
|
|
780
|
+
});
|
|
781
|
+
|
|
782
|
+
// ============================================
|
|
783
|
+
// FILTER BUTTONS
|
|
784
|
+
// ============================================
|
|
785
|
+
|
|
786
|
+
document.querySelectorAll('.filter-btn[data-filter]').forEach(btn => {
|
|
787
|
+
btn.addEventListener('click', () => {
|
|
788
|
+
document.querySelectorAll('.filter-btn[data-filter]').forEach(b => b.classList.remove('active'));
|
|
789
|
+
btn.classList.add('active');
|
|
790
|
+
currentFilter = btn.dataset.filter;
|
|
791
|
+
renderThreadsList();
|
|
792
|
+
});
|
|
793
|
+
});
|
|
794
|
+
|
|
795
|
+
const threadSearchInput = document.getElementById('threadSearchInput');
|
|
796
|
+
if (threadSearchInput) {
|
|
797
|
+
let searchTimer;
|
|
798
|
+
threadSearchInput.addEventListener('input', () => {
|
|
799
|
+
clearTimeout(searchTimer);
|
|
800
|
+
searchTimer = setTimeout(() => {
|
|
801
|
+
threadSearchQuery = threadSearchInput.value.trim().toLowerCase();
|
|
802
|
+
renderThreadsList();
|
|
803
|
+
}, 250);
|
|
804
|
+
});
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
// ============================================
|
|
808
|
+
// SIGN OUT
|
|
809
|
+
// ============================================
|
|
810
|
+
|
|
811
|
+
signOutBtn.addEventListener('click', async () => {
|
|
812
|
+
await firebase.auth().signOut();
|
|
813
|
+
window.location.href = '/app';
|
|
814
|
+
});
|
|
815
|
+
|
|
816
|
+
// ============================================
|
|
817
|
+
// INITIALIZE
|
|
818
|
+
// ============================================
|
|
819
|
+
|
|
820
|
+
firebase.auth().onAuthStateChanged(async (user) => {
|
|
821
|
+
if (!user) {
|
|
822
|
+
authRequired.classList.remove('hidden');
|
|
823
|
+
inboxContainer.classList.add('hidden');
|
|
824
|
+
return;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
authRequired.classList.add('hidden');
|
|
828
|
+
inboxContainer.classList.remove('hidden');
|
|
829
|
+
|
|
830
|
+
currentProvider = await loadProvider();
|
|
831
|
+
if (!currentProvider) {
|
|
832
|
+
authRequired.classList.remove('hidden');
|
|
833
|
+
inboxContainer.classList.add('hidden');
|
|
834
|
+
return;
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
await loadThreads();
|
|
838
|
+
await loadNotifications();
|
|
839
|
+
|
|
840
|
+
// Auto-refresh every 30 seconds
|
|
841
|
+
setInterval(loadThreads, 30000);
|
|
842
|
+
setInterval(loadNotifications, 60000);
|
|
843
|
+
});
|