1// rezeption-v2 (Digitale Rezeption - Praxis-Inbox, CI-konform Bootstrap) 2// reception-task-link-v1 (2026-07-27): Rueckruf-Vorgang und Rueckruf-Aufgabe waren 3// zwei lose Kopien. Neu: die Detailansicht zeigt die verknuepfte Aufgabe und - wo 4// das Ticket-Modul sichtbar ist - einen Sprung ins Ticketsystem auf genau diesen Vorgang. 5// reception-detail-v1 (2026-07-27): "Details" fuehrte ins Leere - der Knopf sprang nur 6// allgemein ins Ticketsystem (Dashboard, ohne den Vorgang), das in der Physio-/Praxis- 7// Edition zudem ausgeblendet ist. Neu: vollstaendige Detailansicht direkt in der Rezeption. 8// reception-appointments-v1 (2026-07-27): Rezeption und Kalender waren nicht verknuepft - 9// beim Patienten/Vorgang stand nirgends, wann er das naechste Mal kommt. Neu: der naechste 10// (und letzte) Termin aus dem Kalender direkt am Vorgang, zugeordnet ueber E-Mail, 11// Rufnummer oder vollstaendigen Namen. 12// reception-assignee-view-v1 (2026-07-28): Es fehlte die Uebersicht, welcher 13// Mitarbeiter welche offenen Vorgaenge uebernommen hat. Neu: eine Zeile mit einem 14// Feld je Mitarbeiter (inkl. "Noch niemand") - ein Klick zeigt genau dessen 15// Vorgaenge. Die Zaehlung folgt der aktuellen Status-/Anliegen-Auswahl. 16// reception-assign-v1 (2026-07-27): Uebernahme sichtbar machen. "Annehmen" liess das 17// Ticket bisher aus der Standardansicht verschwinden (Filter "Offen" = new|open), obwohl 18// es nur uebernommen und nicht erledigt war. Neu: Standardansicht "Aktiv" (offen + in 19// Bearbeitung), Anzeige "Uebernommen von XY", Freigeben und Wieder oeffnen. 20/** 21 * voiceOne Digitale Rezeption - Praxis-Inbox fuer typisierte Anliegen 22 * (Termin/Rezept/Befund/AU/Ueberweisung/Rueckruf/Frage) aus Web-Chat, 23 * WhatsApp und Telefon. Liest das bestehende Ticketsystem (/api/tickets) 24 * und zeigt nur Vorgaenge mit customFields.requestType. 25 */ 26import { Auth } from './auth.js'; 27import { Language } from './language.js'; 28// reception-appointment-edit-v1 / calendar-sync-v1 (2026-07-27): Termine lassen 29// sich hier verschieben und absagen; Aenderungen aus dem Kalender kommen sofort an. 30import { appointmentActionsHtml, appointmentFormSlotHtml, bindAppointmentActions, 31 isAppointmentFormOpen } from './appointmentEdit.js'; 32import { onCalendarChanged } from './calendarSync.js'; 33// waitlist-ui-v1 (2026-08-05): Die Warteliste ist der zweite Teil der 34// Rezeption - wer keinen Termin bekommen hat, faellt sonst hinten runter. 35import { loadWartelistePage, stopWarteliste } from './warteliste.js?v=20260805-wlfix1-merk1'; 36 37function t(s) { return Language.translate(s); } 38const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); 39 40const TYPES = { 41 termin: { label: 'Termin', icon: 'fa-calendar-check', badge: 'bg-primary' }, 42 rezept: { label: 'Rezept', icon: 'fa-prescription-bottle-alt', badge: 'bg-success' }, 43 befund: { label: 'Befund', icon: 'fa-file-medical-alt', badge: 'bg-info' }, 44 au: { label: 'AU', icon: 'fa-notes-medical', badge: 'bg-warning text-dark' }, 45 ueberweisung: { label: 'Ãberweisung', icon: 'fa-share-square', badge: 'bg-info' }, 46 rueckruf: { label: 'Rückruf', icon: 'fa-phone-volume', badge: 'bg-danger' }, 47 frage: { label: 'Anfrage', icon: 'fa-question-circle', badge: 'bg-secondary' }, 48}; 49const CHANNELS = { 50 chat: { label: 'Web-Chat', icon: 'fa-comments' }, 51 web: { label: 'Web-Chat', icon: 'fa-comments' }, 52 web_chat: { label: 'Web-Chat', icon: 'fa-comments' }, 53 whatsapp: { label: 'WhatsApp', icon: 'fa-whatsapp', brand: true }, 54 phone: { label: 'Telefon', icon: 'fa-phone' }, 55}; 56const OPEN_STATUSES = ['new', 'open']; 57const DONE_STATUSES = ['resolved', 'closed']; 58 59let _token = null; 60let _tickets = []; 61let _tab = 'anliegen'; // waitlist-ui-v1: anliegen | warteliste 62let _statusFilter = 'aktiv'; // aktiv | offen | in_progress | erledigt | alle 63let _typeFilter = ''; // '' = alle 64let _assigneeFilter = ''; // '' = alle | '__none__' = niemand | sonst uid 65let _pollTimer = null; 66let _me = null; 67let _detailTid = null; // reception-detail-v1: gerade geoeffneter Vorgang 68// reception-appointments-v1: Vorgangs-ID -> Kalendertermine des Patienten. 69// undefined = noch nicht geladen, Objekt = Ergebnis (auch "keine Termine"). 70let _appts = {}; 71let _apptsPending = false;
72// Laufende Nummer der Sammelabfrage: trifft eine aeltere Antwort nach einer 73// neueren ein, wird sie verworfen statt den frischen Stand zu ueberschreiben. 74let _apptGen = 0; 75// Letzter Verlaufs-Zustand der Detailansicht, damit ein spaeter eintreffendes 76// Termin-Ergebnis den bereits geladenen Verlauf nicht wieder auf "laedt..." setzt. 77let _lastActState = { loading: true }; 78// rez-kappung-v1: Deckel der Listenabfrage + Merker, ob er gegriffen hat. 79const REZ_LIMIT = 200; 80let _gekappt = false; 81 82async function ensureToken() { 83 const u = Auth.getCurrentUser(); 84 if (!u) throw new Error('Nicht angemeldet'); 85 _token = await u.getIdToken(); 86 return _token; 87} 88 89async function api(path, opts = {}) { 90 const tk = await ensureToken(); 91 const r = await fetch(path, { 92 ...opts, 93 headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${tk}`, ...(opts.headers || {}) }, 94 }); 95 if (!r.ok) throw new Error(`HTTP ${r.status}`); 96 return r.json(); 97} 98 99/** 100 * reception-assign-v1: angemeldeten Mitarbeiter ermitteln (fuer "Uebernommen von XY"). 101 * Primaer /api/users/me (Anzeigename aus dem Benutzerkonto), Fallback auf die 102 * Firebase-Identitaet, damit die Uebernahme auch ohne Profilnamen funktioniert. 103 */ 104async function whoAmI() { 105 if (_me) return _me; 106 let uid = ''; 107 let name = ''; 108 try { 109 const d = await api('/api/users/me'); 110 uid = d.uid || ''; 111 name = String(d.displayName || d.display_name || '').trim(); 112 // 'User' ist der Platzhalter des Backends fuer Konten ohne Anzeigenamen - 113 // dann sagt die E-Mail-Adresse mehr darueber aus, wer uebernommen hat. 114 if (!name || name === 'User') name = String(d.email || '').trim(); 115 } catch (e) { 116 console.warn('Rezeption: /api/users/me nicht verfuegbar, nutze Firebase-Identitaet', e?.message || e); 117 } 118 if (!uid || !name) { 119 const u = Auth.getCurrentUser(); 120 uid = uid || (u && u.uid) || ''; 121 name = name || String((u && (u.displayName || u.email)) || '').trim(); 122 } 123 _me = { uid, name: name || t('Mitarbeiter') }; 124 return _me; 125} 126 127function toDate(v) { 128 if (!v) return null; 129 if (typeof v === 'object' && v.seconds != null) return new Date(v.seconds * 1000); 130 const d = new Date(v); 131 return isNaN(d.getTime()) ? null : d; 132} 133 134function relTime(v) { 135 const d = toDate(v); 136 if (!d) return ''; 137 const mins = Math.floor((Date.now() - d.getTime()) / 60000); 138 if (mins < 1) return 'gerade eben'; 139 if (mins < 60) return `vor ${mins} Min.`; 140 const h = Math.floor(mins / 60); 141 if (h < 24) return `vor ${h} Std.`; 142 const days = Math.floor(h / 24); 143 if (days === 1) return 'gestern'; 144 if (days < 7) return `vor ${days} Tagen`; 145 return d.toLocaleDateString('de-DE'); 146} 147 148function isReception(ticket) { 149 return !!(ticket && ticket.customFields && ticket.customFields.requestType); 150} 151 152// --- reception-appointments-v1: Termine des Patienten ---------------------- 153 154/** "Di., 28.07.2026, 14:00 Uhr" - dieselbe Lesart wie im Kalender. */ 155function apptWhen(iso) { 156 const d = new Date(iso); 157 if (isNaN(d.getTime())) return String(iso || ''); 158 const day = d.toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: '2-digit', year: 'numeric' }); 159 const time = d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }); 160 return `${day}, ${time} Uhr`; 161} 162 163/** Kurzform fuer die Karte: "heute 14:00", "morgen 09:30", sonst "28.07., 14:00". */ 164function apptShort(iso) { 165 const d = new Date(iso); 166 if (isNaN(d.getTime())) return String(iso || ''); 167 const time = d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }); 168 const midnight = new Date(); midnight.setHours(0, 0, 0, 0); 169 const days = Math.floor((new Date(d).setHours(0, 0, 0, 0) - midnight.getTime()) / 86400000); 170 if (days === 0) return `${t('heute')} ${time}`; 171 if (days === 1) return `${t('morgen')} ${time}`; 172 return `${d.toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: '2-digit' })}, ${time}`; 173} 174
175/** Ein Termin als Zeile: Zeitpunkt, Titel, Ort. */ 176function apptLine(a) { 177 const bits = [`<strong>${esc(apptWhen(a.start))}</strong>`]; 178 if (a.title) bits.push(esc(a.title)); 179 if (a.location) bits.push(`<span class="text-muted">${esc(a.location)}</span>`); 180 return bits.join(' · '); 181} 182 183/** 184 * Ueber welches Merkmal wurde zugeordnet? Beim Namen ist das ein Indiz, kein 185 * Beweis - das muss dranstehen, sonst wird ein Namensvetter zum falschen Termin. 186 */ 187function apptMatchNote(matchedBy) { 188 if (matchedBy === 'name') { 189 return `<div class="small text-muted mt-1"><i class="fas fa-info-circle me-1"></i>${t('Ãber den Namen zugeordnet â bitte prüfen.')}</div>`; 190 } 191 return ''; 192} 193 194/** Sammelabfrage fuer die Liste - eine Anfrage statt einer je Karte. */ 195async function loadAppointments(tickets, host, force) { 196 const contacts = (tickets || []) 197 .filter(tk => tk.customerEmail || tk.customerPhone || tk.customerName) 198 .slice(0, 60) 199 .map(tk => ({ key: tk.id, email: tk.customerEmail || '', phone: tk.customerPhone || '', name: tk.customerName || '' })); 200 if (!contacts.length) return; 201 if (_apptsPending && !force) return; 202 const gen = ++_apptGen; 203 _apptsPending = true; 204 try { 205 const d = await api('/api/calendar/contact-appointments/batch', 206 { method: 'POST', body: JSON.stringify({ contacts }) }); 207 if (gen !== _apptGen) return; 208 _appts = Object.assign({}, _appts, d.results || {}); 209 if (host && document.body.contains(host)) render(host); 210 } catch (e) { 211 // Termine sind eine Zusatzinformation - die Rezeption bleibt ohne sie nutzbar. 212 console.warn('Rezeption: Termine nicht ladbar', e && e.message || e); 213 } finally { 214 if (gen === _apptGen) _apptsPending = false; 215 } 216} 217 218/** Einzelabfrage beim Oeffnen eines Vorgangs - dort zaehlt der aktuelle Stand. */ 219async function loadAppointmentFor(tk) { 220 const q = new URLSearchParams({ 221 email: tk.customerEmail || '', phone: tk.customerPhone || '', name: tk.customerName || '', 222 }); 223 if (!(tk.customerEmail || tk.customerPhone || tk.customerName)) return null; 224 const d = await api(`/api/calendar/contact-appointments?${q.toString()}`); 225 _appts[tk.id] = d; 226 return d; 227} 228 229/** 230 * Sprung in den Kalender auf den Tag des Termins. Der Kalender wird asynchron 231 * aufgebaut, deshalb wird kurz auf seine Instanz gewartet - klappt das nicht, 232 * bleibt der Nutzer immerhin im Kalender stehen. 233 */ 234function openInCalendar(startIso) { 235 const nav = document.querySelector('.nav-link[data-page="calendar"]'); 236 if (!nav || nav.offsetParent === null) { 237 alert(t('Der Kalender ist für dieses Konto nicht freigeschaltet.')); 238 return; 239 } 240 const modalEl = document.getElementById(DETAIL_MODAL_ID); 241 if (modalEl) { 242 const inst = bootstrap.Modal.getInstance(modalEl); 243 if (inst) inst.hide(); 244 } 245 nav.click(); 246 const target = new Date(startIso); 247 if (isNaN(target.getTime())) return; 248 let tries = 0; 249 const timer = setInterval(() => { 250 tries += 1; 251 const instances = (window.Calendar && window.Calendar.calendarInstances) || []; 252 if (instances.length) { 253 clearInterval(timer); 254 instances.forEach(ci => { try { ci.gotoDate(target); } catch (e) { /* Ansicht ohne gotoDate */ } }); 255 } else if (tries > 40) { 256 clearInterval(timer); 257 } 258 }, 150); 259} 260 261function isOpen(st) { return OPEN_STATUSES.includes(st); } 262function isDone(st) { return DONE_STATUSES.includes(st); } 263 264/** 265 * rez-filter-v1 (2026-08-13): Der zweite Parameter hiess "ignoreAssignee" und war 266 * ein einfacher Wahrheitswert. Aufgerufen wurde die Funktion aber als 267 * `_tickets.filter(matchesFilter)` - und Array.filter uebergibt an dieser Stelle 268 * den INDEX. Damit war der Mitarbeiter-Filter fuer jede Karte ab der zweiten 269 * ausgeschaltet: wer "Anna" waehlte, sah trotzdem die Vorgaenge aller Kollegen. 270 * Jetzt eine benannte Auswahl (Objekt), die ein Index nicht versehentlich setzen
271 * kann, plus ein ausdruecklicher Aufruf an der Filterstelle. 272 */ 273function matchesFilter(ticket, opts) { 274 const o = (opts === true) ? { ohneMitarbeiter: true } : (opts || {}); 275 const st = ticket.status || 'new'; 276 if (!o.ohneStatus) { 277 // "aktiv" = alles was noch Arbeit macht (offen ODER von jemandem uebernommen). 278 // Standardansicht, damit ein angenommenes Ticket sichtbar bleibt. 279 if (_statusFilter === 'aktiv' && isDone(st)) return false; 280 if (_statusFilter === 'offen' && !isOpen(st)) return false; 281 if (_statusFilter === 'in_progress' && st !== 'in_progress') return false; 282 if (_statusFilter === 'erledigt' && !isDone(st)) return false; 283 } 284 if (!o.ohneTyp && _typeFilter && ((ticket.customFields || {}).requestType !== _typeFilter)) return false; 285 if (!o.ohneMitarbeiter && _assigneeFilter) { 286 const who = assigneeKey(ticket); 287 if (_assigneeFilter === '__none__' ? who !== '' : who !== _assigneeFilter) return false; 288 } 289 return true; 290} 291 292/** Kennung des zustaendigen Mitarbeiters ('' = noch niemand). */ 293function assigneeKey(tk) { 294 return String((tk && tk.assignedTo) || '').trim(); 295} 296 297/** 298 * reception-assignee-view-v1: Vorgaenge je Mitarbeiter zaehlen - auf Basis der 299 * aktuellen Status-/Anliegen-Auswahl, aber unabhaengig davon, welcher Mitarbeiter 300 * gerade ausgewaehlt ist (sonst zeigte jedes Feld immer nur sich selbst). 301 */ 302function assigneeBuckets() { 303 const map = new Map(); 304 let none = 0; 305 for (const tk of _tickets) { 306 if (!matchesFilter(tk, true)) continue; 307 const uid = assigneeKey(tk); 308 if (!uid) { none++; continue; } 309 const name = String(tk.assignedToName || '').trim() || t('Unbekannter Mitarbeiter'); 310 const cur = map.get(uid) || { uid, name, n: 0 }; 311 cur.name = name; 312 cur.n++; 313 map.set(uid, cur); 314 } 315 const list = Array.from(map.values()).sort((a, b) => b.n - a.n || a.name.localeCompare(b.name, 'de')); 316 return { list, none }; 317} 318 319function statusLabel(st) { 320 return ({ new: 'Neu', open: 'Offen', in_progress: 'In Bearbeitung', resolved: 'Erledigt', closed: 'Geschlossen' })[st] || st; 321} 322 323function statusBadgeClass(st) { 324 if (isOpen(st)) return 'bg-warning text-dark'; 325 if (st === 'in_progress') return 'bg-primary'; 326 if (isDone(st)) return 'bg-success'; 327 return 'bg-secondary'; 328} 329 330/** 331 * rez-zaehler-v1 (2026-08-13): Die Zahlen zeigten den Gesamtbestand, waehrend die 332 * Liste gefiltert war - bei Status "Erledigt" stand am Anliegen-Knopf weiter 333 * "Rezept 3", obwohl nur ein erledigtes Rezept existierte. Die Mitarbeiterzeile 334 * rechnete schon immer richtig; die Differenz war der Fehler. Jetzt gilt fuer 335 * alle drei Zeilen dieselbe Regel: eine Zahl sagt, was ein Klick darauf liefert. 336 */ 337function counts() { 338 const c = { aktiv: 0, offen: 0, in_progress: 0, erledigt: 0, alle: 0, types: {} }; 339 for (const tk of _tickets) { 340 const st = tk.status || 'new'; 341 if (matchesFilter(tk, { ohneStatus: true })) { 342 if (isOpen(st)) c.offen++; 343 else if (st === 'in_progress') c.in_progress++; 344 else if (isDone(st)) c.erledigt++; 345 if (!isDone(st)) c.aktiv++; 346 c.alle++; 347 } 348 if (matchesFilter(tk, { ohneTyp: true })) { 349 const ty = (tk.customFields || {}).requestType; 350 c.types[ty] = (c.types[ty] || 0) + 1; 351 } 352 } 353 return c; 354} 355 356/** 357 * reception-assign-v1: "Von Mitarbeiter XY uebernommen" sichtbar machen. 358 * Wird angezeigt sobald ein Ticket einem Mitarbeiter zugeordnet ist - bei erledigten 359 * Vorgaengen als Bearbeiter-Nachweis. 360 */ 361function assigneeHtml(tk) { 362 const name = String(tk.assignedToName || '').trim(); 363 if (!name && !tk.assignedTo) return ''; 364 const st = tk.status || 'new'; 365 const who = name || t('einem Mitarbeiter'); 366 const when = tk.assignedAt ? ` · ${esc(relTime(tk.assignedAt))}` : ''; 367 const label = isDone(st) ? t('Bearbeitet von') : t('Ãbernommen von'); 368 const tone = isDone(st) ? 'text-muted' : 'text-primary fw-semibold'; 369 return `<div class="small ${tone} mb-2"><i class="fas fa-user-check me-1"></i>${label} ${esc(who)}${when}</div>`; 370} 371 372/**
373 * reception-detail-v1: Statusaenderung an einer Stelle, damit Karte und Detailansicht 374 * exakt dasselbe tun. Haelt die lokale Liste synchron und liefert den Hinweistext. 375 */ 376async function applyAction(tid, act) { 377 let body; 378 let note; 379 if (act === 'take') { 380 const me = await whoAmI(); 381 body = { status: 'in_progress', assignedTo: me.uid, assignedToName: me.name }; 382 note = `${t('Ãbernommen von')} ${me.name}`; 383 } else if (act === 'release') { 384 body = { status: 'open', assignedTo: '', assignedToName: '' }; 385 note = t('Freigegeben â liegt wieder unter âOffenâ'); 386 } else if (act === 'reopen') { 387 body = { status: 'open', assignedTo: '', assignedToName: '' }; 388 note = t('Wieder geöffnet'); 389 } else { 390 body = { status: 'resolved' }; 391 note = t('Als erledigt markiert â zu finden unter âErledigtâ'); 392 } 393 await api(`/api/tickets/${encodeURIComponent(tid)}`, { method: 'PUT', body: JSON.stringify(body) }); 394 const tk = _tickets.find(x => x.id === tid); 395 if (tk) { 396 Object.assign(tk, body); 397 if (act === 'take') tk.assignedAt = new Date().toISOString(); 398 if (act === 'release' || act === 'reopen') tk.assignedAt = null; 399 } 400 return note; 401} 402 403// --- Detailansicht --------------------------------------------------------- 404 405const DETAIL_MODAL_ID = 'rezDetailModal'; 406// Intern/technisch oder bereits an anderer Stelle sichtbar - nicht doppelt zeigen. 407const DETAIL_HIDDEN_FIELDS = new Set(['requestType', 'requestTypeLabel', 'channel', 'agentId']); 408const DETAIL_FIELD_LABELS = { callerNumber: 'Anrufer-Nummer', sourceUrl: 'Herkunft' }; 409 410function prettyKey(k) { 411 if (DETAIL_FIELD_LABELS[k]) return t(DETAIL_FIELD_LABELS[k]); 412 return String(k).replace(/[_-]+/g, ' ').replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/^./, c => c.toUpperCase()); 413} 414 415/** Zusatzangaben generisch anzeigen, damit kuenftige Anliegen-Arten nichts verlieren. */ 416function extraFieldsHtml(cf) { 417 const rows = []; 418 for (const [k, v] of Object.entries(cf || {})) { 419 if (DETAIL_HIDDEN_FIELDS.has(k)) continue; 420 let val; 421 if (Array.isArray(v)) val = v.filter(x => x != null && typeof x !== 'object').join(', '); 422 else if (v && typeof v === 'object') continue; 423 else val = String(v ?? ''); 424 val = val.trim(); 425 if (!val) continue; 426 rows.push(`<div class="col-sm-6 mb-2"><div class="text-muted small">${esc(prettyKey(k))}</div><div>${esc(val)}</div></div>`); 427 } 428 return rows.length ? `<div class="row g-0 mt-2">${rows.join('')}</div>` : ''; 429} 430 431/** 432 * reception-task-link-v1: Ist das Ticket-Modul fuer diesen Nutzer ueberhaupt da? 433 * Die Sichtbarkeit haengt an zwei Dingen - dem Recht "tickets" und der Edition 434 * (Physio/Gastro blenden den Menuepunkt per CSS aus). Beides zeigt sich am 435 * Menuepunkt selbst, deshalb fragen wir den statt Rechte nachzubauen. 436 */ 437function ticketModuleVisible() { 438 const nav = document.querySelector('.nav-link[data-page="tickets"]'); 439 if (!nav) return false; 440 // offsetParent === null heisst: per display:none ausgeblendet (Edition/Recht). 441 return nav.offsetParent !== null; 442} 443 444/** Wechselt auf die Ticket-Seite und oeffnet dort genau diesen Vorgang. */ 445function openInTicketSystem(tid) { 446 const nav = document.querySelector('.nav-link[data-page="tickets"]'); 447 if (!nav) return; 448 // Aus der Liste heraus ist kein Modal offen - dann nichts schliessen 449 // (getOrCreateInstance wuerde sonst ungefragt eines anlegen). 450 const _openModal = document.getElementById('rezDetailModal'); 451 if (_openModal) { 452 const _inst = bootstrap.Modal.getInstance(_openModal); 453 if (_inst) _inst.hide(); 454 } 455 // ticket-deeplink-v1: Absicht VOR der Navigation hinterlegen. Der Aufbau der 456 // Ticket-Seite liest sie und springt selbst in den Vorgang - sonst setzt er 457 // die Ansicht direkt nach unserem Sprung wieder aufs Dashboard zurueck. 458 window.__ticketDeepLink = tid; 459 nav.click(); 460 // Auffangnetz: greift der Seitenaufbau die Absicht nicht auf, nach ~1.2s 461 // selbst anspringen. Laeuft nur solange die Absicht unverbraucht ist. 462 let tries = 0; 463 const timer = setInterval(() => { 464 tries += 1; 465 if (window.__ticketDeepLink !== tid) { clearInterval(timer); return; } // verbraucht 466 if (tries < 8) return; 467 clearInterval(timer); 468 window.__ticketDeepLink = null; 469 if (window.ticketSystem && typeof window.ticketSystem.loadTicketDetail === 'function') { 470 window.ticketSystem.loadTicketDetail(tid); 471 } 472 }, 150); 473} 474 475/** Verknuepfte Rueckruf-Aufgabe anzeigen (task.ticket_id <-> ticket.taskId). */ 476function linkedTaskHtml(tk) { 477 if (!tk.taskId) return ''; 478 return ` 479 <div class="alert alert-light border d-flex justify-content-between align-items-center py-2 px-3 mb-3">
480 <span><i class="fas fa-list-check me-2 text-muted"></i>${t('Verknüpfte Aufgabe im Aufgaben-Board')}</span> 481 <button class="btn btn-sm btn-outline-secondary" data-act="open-task">${t('Aufgabe öffnen')}</button> 482 </div>`; 483} 484 485/** 486 * reception-appointments-v1: Terminblock der Detailansicht. Zeigt den naechsten 487 * Termin, den letzten Besuch und - wo vorhanden - weitere kommende Termine. 488 */ 489function apptDetailHtml(tk) { 490 const a = _appts[tk.id]; 491 if (a === undefined) { 492 return `<div class="card bg-light border-0 mb-3"><div class="card-body py-2 px-3 text-muted small"> 493 <i class="fas fa-spinner fa-spin me-1"></i>${t('Termine werden geladen...')}</div></div>`; 494 } 495 const rows = []; 496 if (a.next) { 497 rows.push(`<div class="mb-1"><i class="fas fa-calendar-check text-primary me-2"></i>${t('Nächster Termin')}: ${apptLine(a.next)}</div>`); 498 const more = (a.upcoming || []).slice(1); 499 if (more.length) { 500 rows.push(`<div class="small text-muted mb-1"><i class="fas fa-calendar-plus me-2"></i>${t('Danach')}: ${more.map(x => esc(apptWhen(x.start))).join(' · ')}${a.upcomingCount > a.upcoming.length ? ` (+${a.upcomingCount - a.upcoming.length})` : ''}</div>`); 501 } 502 } else { 503 rows.push(`<div class="mb-1 text-muted"><i class="far fa-calendar me-2"></i>${t('Kein kommender Termin im Kalender')}</div>`); 504 } 505 if (a.previous) { 506 rows.push(`<div class="small text-muted"><i class="fas fa-history me-2"></i>${t('Letzter Termin')}: ${esc(apptWhen(a.previous.start))}${a.previous.title ? ' · ' + esc(a.previous.title) : ''}</div>`); 507 } 508 const jump = a.next 509 ? `<button class="btn btn-sm btn-outline-primary" data-act="open-calendar" data-start="${esc(a.next.start)}"><i class="fas fa-calendar-alt me-1"></i>${t('Im Kalender öffnen')}</button>` 510 : ''; 511 return ` 512 <div class="card bg-light border-0 mb-3" data-appt-box><div class="card-body py-2 px-3"> 513 <div class="d-flex justify-content-between align-items-start gap-2 flex-wrap"> 514 <div>${rows.join('')}${apptMatchNote(a.matchedBy)}</div> 515 <div class="d-flex gap-2 flex-wrap">${jump}${appointmentActionsHtml(a)}</div> 516 </div> 517 ${appointmentFormSlotHtml()} 518 </div></div>`; 519} 520 521/** Kurzhinweis auf der Karte - nur wenn es wirklich einen Termin gibt. */ 522function apptChipHtml(tk) { 523 const a = _appts[tk.id]; 524 if (!a || !a.next) return ''; 525 return `<div class="small text-primary mb-2"><i class="fas fa-calendar-check me-2"></i>${t('Nächster Termin')}: ${esc(apptShort(a.next.start))}</div>`; 526} 527 528function activityAuthor(a) { 529 const n = String(a.createdByName || '').trim(); 530 // 'User' ist der Platzhalter aus der Registrierung - dann sagt der eigene 531 // Anzeigename mehr, sofern der Vermerk vom angemeldeten Mitarbeiter stammt. 532 if (n && n !== 'User') return n; 533 if (_me && a.createdBy && a.createdBy === _me.uid) return _me.name; 534 return t('Mitarbeiter'); 535} 536 537function activitiesHtml(state) { 538 if (state.loading) return `<div class="text-muted small"><i class="fas fa-spinner fa-spin me-1"></i>${t('Lade Verlauf...')}</div>`; 539 if (state.failed) return `<div class="text-muted small">${t('Verlauf konnte nicht geladen werden.')}</div>`; 540 const acts = (state.items || []).filter(a => String(a.content || '').trim()); 541 if (!acts.length) return `<div class="text-muted small">${t('Noch keine Vermerke.')}</div>`; 542 return acts.map(a => ` 543 <div class="border-start border-3 border-light ps-3 mb-3"> 544 <div class="small text-muted"> 545 <i class="fas fa-user-edit me-1"></i>${esc(activityAuthor(a))} · ${esc(relTime(a.createdAt))} 546 ${a.isInternal ? `<span class="badge bg-light text-dark border ms-1">${t('intern')}</span>` : ''} 547 </div> 548 <div style="white-space:pre-line;">${esc(a.content)}</div> 549 </div>`).join(''); 550} 551 552function actionButtonsHtml(st) { 553 const b = []; 554 if (isOpen(st)) b.push(`<button class="btn btn-sm btn-outline-primary" data-act="take">${t('Annehmen')}</button>`); 555 if (st === 'in_progress') b.push(`<button class="btn btn-sm btn-outline-secondary" data-act="release">${t('Freigeben')}</button>`); 556 if (!isDone(st)) b.push(`<button class="btn btn-sm btn-outline-success" data-act="done"><i class="fas fa-check me-1"></i>${t('Erledigt')}</button>`); 557 if (isDone(st)) b.push(`<button class="btn btn-sm btn-outline-warning" data-act="reopen">${t('Wieder öffnen')}</button>`); 558 return b.join(''); 559} 560 561function detailBodyHtml(tk, actState) { 562 const cf = tk.customFields || {}; 563 const ty = TYPES[cf.requestType] || { label: cf.requestTypeLabel || cf.requestType, icon: 'fa-tag', badge: 'bg-secondary' }; 564 const st = tk.status || 'new'; 565 const ch = CHANNELS[(cf.channel || '').toLowerCase()] || CHANNELS[(tk.source || '').toLowerCase()]; 566 const contact = []; 567 if (tk.customerName) contact.push(`<div class="mb-1"><i class="fas fa-user me-2 text-muted"></i>${esc(tk.customerName)}
567</div>`); 568 if (tk.customerPhone) contact.push(`<div class="mb-1"><i class="fas fa-phone me-2 text-muted"></i><a href="tel:${esc(tk.customerPhone)}">${esc(tk.customerPhone)}</a></div>`); 569 if (tk.customerEmail) contact.push(`<div class="mb-1"><i class="fas fa-envelope me-2 text-muted"></i><a href="mailto:${esc(tk.customerEmail)}">${esc(tk.customerEmail)}</a></div>`); 570 const meta = []; 571 if (ch) meta.push(`<i class="${ch.brand ? 'fab' : 'fas'} ${ch.icon} me-1"></i>${esc(ch.label)}`); 572 if (tk.createdAt) meta.push(`${t('Eingegangen')}: ${esc(relTime(tk.createdAt))}`); 573 if (tk.updatedAt) meta.push(`${t('Zuletzt geändert')}: ${esc(relTime(tk.updatedAt))}`); 574 return ` 575 <div class="d-flex flex-wrap gap-2 align-items-center mb-3"> 576 <span class="badge ${ty.badge}"><i class="fas ${ty.icon} me-1"></i>${esc(ty.label)}</span> 577 <span class="badge ${statusBadgeClass(st)}">${esc(statusLabel(st))}</span> 578 <span class="text-muted small">${esc(tk.ticketNumber || '')}</span> 579 </div> 580 ${assigneeHtml(tk)} 581 ${linkedTaskHtml(tk)} 582 <h5 class="mb-3">${esc(tk.subject || t('(ohne Betreff)'))}</h5> 583 ${contact.length ? `<div class="card bg-light border-0 mb-3"><div class="card-body py-2 px-3">${contact.join('')}</div></div>` : ''} 584 ${apptDetailHtml(tk)} 585 <div class="text-muted small mb-1">${t('Anliegen')}</div> 586 <div class="mb-3" style="white-space:pre-line;">${tk.description ? esc(tk.description) : `<span class="text-muted">${t('Kein Text hinterlegt.')}</span>`}</div> 587 ${extraFieldsHtml(cf)} 588 ${meta.length ? `<div class="text-muted small border-top pt-2 mt-2">${meta.join(' · ')}</div>` : ''} 589 <hr> 590 <div class="text-muted small mb-2">${t('Verlauf')}</div> 591 <div id="rezDetailActivities">${activitiesHtml(actState)}</div> 592 <div class="mt-3"> 593 <label for="rezDetailNote" class="form-label small mb-1">${t('Interner Vermerk')}</label> 594 <textarea class="form-control form-control-sm" id="rezDetailNote" rows="2" placeholder="${t('z.B. Patient angerufen, nicht erreicht')}"></textarea> 595 <div class="d-flex justify-content-between align-items-center mt-1"> 596 <small class="text-muted">${t('Nur intern sichtbar â der Patient erhält keine Nachricht.')}</small> 597 <button class="btn btn-sm btn-outline-primary" data-act="note">${t('Vermerk speichern')}</button> 598 </div> 599 </div>`; 600} 601 602function ensureDetailModal() { 603 let el = document.getElementById(DETAIL_MODAL_ID); 604 if (el) return el; 605 el = document.createElement('div'); 606 el.id = DETAIL_MODAL_ID; 607 el.className = 'modal fade'; 608 el.tabIndex = -1; 609 el.innerHTML = ` 610 <div class="modal-dialog modal-lg modal-dialog-scrollable"> 611 <div class="modal-content"> 612 <div class="modal-header"> 613 <h5 class="modal-title">${t('Anliegen')}</h5> 614 <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="${t('SchlieÃen')}"></button> 615 </div> 616 <div class="modal-body"></div> 617 <div class="modal-footer flex-wrap gap-2 justify-content-between"></div> 618 </div> 619 </div>`; 620 document.body.appendChild(el); 621 el.addEventListener('hidden.bs.modal', () => { _detailTid = null; }); 622 return el; 623} 624 625/** Baut den Inhalt neu auf und verdrahtet die Knoepfe. */ 626function paintDetail(el, tk, host, actState) { 627 const st = tk.status || 'new'; 628 _lastActState = actState || _lastActState; 629 // rez-vermerk-v1 (2026-08-13): Das Fenster wird mehrfach neu aufgebaut (Verlauf, 630 // Termine, Minuten-Takt). Ein halb getippter Vermerk ging dabei jedes Mal 631 // verloren - genau waehrend der Mitarbeiter telefoniert und mitschreibt. 632 const _alt = el.querySelector('#rezDetailNote'); 633 const _entwurf = _alt ? _alt.value : ''; 634 const _hatteFokus = !!(_alt && document.activeElement === _alt); 635 const _cursor = _alt ? _alt.selectionStart : 0; 636 el.querySelector('.modal-body').innerHTML = detailBodyHtml(tk, actState); 637 if (_entwurf) { 638 const _neu = el.querySelector('#rezDetailNote'); 639 if (_neu) { 640 _neu.value = _entwurf; 641 if (_hatteFokus) { _neu.focus(); try { _neu.setSelectionRange(_cursor, _cursor); } catch (e) { /* aeltere Browser */ } } 642 } 643 } 644 // reception-appointment-edit-v1: Verschieben/Absagen im Terminblock verdrahten. 645 const _apptBox = el.querySelector('[data-appt-box]'); 646 if (_apptBox) bindAppointmentActions(_apptBox, { 647 data: _appts[tk.id], ticketId: tk.id, onChanged: reloadAppointmentsSoon, 648 }); 649 // reception-task-link-v1: Sprung ins Ticketsystem nur anbieten, wo es das Modul 650 // ueberhaupt gibt - in der Praxis-/Physio-Edition ist es ausgeblendet und der 651 // Knopf liefe ins Leere (genau der Fehler, den die Detailansicht abgeloest hat). 652 const ticketLinkBtn = ticketModuleVisible() 653 ? `<button type="button" class="btn btn-link btn-sm text-decoration-none px-0" data-act="open-ticket"> 654 <i class="fas fa-up-right-from-square me-1"></i>${t('Im Ticketsystem öffnen')}</button>` 655 : ''; 656 el.querySelector('.modal-footer').innerHTML = 657 `<div class="d-flex gap-2 flex-wrap align-items-center">${actionButtonsHtml(st)}${ticketLinkBtn}</div> 658 <button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">${t('SchlieÃen')}</button>`; 659 el.querySelectorAll('[data-act]').forEach(btn => btn.addEventListener('click', async () => { 660 const act = btn.dataset.act; 661 const tid = tk.id; 662 if (act === 'open-ticket') { openInTicketSystem(tid); return; } 663 if (act === 'open-task') { openLinkedTask(tk); return; } 664 if (act === 'open-calendar') { openInCalendar(btn.dataset.start); return; } 665 btn.disabled = true; 666 try { 667 if (act === 'note') { 668 const ta = el.querySelector('#rezDetailNote'); 669 const text = String(ta && ta.value || '').trim(); 670 if (!text) { btn.disabled = false; return; } 671 // isInternal: der Patient bekommt dadurch KEINE E-Mail. 672 await api(`/api/tickets/${encodeURIComponent(tid)}/comments`, 673 { method: 'POST', body: JSON.stringify({ content: text, isInternal: true }) }); 674 if (ta) ta.value = ''; 675 await loadDetailActivities(el, tk, host); 676 toast(t('Vermerk gespeichert')); 677 return; 678 } 679 const note = await applyAction(tid, act); 680 render(host); 681 paintDetail(el, tk, host, actState); 682 toast(note); 683 } catch (e) { 684 console.error('Rezeption: Detail-Aktion fehlgeschlagen', e); 685 btn.disabled = false;
686 alert(t('Aktualisierung fehlgeschlagen. Bitte erneut versuchen.')); 687 } 688 })); 689} 690 691/** Verlauf nachladen - die Liste selbst bleibt Quelle fuer die Stammdaten. */ 692async function loadDetailActivities(el, tk, host) { 693 const state = { loading: true }; 694 paintDetail(el, tk, host, state); 695 try { 696 const d = await api(`/api/tickets/${encodeURIComponent(tk.id)}`); 697 const full = (d && d.ticket) || {}; 698 if (_detailTid !== tk.id) return; 699 paintDetail(el, tk, host, { items: full.activities || [] }); 700 } catch (e) { 701 console.warn('Rezeption: Verlauf nicht ladbar', e && e.message || e); 702 if (_detailTid === tk.id) paintDetail(el, tk, host, { failed: true }); 703 } 704} 705 706/** 707 * Wechselt auf das Aufgaben-Board. Ein Deeplink auf die einzelne Aufgabe gibt es 708 * dort nicht, deshalb wird die Vorgangsnummer in die Zwischenablage gelegt - 709 * damit findet man die Aufgabe ueber die Suche sofort wieder. 710 */ 711function openLinkedTask(tk) { 712 const nav = document.querySelector('.nav-link[data-page="mytask"]'); 713 bootstrap.Modal.getOrCreateInstance(ensureDetailModal()).hide(); 714 if (tk.ticketNumber && navigator.clipboard) { 715 navigator.clipboard.writeText(tk.ticketNumber).catch(() => {}); 716 } 717 if (nav) { 718 nav.click(); 719 toast(t('Aufgaben-Board geöffnet â Vorgangsnummer in der Zwischenablage.')); 720 } else { 721 alert(t('Das Aufgaben-Board ist für dieses Konto nicht freigeschaltet.')); 722 } 723} 724 725async function openDetail(tid, host) { 726 const tk = _tickets.find(x => x.id === tid); 727 if (!tk) return; 728 const el = ensureDetailModal(); 729 _detailTid = tid; 730 const ty = TYPES[(tk.customFields || {}).requestType]; 731 el.querySelector('.modal-title').textContent = 732 `${(ty && ty.label) || t('Anliegen')} · ${tk.ticketNumber || ''}`.trim(); 733 paintDetail(el, tk, host, { loading: true }); 734 bootstrap.Modal.getOrCreateInstance(el).show(); 735 // Name des angemeldeten Mitarbeiters fuer die Autorenzeile vorbereiten. 736 whoAmI().catch(() => {}); 737 // reception-appointments-v1: Termine frisch holen - im geoeffneten Vorgang 738 // zaehlt der aktuelle Stand, nicht der aus der letzten Listenaktualisierung. 739 loadAppointmentFor(tk) 740 .then(() => { if (_detailTid === tid) paintDetail(el, tk, host, _lastActState); }) 741 .catch(e => { 742 console.warn('Rezeption: Termine des Vorgangs nicht ladbar', e && e.message || e); 743 if (_appts[tk.id] === undefined) _appts[tk.id] = { matchedBy: '', next: null, previous: null, upcoming: [], upcomingCount: 0 }; 744 if (_detailTid === tid) paintDetail(el, tk, host, _lastActState); 745 }); 746 await loadDetailActivities(el, tk, host); 747} 748 749function cardHtml(tk) { 750 const ty = TYPES[tk.customFields.requestType] || { label: tk.customFields.requestTypeLabel || tk.customFields.requestType, icon: 'fa-tag', badge: 'bg-secondary' }; 751 const st = tk.status || 'new'; 752 const ch = CHANNELS[(tk.customFields.channel || '').toLowerCase()] || CHANNELS[(tk.source || '').toLowerCase()]; 753 const contact = []; 754 if (tk.customerName) contact.push(`<div><i class="fas fa-user me-2 text-muted"></i>${esc(tk.customerName)}</div>`); 755 if (tk.customerPhone) contact.push(`<div><i class="fas fa-phone me-2 text-muted"></i><a href="tel:${esc(tk.customerPhone)}">${esc(tk.customerPhone)}</a></div>`); 756 if (tk.customerEmail) contact.push(`<div><i class="fas fa-envelope me-2 text-muted"></i>${esc(tk.customerEmail)}</div>`); 757 const canTake = isOpen(st); 758 const canRelease = st === 'in_progress'; 759 const canDone = !isDone(st); 760 const canReopen = isDone(st); 761 return ` 762 <div class="col-md-6 col-xl-4 mb-3"> 763 <div class="card h-100" data-tid="${esc(tk.id)}"> 764 <div class="card-header bg-transparent d-flex justify-content-between align-items-center"> 765 <span class="badge ${ty.badge}"><i class="fas ${ty.icon} me-1"></i>${esc(ty.label)}</span> 766 <span class="badge ${statusBadgeClass(st)}">${esc(statusLabel(st))}</span> 767 </div> 768 <div class="card-body"> 769 <h6 class="card-title mb-2">${esc(tk.subject || '(ohne Betreff)')}</h6> 770 ${assigneeHtml(tk)} 771 <div class="small mb-2">${contact.join('')}</div> 772 ${apptChipHtml(tk)} 773 ${tk.description ? `<p class="card-text small text-muted mb-0" title="${esc(tk.description)}" style="white-space:pre-line; display:-webkit-box; -webkit-box-orient:vertical; -webkit-line-clamp:4; line-clamp:4; overflow:hidden;">${esc(tk.description)}</p>` : ''} <!-- reception-clamp-v1 --> 774 </div> 775 <div class="card-footer bg-transparent d-flex justify-content-between align-items-center flex-wrap gap-2">
776 <span class="small text-muted"> 777 ${esc(tk.ticketNumber || '')} 778 ${ch ? ` · <i class="${ch.brand ? 'fab' : 'fas'} ${ch.icon}"></i> ${esc(ch.label)}` : ''} 779 · ${esc(relTime(tk.createdAt))} 780 </span> 781 <span class="d-flex gap-1"> 782 ${canTake ? `<button class="btn btn-sm btn-outline-primary" data-act="take">${t('Annehmen')}</button>` : ''} 783 ${canRelease ? `<button class="btn btn-sm btn-outline-secondary" data-act="release" title="${t('Zurück in die offenen Anliegen legen')}">${t('Freigeben')}</button>` : ''} 784 ${canDone ? `<button class="btn btn-sm btn-outline-success" data-act="done"><i class="fas fa-check me-1"></i>${t('Erledigt')}</button>` : ''} 785 ${canReopen ? `<button class="btn btn-sm btn-outline-warning" data-act="reopen">${t('Wieder öffnen')}</button>` : ''} 786 <button class="btn btn-sm btn-outline-secondary" data-act="open">${t('Details')}</button> 787 </span> 788 </div> 789 </div> 790 </div>`; 791} 792 793/** Kurze Rueckmeldung, damit nachvollziehbar ist wohin ein Vorgang gewandert ist. */ 794function toast(msg) { 795 let host = document.getElementById('rezToastHost'); 796 if (!host) { 797 host = document.createElement('div'); 798 host.id = 'rezToastHost'; 799 host.style.cssText = 'position:fixed;bottom:1rem;right:1rem;z-index:1080;display:flex;flex-direction:column;gap:.5rem;max-width:22rem;'; 800 document.body.appendChild(host); 801 } 802 const el = document.createElement('div'); 803 el.className = 'alert alert-dark shadow-sm py-2 px-3 mb-0 small'; 804 el.style.cssText = 'opacity:0;transition:opacity .2s;'; 805 el.textContent = msg; 806 host.appendChild(el); 807 requestAnimationFrame(() => { el.style.opacity = '1'; }); 808 setTimeout(() => { el.style.opacity = '0'; setTimeout(() => el.remove(), 250); }, 3500); 809} 810 811// waitlist-ui-v1: eine Quelle fuer beide Reiter - so kann die Beschriftung 812// nicht auseinanderlaufen. 813function tabsHtml() { 814 const tabs = [['anliegen', 'Anliegen', 'fa-inbox'], ['warteliste', 'Warteliste', 'fa-user-clock']]; 815 return `<div class="d-flex gap-2 mb-3" data-rez-tabs>` + tabs.map(([k, lbl, ic]) => ` 816 <button class="btn btn-sm ${_tab === k ? 'btn-success' : 'btn-outline-secondary'}" data-tab="${k}"> 817 <i class="fas ${ic} me-1"></i>${t(lbl)}</button>`).join('') + `</div>`; 818} 819 820function bindTabs(host) { 821 host.querySelectorAll('[data-rez-tabs] button[data-tab]').forEach(el => 822 el.addEventListener('click', () => { 823 if (_tab === el.dataset.tab) return; 824 _tab = el.dataset.tab; 825 if (_tab !== 'warteliste') stopWarteliste(); 826 render(host); 827 if (_tab === 'anliegen') refresh(host, false); 828 })); 829} 830 831function render(host) { 832 // waitlist-ui-v1: Weiche VOR dem Aufbau der Anliegen-Ansicht - sonst 833 // wuerde der Minuten-Takt die geoeffnete Warteliste ueberschreiben. 834 if (_tab === 'warteliste') { 835 if (!host.querySelector('#rezWaitlistHost')) { 836 host.innerHTML = `<div style="padding: 1.5rem;">${tabsHtml()}<div id="rezWaitlistHost"></div></div>`; 837 bindTabs(host); 838 loadWartelistePage(host.querySelector('#rezWaitlistHost')); 839 } 840 return; 841 } 842 const c = counts(); 843 const visible = _tickets.filter(tk => matchesFilter(tk)); // rez-filter-v1 844 const statCards = [ 845 ['aktiv', 'Aktiv', c.aktiv, 'border-info', 'Offene und übernommene Anliegen'], 846 ['offen', 'Offen', c.offen, 'border-warning', 'Noch niemand hat übernommen'], 847 ['in_progress', 'In Bearbeitung', c.in_progress, 'border-primary', 'Von einem Mitarbeiter übernommen'], 848 ['erledigt', 'Erledigt', c.erledigt, 'border-success', 'Abgeschlossene Anliegen'], 849 ['alle', 'Alle', c.alle, 'border-secondary', 'Alle Anliegen'], 850 ].map(([k, lbl, n, border, hint]) => { 851 // rez-auswahl-v1 (2026-08-13): Die gewaehlte Kachel war nur an einem 852 // Schatten zu erkennen - auf weissen Karten vor grauem Grund praktisch 853 // unsichtbar. Damit war nicht ablesbar, welcher Ausschnitt gerade gilt, 854 // waehrend Anliegen-Art und Mitarbeiter ihre Auswahl deutlich zeigen. 855 const _an = _statusFilter === k; 856 return ` 857 <div class="col"> 858 <div class="card h-100 border-start border-4 ${border} ${_an ? 'shadow-sm' : ''}" data-status="${k}" role="button" 859 aria-pressed="${_an ? 'true' : 'false'}" 860 style="cursor:pointer;${_an ? 'background:#eef4f8;border-top:1px solid #2e6b94;border-right:1px solid #2e6b94;border-bottom:1px solid #2e6b94;' : ''}" 861 title="${esc(t(hint))}"> 862 <div class="card-body text-center py-3"> 863 <h3 class="mb-0" style="${_an ? 'color:#2e6b94;' : ''}">${n}</h3> 864 <div class="small ${_an ? 'fw-semibold' : 'text-muted'}" style="${_an ? 'color:#2e6b94;' : ''}">${t(lbl)}</div> 865 </div> 866 </div> 867 </div>`; }).join(''); 868 const typeButtons = [`<button class="btn btn-sm ${_typeFilter === '' ? 'btn-primary' : 'btn-outline-primary'}" data-type="">${t('Alle Anliegen')}</button>`] 869 .concat(Object.entries(TYPES).map(([k, v]) => 870 `<button class="btn btn-sm ${_typeFilter === k ? 'btn-primary' : 'btn-outline-primary'}" data-type="${k}"><i class="fas ${v.icon} me-1"></i>${esc(v.label)} <span class="badge ${_typeFilter === k ? 'bg-light text-dark' : 'bg-secondary'} ms-1">
870${c.types[k] || 0}</span></button>`)) 871 .join(''); 872 // reception-assignee-view-v1: Zeile nur zeigen, wenn ueberhaupt jemand 873 // etwas uebernommen hat - sonst waere sie in kleinen Praxen nur Ballast. 874 const _bk = assigneeBuckets(); 875 // rez-sackgasse-v1 (2026-08-13): Verschwand der gewaehlte Mitarbeiter aus der 876 // Zeile (z.B. nach einem Statuswechsel), fiel die ganze Zeile weg - der Filter 877 // blieb aber aktiv. Ergebnis: "Keine Anliegen in dieser Ansicht" ohne jeden 878 // sichtbaren Grund und ohne Weg zurueck. Ist eine Auswahl aktiv, bleibt die 879 // Zeile stehen und der gewaehlte Mitarbeiter wird - notfalls mit 0 - gezeigt. 880 if (_assigneeFilter && _assigneeFilter !== '__none__' 881 && !_bk.list.some(x => x.uid === _assigneeFilter)) { 882 const _tkA = _tickets.find(x => assigneeKey(x) === _assigneeFilter); 883 _bk.list.push({ uid: _assigneeFilter, n: 0, 884 name: String((_tkA && _tkA.assignedToName) || '').trim() || t('Unbekannter Mitarbeiter') }); 885 } 886 const _zeigeMitarbeiter = _bk.list.length || _bk.none || _assigneeFilter; 887 const assigneeButtons = _zeigeMitarbeiter ? ( 888 [`<span class="text-muted small me-1 align-self-center">${t('Mitarbeiter')}:</span>`, 889 `<button class="btn btn-sm ${_assigneeFilter === '' ? 'btn-secondary' : 'btn-outline-secondary'}" data-assignee="">${t('Alle')}</button>`] 890 .concat(_bk.list.map(x => 891 `<button class="btn btn-sm ${_assigneeFilter === x.uid ? 'btn-secondary' : 'btn-outline-secondary'}" data-assignee="${esc(x.uid)}" title="${esc(t('Von diesem Mitarbeiter übernommen'))}"><i class="fas fa-user-check me-1"></i>${esc(x.name)} <span class="badge ${_assigneeFilter === x.uid ? 'bg-light text-dark' : 'bg-secondary'} ms-1">${x.n}</span></button>`)) 892 .concat((_bk.none || _assigneeFilter === '__none__') ? [ 893 `<button class="btn btn-sm ${_assigneeFilter === '__none__' ? 'btn-secondary' : 'btn-outline-secondary'}" data-assignee="__none__" title="${esc(t('Noch von niemandem übernommen'))}"><i class="fas fa-user-slash me-1"></i>${t('Noch niemand')} <span class="badge ${_assigneeFilter === '__none__' ? 'bg-light text-dark' : 'bg-secondary'} ms-1">${_bk.none}</span></button>`] : []) 894 .join('') 895 ) : ''; 896 897 host.innerHTML = ` 898 <div style="padding: 1.5rem;"> 899 ${tabsHtml()} 900 <div class="d-flex justify-content-between align-items-center mb-4 flex-wrap gap-2"> 901 <div> 902 <h1 style="margin: 0; font-size: 1.5rem; color: #2e2e2e;">${t('Digitale Rezeption')}</h1> 903 <div class="text-muted small">${t('Anliegen aus Web-Chat, WhatsApp und Telefon an einem Ort')}</div> 904 </div> 905 <button class="btn btn-sm btn-outline-primary" data-act="refresh"><i class="fas fa-sync-alt me-1"></i>${t('Aktualisieren')}</button> 906 </div> 907 <div class="row row-cols-2 row-cols-md-3 row-cols-xl-5 g-2 mb-3">${statCards}</div> 908 <div class="d-flex flex-wrap gap-2 mb-3">${typeButtons}</div> 909 ${assigneeButtons ? `<div class="d-flex flex-wrap gap-2 mb-4">${assigneeButtons}</div>` : ''} 910 ${_gekappt ? `<div class="alert alert-warning py-2 px-3 small"><i class="fas fa-info-circle me-1"></i>${t('Es werden die neuesten')} ${REZ_LIMIT} ${t('Vorgänge geladen â ältere Anliegen sind hier nicht enthalten.')}</div>` : ''} 911 ${visible.length 912 ? `<div class="row">${visible.map(cardHtml).join('')}</div>` 913 : `<div class="text-center p-5"><i class="fas fa-inbox fa-3x text-muted mb-3 d-block"></i><h4 class="text-muted">${t('Keine Anliegen in dieser Ansicht')}</h4></div>`} 914 </div>`; 915 916 bindTabs(host); // waitlist-ui-v1 917 host.querySelectorAll('.card[data-status]').forEach(el => el.addEventListener('click', () => { _statusFilter = el.dataset.status; render(host); })); 918 host.querySelectorAll('button[data-type]').forEach(el => el.addEventListener('click', () => { _typeFilter = el.dataset.type; render(host); })); 919 host.querySelectorAll('button[data-assignee]').forEach(el => el.addEventListener('click', () => { _assigneeFilter = el.dataset.assignee; render(host); })); 920 const refreshEl = host.querySelector('button[data-act="refresh"]'); 921 if (refreshEl) refreshEl.addEventListener('click', () => refresh(host, true)); 922 host.querySelectorAll('.card[data-tid] button[data-act]').forEach(btn => btn.addEventListener('click', async (ev) => { 923 const card = ev.target.closest('.card[data-tid]'); 924 const tid = card ? card.dataset.tid : null; 925 if (!tid) return; 926 const act = btn.dataset.act; 927 // reception-detail-nav-v1: "Details" fuehrt in den Vorgang im 928 // Ticketsystem. Nur wo das Ticket-Modul ausgeblendet ist 929 // (Praxis-/Physio-Edition) bleibt die eingebaute Detailansicht -
930 // dort gaebe es sonst kein Sprungziel. 931 if (act === 'open') { 932 if (ticketModuleVisible()) openInTicketSystem(tid); 933 else openDetail(tid, host); 934 return; 935 } 936 btn.disabled = true; 937 try { 938 const note = await applyAction(tid, act); 939 render(host); 940 toast(note); 941 } catch (e) { 942 console.error('Rezeption: update failed', e); 943 btn.disabled = false; 944 alert(t('Aktualisierung fehlgeschlagen. Bitte erneut versuchen.')); 945 } 946 })); 947} 948 949// --- calendar-sync-v1: Termine nach einer Aenderung neu holen --------------- 950let _apptReloadTimer = null; 951 952/** 953 * Buendelt kurz: die eigene Aenderung und die Meldung darueber treffen fast 954 * gleichzeitig ein und sollen nur eine Abfrage ausloesen. 955 */ 956function reloadAppointmentsSoon() { 957 if (_apptReloadTimer) clearTimeout(_apptReloadTimer); 958 _apptReloadTimer = setTimeout(async () => { 959 _apptReloadTimer = null; 960 const host = document.getElementById('rezeptionHost'); 961 const el = document.getElementById(DETAIL_MODAL_ID); 962 // Zuerst der geoeffnete Vorgang - dort schaut der Mitarbeiter gerade hin. 963 if (_detailTid && el) { 964 const tk = _tickets.find(x => x.id === _detailTid); 965 if (tk && !isAppointmentFormOpen(el)) { 966 try { await loadAppointmentFor(tk); } 967 catch (e) { console.warn('Rezeption: Termine nicht neu ladbar', e && e.message || e); } 968 if (_detailTid === tk.id) paintDetail(el, tk, host, _lastActState); 969 } 970 } 971 if (host && document.body.contains(host)) loadAppointments(_tickets, host, true); 972 }, 250); 973} 974 975// Ein Empfaenger auf Modulebene: aendert jemand einen Termin im Kalender (oder in 976// einem zweiten Browser-Tab), zeigt die Rezeption sofort den neuen Stand. 977onCalendarChanged(reloadAppointmentsSoon); 978 979async function refresh(host, force) { 980 // waitlist-ui-v1: Die Warteliste bringt ihren eigenen Takt mit. 981 if (_tab !== 'anliegen') return; 982 try { 983 const data = await api(`/api/tickets?limit=${REZ_LIMIT}`); 984 const alle = data.tickets || []; 985 _tickets = alle.filter(isReception); 986 // rez-kappung-v1: Haengt die Liste an der Ausschnittsgrenze, muss das 987 // dranstehen - sonst liest sich ein unvollstaendiger Stand wie der ganze. 988 _gekappt = alle.length >= REZ_LIMIT; 989 render(host); 990 // rez-detailstand-v1 (2026-08-13): Der Minuten-Takt ersetzt die Liste 991 // vollstaendig. Ein offenes Fenster arbeitete danach auf einem Objekt, das 992 // es nicht mehr gibt - die Knoepfe zeigten den Stand von vor der 993 // Aktualisierung. Jetzt bekommt das offene Fenster den frischen Vorgang. 994 if (_detailTid) { 995 const _el = document.getElementById(DETAIL_MODAL_ID); 996 const _frisch = _tickets.find(x => x.id === _detailTid); 997 if (_el && _frisch && _el.classList.contains('show') && !isAppointmentFormOpen(_el)) { 998 paintDetail(_el, _frisch, host, _lastActState); 999 } 1000 } 1001 // reception-appointments-v1: Termine nachladen und die Liste danach 1002 // erneut zeichnen - die Anliegen sind sofort da, der Kalender folgt. 1003 loadAppointments(_tickets, host); 1004 } catch (e) { 1005 console.error('Rezeption: load failed', e); 1006 if (force || !_tickets.length) { 1007 host.innerHTML = `<div class="text-center p-5"><i class="fas fa-exclamation-triangle fa-3x text-muted mb-3 d-block"></i><h4 class="text-muted">${t('Anliegen konnten nicht geladen werden')}</h4></div>`; 1008 } 1009 } 1010} 1011 1012function startRezPoll() { 1013 if (_pollTimer) clearInterval(_pollTimer); 1014 _pollTimer = setInterval(() => { 1015 const h = document.getElementById('rezeptionHost'); 1016 if (!h || !document.body.contains(h)) { 1017 clearInterval(_pollTimer); _pollTimer = null; 1018 stopWarteliste(); // waitlist-p1-v1: kein Takt ohne Ansicht 1019 return; 1020 } 1021 refresh(h, false); 1022 }, 60000); 1023} 1024 1025export async function loadRezeptionPage() { 1026 const host = document.getElementById('rezeptionHost'); 1027 if (!host) return; 1028 // waitlist-p1-v1: _tab ist eine Modul-Variable und ueberlebt den 1029 // Seitenwechsel. Ohne diese Weiche liefe refresh() in seinen frueh-Ausstieg 1030 // (`if (_tab !== 'anliegen') return`) und die Seite bliebe fuer den Rest der 1031 // Sitzung im Ladezustand haengen - der Schutz gegen den Minuten-Takt hat 1032 // korrekt gegriffen, nur kannte der Einstiegspunkt ihn nicht. 1033 if (_tab !== 'anliegen') { 1034 host.innerHTML = ''; // erzwingt den Neuaufbau in render() 1035 render(host); 1036 startRezPoll(); 1037 return; 1038 } 1039 host.innerHTML = `<div class="text-center p-5"><div class="spinner-border text-primary"></div><p class="mt-2">${t('Lade Anliegen...')}</p></div>`; 1040 await refresh(host, true); 1041 startRezPoll(); 1042}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.