PageSourceSearch

https://app.medi-one.ai/modules/rezeption.js?v=20260805-wlfix1-merk1-rez1-rez2-rezauto1

js medi-one.ai collected 2026-10-05 14:15:23 UTC 53,853 bytes, 1,042 lines download raw bytes

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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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.