PageSourceSearch

https://app.medi-one.ai/modules/contactDetail.js?v=20260807-noshow…1-kont1--kd1-prax1-nsl1-nsl2-impauth1-tdup2

js medi-one.ai collected 2026-10-05 14:15:43 UTC 293,141 bytes, 5,619 lines download raw bytes

1// // phase8-contactDetail-migrated
2import { usersApi } from './usersApi.js';
3async function _phase8UserSnap(uid) {
4  try { const me = await usersApi.me(); return { exists: () => !!me, data: () => me, id: (me && me.uid) || uid }; }
5  catch (e) { return { exists: () => false, data: () => null }; }
6}
7// [tsfix] typ-tolerante Timestamp->millis Normalisierung (Firestore-Timestamp | {_seconds|seconds} | ISO-String | Zahl | Date)
8function _tsToMillis(v) {
9  if (v == null) return 0;
10  if (typeof v === 'number') return v < 1e12 ? v * 1000 : v;
11  if (typeof v === 'string') { const m = Date.parse(v); return isNaN(m) ? 0 : m; }
12  if (typeof v.toMillis === 'function') { try { return v.toMillis(); } catch (e) { return 0; } }
13  if (typeof v.toDate === 'function') { try { return v.toDate().getTime(); } catch (e) { return 0; } }
14  if (typeof v._seconds === 'number') return v._seconds * 1000 + (v._nanoseconds ? Math.floor(v._nanoseconds / 1e6) : 0);
15  if (typeof v.seconds === 'number') return v.seconds * 1000 + (v.nanoseconds ? Math.floor(v.nanoseconds / 1e6) : 0);
16  if (v instanceof Date) return v.getTime();
17  return 0;
18}
19
20// phase8d-contactDetail-migrated
21import { auth } from '../firebase-init.js';
22import contactsApi from "./contactsApi.js";
23import notesApi from "./notesApi.js";
24import callSummaryApi from './callSummaryApi.js';
25import UI from './ui.js?v=20260820-dd1-imperr1-bann1-impauth1';
26import { Pages } from './pages.js';
27import { Auth } from './auth.js';
28import { AUFGABE_OPTIONS, DEFAULT_INDUSTRIES } from './constants.js';
29import { loadCustomLeadStatuses } from '../app.js';
30import { Calendar } from './calendar.js?v=20260826-tdup2-badge1-dnd1-kalrw2';
31import { contacts } from './contacts.js'; // Importiere die contacts-Instanz
32import { Language } from './language.js';
33import FirebaseAudioHandler from './firebase_audio_handler.js';
34import { ladeKundenfelder, renderKundenfelder, sammleKundenfelder, zeigeFeldVerwaltung } from './customFields.js?v=kf3'; // kundenfelder-v1
35import * as KTeam from './kontaktTeam.js?v=kteam3'; // kontakt-team-v1
36
37// Kurze Übersetzungsfunktion für einfacheren Zugriff
38function t(text) {
39    return Language.translate(text);
40}
41
42// Helper-Funktion für lokalisierte Datumsformatierung
43function getLocale() {
44    return Language.currentLanguage || 'de-DE';
45}
46
47export default class ContactDetail {
48    /**
49     * contact-detail-v1b: robust gegen alle Zeitstempel-Formen im Bestand.
50     * Firestore-Timestamp, ISO-String, Zahl, Objekt {iso|seconds} und der
51     * als String durchgereichte Python-repr davon. Nie mehr "Invalid Date".
52     */
53    _toDateSafe(v) {
54        if (v === null || v === undefined || v === '') return null;
55        if (typeof v.toDate === 'function') { try { return v.toDate(); } catch (e) { return null; } }
56        if (v instanceof Date) return isNaN(v.getTime()) ? null : v;
57        if (typeof v === 'number') {
58            // Sekunden vs. Millisekunden unterscheiden
59            const d = new Date(v < 1e12 ? v * 1000 : v);
60            return isNaN(d.getTime()) ? null : d;
61        }
62        if (typeof v === 'object') {
63            const inner = v.iso || v._seconds || v.seconds;
64            return inner === undefined ? null : this._toDateSafe(inner);
65        }
66        if (typeof v === 'string') {
67            let d = new Date(v);
68            if (!isNaN(d.getTime())) return d;
69            // Python-repr eines dicts, z.B. {'iso': '2025-07-22T09:38:36+00:00', ...}
70            const m = v.match(/['"]iso['"]\s*:\s*['"]([^'"]+)['"]/);
71            if (m) { d = new Date(m[1]); if (!isNaN(d.getTime())) return d; }
72            const s = v.match(/['"](?:_?seconds)['"]\s*:\s*(\d+)/);
73            if (s) { d = new Date(parseInt(s[1], 10) * 1000); if (!isNaN(d.getTime())) return d; }
74            if (/^\d+$/.test(v.trim())) return this._toDateSafe(parseInt(v.trim(), 10));
75        }
76        return null;
77    }
78
79    formatTimestamp(timestamp) {
80        if (!timestamp) return t('Nicht verfügbar');
81        
82        const date = this._toDateSafe(timestamp);
83        if (!date) return t('Unbekannt');
84
85        return date.toLocaleString(Language.currentLanguage, {
86            day: '2-digit',
87            month: '2-digit',
88            year: 'numeric',
89            hour: '2-digit',
90            minute: '2-digit'
91        });
92    }
93
94    async bindAudioControls() {
95        const container = document.getElementById('callSummaryTableBody');
96        if (!container) return;
97
98        // Überprüfe die Audio-Wiedergabe-Berechtigung
99        const user = Auth.getCurrentUser();
100        let showAudioButtons = true; // Standardmäßig anzeigen
101        try {
102            if (user) {
103                                const userSnap = await _phase8UserSnap(user.uid); /* phase8 */
104                if (userSnap.exists()) {
105                    const userData = userSnap.data();
106                    // NEU: Buttons anzeigen, wenn audio_playback NICHT explizit false ist
107                    showAudioButtons = userData.audio_playback !== false;
108                }
109            }
110        } catch (error) {
111            console.error('Fehler beim Prüfen der Audio-Berechtigung:', error);
112            // Bei Fehler zeigen wir die Buttons an
113            showAudioButtons = true;
114        }
115
116        // Verstecke die Audio-Buttons, wenn keine Berechtigung vorhanden ist
117        if (!showAudioButtons) {
118            container.querySelectorAll('.play-button, .stop-button').forEach(button => {
119                button.style.display = 'none';
120            });
121            return;
122        }
123
124        // Play buttons
125        container.querySelectorAll('.play-button').forEach(button => {
126            button.addEventListener('click', () => {
127                const audioUrl = button.dataset.audioUrl;
128                const stopButton = button.nextElementSibling;
129                this.playAudio(audioUrl, button, stopButton);
130            });
131        });
132
133        // Stop buttons
134        container.querySelectorAll('.stop-button').forEach(button => {
135            button.addEventListener('click', () => {
136                const playButton = button.previousElementSibling;
137                this.stopAudio(playButton, button);
138            });
139        });
140
141        // Delete buttons
142        container.querySelectorAll('.delete-summary').forEach(button => {
143            button.addEventListener('click', () => {
144                this.deleteCallSummary(button.dataset.summaryId);
145            });
146        });
147    }
148
149    async playAudio(audioUrl, playButton, stopButton) {
150        // Überprüfe die Audio-Wiedergabe-Berechtigung
151        const user = Auth.getCurrentUser();
152        if (user) {
153                        const userSnap = await _phase8UserSnap(user.uid); /* phase8 */
154            if (userSnap.exists()) {
155                const userData = userSnap.data();
156                if (userData.audio_playback === false) {
157                    UI.showError(t('Die Audio-Wiedergabe ist für Sie deaktiviert'));
158                    return;
159                }
160            }
161        }
162
163        // Stoppe vorheriges Audio, falls vorhanden
164        if (this.currentAudio) {
165            this.currentAudio.pause();
166            this.currentAudio = null;
167            // Reset alle anderen Play/Stop Buttons
168            document.querySelectorAll('.play-button').forEach(btn => btn.style.display = 'inline-block');
169            document.querySelectorAll('.stop-button').forEach(btn => btn.style.display = 'none');
170        }
171
172// console.log('Playing audio URL:', audioUrl);
173        this.currentAudio = new Audio(audioUrl);
174        this.currentAudio.play()
175            .then(() => {
176// console.log('Audio playback started successfully');
177                // Toggle Buttons
178                playButton.style.display = 'none';
179                stopButton.style.display = 'inline-block';
180
181                // Wenn Audio zu Ende ist
182                this.currentAudio.addEventListener('ended', () => {
183                    this.stopAudio(playButton, stopButton);
184                });
185            })
186            .catch(error => {
187                console.error('Fehler beim Abspielen des Audios:', error);
188                UI.showError(t('Fehler beim Abspielen des Audios') + ': ' + error.message);
189            });
190    }
191
192    stopAudio(playButton, stopButton) {
193        if (this.currentAudio) {
194            this.currentAudio.pause();
195            this.currentAudio.currentTime = 0;
196            this.currentAudio = null;
197        }
198        // Toggle Buttons
199        playButton.style.display = 'inline-block';
200        stopButton.style.display = 'none';
201    }
202
203    constructor(contactId, phoneNumber = null) {
204        this.contactId = contactId;
205        this.phoneNumber = phoneNumber;
206        this.contact = null;
207        this.db = db;
208        this.isMobileView = window.matchMedia('(max-width: 767.98px)').matches;
209        // Initialize properties to avoid undefined errors
210        this.tasks = [];
211        this.callSummaries = [];
212        this.notes = [];
213        this.emails = [];
214        this.audioHandler = new FirebaseAudioHandler(); // Firebase Audio Handler Instance
215        this.appointments = [];
216        // FIX: isMobileView wurde nur einmal gesetzt und war nach Resize/Rotate falsch
217        this._mqlMobile = window.matchMedia('(max-width: 767.98px)');
218        this._onMqlChange = (e) => { this.isMobileView = e.matches; };
219        if (this._mqlMobile.addEventListener) {
220            this._mqlMobile.addEventListener('change', this._onMqlChange);
221        } else if (this._mqlMobile.addListener) {
222            this._mqlMobile.addListener(this._onMqlChange);
223        }
224// console.log(`ContactDetail initialized with ID: ${contactId}, Phone: ${phoneNumber}`);
225    }
226
227    /** HTML-Escape fuer alle Werte, die per innerHTML/Template ins DOM gehen. */
228    esc(s) {
229        return String(s === null || s === undefined ? '' : s)
230            .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
231            .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
232    }
233
234    /**
235     * contact-detail-v1: Identitaetsblock fuer die Kopfzeile.
236     * Bewusst identisch zur Kontaktliste (contacts.js contactIdentity),
237     * damit derselbe Kontakt ueberall gleich aussieht.
238     */
239    _identity() {
240        const c = this.contact || {};
241        const rawName = String(c.name || '').trim();
242        const phone = String(c.phone_number || '').trim();
243        const company = String(c.company || '').trim();
244        const email = String(c.email || '').trim();
245        const unknown = !rawName || rawName === '-' || /^unbekannt/i.test(rawName);
246
247        let initials = '';
248        if (!unknown) {
249            const parts = rawName.split(/\s+/).filter(Boolean);
250            initials = parts.slice(0, 2).map(p => p.charAt(0)).join('').toUpperCase();
251        }
252        const key = (rawName || phone || String(c.id || ''));
253        let h = 0;
254        for (let i = 0; i < key.length; i++) h = (h * 31 + key.charCodeAt(i)) >>> 0;
255        const palette = ['#4C6EF5', '#12B886', '#F76707', '#7048E8', '#0CA678', '#E8590C', '#1C7ED6', '#AE3EC9'];
256
257        return {
258            unknown, phone, company, email, initials,
259            primary: unknown ? (phone || t('Unbekannt')) : rawName,
260            color: palette[h % palette.length]
261        };
262    }
263
264    /** contact-detail-v1b: "Mehr anzeigen" global einmalig verdrahten. */
265    _bindMoreToggle() {
266        if (window.__cdMoreBound) return;
267        window.__cdMoreBound = true;
268        document.addEventListener('click', (e) => {
269            const btn = e.target.closest('.cd-more');
270            if (!btn) return;
271            const cell = btn.parentElement && btn.parentElement.querySelector('.summary-cell');
272            if (!cell) return;
273            const clamped = cell.classList.toggle('cd-clamp');
274            btn.textContent = clamped ? t('Mehr anzeigen') : t('Weniger anzeigen');
275        });
276    }
277
278    /** contact-detail-v1: Stilblock einmalig in die Seite haengen. */
279    _injectStyle() {
280        this._bindMoreToggle();
281        if (document.getElementById('contact-detail-v1-style')) return;
282        const st = document.createElement('style');
283        st.id = 'contact-detail-v1-style';
284        st.textContent = `
285            .cd-head{display:flex;align-items:center;gap:.85rem;min-width:0}
286            .cd-avatar{width:52px;height:52px;border-radius:50%;flex:0 0 52px;
287                display:inline-flex;align-items:center;justify-content:center;
288                color:#fff;font-weight:600;font-size:1.1rem;letter-spacing:.5px}
289            .cd-title{font-size:1.4rem;font-weight:600;margin:0;line-height:1.25;
290                overflow-wrap:anywhere}
291            .cd-chips{display:flex;flex-wrap:wrap;gap:.35rem .85rem;margin-top:.25rem}
292            .cd-chip{font-size:.82rem;color:#6c757d;display:inline-flex;align-items:center;gap:.3rem}
293            .cd-chip i{opacity:.65}
294            .cd-chip a{color:inherit;text-decoration:none}
295            .cd-chip a:hover{text-decoration:underline}
296            .cd-sec{font-size:.72rem;font-weight:600;letter-spacing:.06em;
297                text-transform:uppercase;color:#8a94a6;margin:0 0 .6rem}
298            .cd-sec:not(:first-child){margin-top:1.35rem;padding-top:1.15rem;
299                border-top:1px solid #eef0f4}
300            .cd-save{position:sticky;bottom:0;background:#fff;padding-top:.9rem;
301                margin-top:1.1rem;border-top:1px solid #eef0f4}
302            .cd-empty{text-align:center;color:#98a2b3;padding:1.5rem .75rem;font-size:.9rem}
303            /* Zusammenfassungen sind oft mehrere hundert Zeichen lang und
304               sprengten die Spalte. Gekuerzt anzeigen, auf Klick aufklappen. */
305            .summary-cell{overflow-wrap:anywhere}
306            .summary-cell.cd-clamp{display:-webkit-box;-webkit-line-clamp:5;
307                -webkit-box-orient:vertical;overflow:hidden}
308            .cd-more{background:none;border:0;padding:0;margin-top:.3rem;
309                font-size:.82rem;color:#1C7ED6;cursor:pointer}
310            .cd-more:hover{text-decoration:underline}
311            .cd-empty i{display:block;font-size:1.5rem;opacity:.35;margin-bottom:.45rem}
312            /* Kontextmenue darf nicht am Kartenrand abgeschnitten werden */
313            #contactDetailActionButtons{position:relative;z-index:5}
314        `;
315        document.head.appendChild(st);
316    }
317
318    /** true, wenn die Praxis-Ansicht aktiv ist.
319     *  praxis-modus-v1 (13.08.2026): physio-menu kommt dazu - so sieht eine
320     *  Praxis auf app.voice-one.ai dieselbe Patientenansicht wie auf
321     *  app.medi-one.ai. Gleiches Muster wie in reviews.js, noShow.js,
322     *  anamneseTemplates.js, calendar.js und callDetail.js, die beide Klassen
323     *  seit jeher pruefen. */
324    isPhysio() {
325        const c = document.documentElement.classList;
326        return c.contains('physio-edition') || c.contains('physio-menu')
327            || document.body.classList.contains('physio-menu');
328    }
329
330    /** Aus ISO-Datum das Alter berechnen (null wenn ungueltig). */
331    _ageFrom(dateStr) {
332        if (!dateStr) return null;
333        const d = new Date(dateStr);
334        if (isNaN(d.getTime())) return null;
335        const now = new Date();
336        let age = now.getFullYear() - d.getFullYear();
337        const m = now.getMonth() - d.getMonth();
338        if (m < 0 || (m === 0 && now.getDate() < d.getDate())) age--;
339        return (age >= 0 && age < 130) ? age : null;
340    }
341
342    _fmtDate(dateStr) {
343        if (!dateStr) return '';
344        const d = new Date(dateStr);
345        if (isNaN(d.getTime())) return this.esc(dateStr);
346        return d.toLocaleDateString(getLocale(), { day: '2-digit', month: '2-digit', year: 'numeric' });
347    }
348
349    /**
350     * Baut ein <option>-Set und haengt einen unbekannten Bestandswert als
351     * zusaetzliche Option an. FIX: sonst stand das Select leer und der Wert
352     * wurde beim naechsten Speichern stillschweigend geloescht.
353     */
354    _optionsWithCurrent(options, current, placeholder) {
355        const list = (options || []).map(String);
356        const cur = (current === null || current === undefined) ? '' : String(current);
357        if (cur && !list.includes(cur)) list.push(cur);
358        return `<option value="">${this.esc(placeholder)}</option>` + list.map(o =>
359            `<option value="${this.esc(o)}"${o === cur ? ' selected' : ''}>${this.esc(t(o))}</option>`
360        ).join('');
361    }
362
363    async init(container) {
364        try {
365// console.log('Initializing ContactDetail');
366            
367            // Setze globale Instanz
368            window.contactDetailInstance = this;
369            
370            await this.loadContact(); // Load contact data first
371
372            // --- REVISED: Check contact loading status and handle errors --- 
373            if (!this.contact) {
374                console.error('Contact could not be loaded or access denied.');
375                // Show error message in container
376                container.innerHTML = `<div class="alert alert-danger">${t('Kontakt konnte nicht geladen werden oder der Zugriff wurde verweigert.')}</div>`;
377                return; // Stop further processing
378            }
379            // --- END REVISED ---
380
381            // If contact loaded successfully, proceed with rendering
382            await this.renderAll(container);
383        } catch (error) {
384            console.error('Error in init:', error);
385            UI.showError(t('Fehler beim Laden der Kontaktdetails'));
386        }
387    }
388
389    /**
390     * Rendert das komplette Detail-Grundgeruest, bindet die Events und
391     * befuellt alle Unterbereiche.
392     *
393     * FIX (kritisch): Diese Routine wird jetzt von init() UND von
394     * updateContactDetails() benutzt. Vorher rief das Speichern nur
395     * renderContact() auf - danach war das DOM neu, aber ohne Event-Listener
396     * und ohne Sub-Renderer: Patientenakte weg, alle Karten auf "Lade...",
397     * alle Buttons tot bis F5.
398     */
399    async renderAll(container) {
400        container = container || document.getElementById('contactDetailContainer');
401        if (!container) {
402            console.warn('[renderAll] contactDetailContainer nicht gefunden');
403            return;
404        }
405
406        await this.renderContact(container);
407
408        // Wait for DOM to be updated before rendering sub-components
409        await new Promise(resolve => setTimeout(resolve, 0));
410
411        this.bindEvents();
412
413        // Sub-Renderer parallel; jeder faengt seine Fehler selbst ab, damit ein
414        // kaputter Bereich die uebrigen nicht mitreisst.
415        const guarded = (label, fn) => Promise.resolve()
416            .then(() => fn.call(this))
417            .catch(err => console.error(`Error rendering ${label}:`, err));
418
419        await Promise.all([
420            guarded('notes', this.renderNotes),
421            guarded('tasks', this.renderTasks),
422            guarded('call summary', this.renderCallSummary),
423            guarded('emails', this.renderEmails),
424            guarded('appointments', this.renderAppointments),
425            guarded('patient records', this.renderPatientRecords)
426        ]);
427    }
428
429    async loadContact() {
430        try {
431            let contactDocSnap = null;
432
433            // Get current user's tenant ID first
434            const user = auth.currentUser;
435            if (!user) {
436                console.error('No user logged in');
437                this.contact = null;
438                return;
439            }
440                        const userDocSnap = await _phase8UserSnap(user.uid); /* phase8 */
441            const tenantId = userDocSnap.data()?.tenant_id;
442            if (!tenantId) {
443                console.error('No tenant ID found for user:', user.uid);
444                this.contact = null;
445                return;
446            }
447
448            if (this.contactId) {
449                try {
450                    const _c = await contactsApi.get(this.contactId);
451                    contactDocSnap = { exists: () => !!_c, data: () => _c, id: (_c && _c.id) || this.contactId };
452                } catch (e) {
453                    console.warn('contactsApi.get failed', e);
454                    contactDocSnap = { exists: () => false, data: () => null };
455                }
456            } else if (this.phoneNumber) {
457                const normalizedPhone = (this.phoneNumber || '').replace(/\s+/g, '');
458                let _c = null;
459                try { _c = await contactsApi.getByPhone(normalizedPhone); } catch (e) { _c = null; }
460                if (!_c && normalizedPhone !== this.phoneNumber) {
461                    try { _c = await contactsApi.getByPhone(this.phoneNumber); } catch (e) { _c = null; }
462                }
463                if (_c) {
464                    this.contactId = _c.id;
465                    contactDocSnap = { exists: () => true, data: () => _c, id: _c.id };
466                } else {
467                    console.warn(`No contact found for phone number: ${this.phoneNumber} in tenant: ${tenantId}`);
468                    contactDocSnap = { exists: () => false, data: () => null };
469                }
470            } else {
471                UI.showError(t('Keine Kontakt-ID oder Telefonnummer zum Laden vorhanden.'));
472                return;
473            }
474
475            if (!contactDocSnap || !contactDocSnap.exists()) {
476                UI.showError(t('Kontakt nicht gefunden'));
477                this.contact = null; // Ensure contact is null if not found
478                return;
479            }
480
481            // Tenant ID Check is no longer needed here since we're already filtering by tenant_id in the query
482            const contactData = contactDocSnap.data();
483
484            this.contact = {
485                id: contactDocSnap.id,
486                ...contactData,
487                tenant_id: tenantId // Ensure tenant_id from user is included
488            };
489            
490            // The init function now handles rendering the base structure if needed.
491            // --- REMOVED: Check and renderContact call removed ---
492            // const container = document.getElementById('contactDetailContainer');
493            // if (container && container.querySelector('#contactForm') === null) { // Check if form exists
494            //      console.log('Rendering contact structure inside loadContact because it was missing.');
495            //      await this.renderContact(); // This populates the form and sets up placeholders
496            // } else {
497            //      console.log('Contact structure already exists or renderContact will be called by init.');
498                 // If structure exists, ensure form is populated (or rely on renderContact called by init)
499                 // If renderContact is *always* called by init after loadContact, this might be redundant
500                 // For safety, we might want to call a light-weight update function here instead of full renderContact
501                 // await this.populateContactForm(); // Example of a potential lighter update
502            // }
503            // --- END REMOVED ---
504
505        } catch (error) {
506            console.error('Error loading contact:', error);
507            UI.showError(t('Fehler beim Laden des Kontakts'));
508            this.contact = null; // Ensure contact is null on error
509        }
510    }
511
512    async renderContact(container) {
513        try {
514            // Sicherheitsprüfung: Container muss existieren
515            if (!container) {
516                console.error('[renderContact] Container parameter ist undefined oder null');
517                container = document.getElementById('contactDetailContainer');
518                if (!container) {
519                    console.error('[renderContact] Konnte contactDetailContainer nicht im DOM finden');
520                    return;
521                }
522                console.log('[renderContact] Container gefunden als Fallback');
523            }
524            
525            // Lade nur die Lead-Status für den aktuellen Tenant
526            const user = auth.currentUser;
527            if (!user) {
528                console.error('Kein Benutzer eingeloggt');
529                return;
530            }
531
532                        const userDocSnap = await _phase8UserSnap(user.uid); /* phase8 */
533            const tenantId = userDocSnap.data()?.tenant_id;
534
535            if (!tenantId) {
536                console.error('Keine Tenant-ID gefunden');
537                return;
538            }
539
540            // Modifiziere die loadCustomLeadStatuses Funktion um den tenantId Parameter zu übergeben
541            const allLeadStatuses = await loadCustomLeadStatuses(tenantId);
542
543            // kundenfelder-v1: Definitionen der eigenen Felder (beide Layouts)
544            try { this.customFieldDefs = await ladeKundenfelder(); }
545            catch (e) { this.customFieldDefs = []; }
546            
547            // FIX: Zwei Layouts. Die Physio-/mediOne-Edition bekommt eine echte
548            // Patientenansicht statt der B2B-Vertriebsmaske.
549            container.innerHTML = this.isPhysio()
550                ? this._layoutPhysio(allLeadStatuses)
551                : this._layoutCrm(allLeadStatuses);
552            // kontakt-team-v1: Team, Metadaten und Verlauf (nur CRM-Layout)
553            const _kteamPlatz = document.getElementById('kteamKartePlatz');
554            if (_kteamPlatz && this.contact) {
555                KTeam.renderTeamKarte(_kteamPlatz, this.contact).catch(e => console.warn('[kontakt-team]', e));
556            }
557        } catch (error) {
558            console.error('Error rendering contact:', error);
559            throw error;
560        }
561    }
562
563    // ==================================================================
564    //  Layout-Bausteine
565    //  Die Karten werden von beiden Layouts geteilt; die Sub-Renderer
566    //  (renderNotes/renderTasks/...) suchen ihre Container ueber diese IDs.
567    // ==================================================================
568
569    _cardAppointments() {
570        return `
571            <div id="appointmentsCardPlaceholder">
572                <div class="card shadow-sm mb-4">
573                    <div class="card-body">
574                        <h5 class="card-title mb-3">${t('Termine')}</h5>
575                        <div id="appointmentsTableContainer" class="table-responsive d-none d-md-block">
576                            <table class="table table-hover">
577                                <thead>
578                                    <tr>
579                                        <th style="width: 100px;">${t('Datum')}</th>
580                                        <th style="width: 60px;">${t('Zeit')}</th>
581                                        <th>${t('Titel')}</th>
582                                    </tr>
583                                </thead>
584                                <tbody id="appointmentsTableBody">
585                                    <tr><td colspan="3" class="text-center text-muted">${t('Lade Termine...')}</td></tr>
586                                </tbody>
587                            </table>
588                        </div>
589                        <div id="appointmentsCardsContainer" class="d-block d-md-none">
590                            <div class="text-center text-muted p-3">${t('Lade Termine...')}</div>
591                        </div>
592                    </div>
593                </div>
594            </div>`;
595    }
596
597    /**
598     * Karte fuer die contact-Tasks.
599     * FIX: hiess bisher "Anrufe", rendert aber Aufgaben (Spalten
600     * Aufgabe/Nachricht/Status, Tooltip "Task loeschen").
601     */
602    _cardTasks() {
603        return `
604            <div class="card shadow-sm mb-4">
605                <div class="card-body">
606                    <h6 class="card-title d-flex justify-content-between align-items-center mb-3">
607                        <span>${t('Aufgaben aus Anrufen')}</span>
608                    </h6>
609                    <div id="tasksTableContainer" class="d-none d-md-block">
610                        <div class="text-center text-muted">${t('Lade Aufgaben...')}</div>
611                    </div>
612                    <div id="tasksCardsContainer" class="d-block d-md-none">
613                        <div class="text-center text-muted">${t('Lade Aufgaben...')}</div>
614                    </div>
615                </div>
616            </div>`;
617    }
618
619    _cardCallSummary() {
620        return `
621            <div class="card shadow-sm mb-4">
622                <div class="card-body">
623                    <h6 class="card-title d-flex justify-content-between align-items-center mb-3">
624                        <span>${t('Anruf Historie')}</span>
625                    </h6>
626                    <div id="callSummaryTableContainer" class="table-responsive d-none d-md-block">
627                        <table class="table call-summary-table">
628                            <thead>
629                                <tr>
630                                    <th style="width: 130px">${t('Datum')}</th>
631                                    <th>${t('Zusammenfassung')}</th>
632                                    <th style="width: 100px"></th>
633                                </tr>
634                            </thead>
635                            <tbody id="callSummaryTableBody">
636                                <tr><td colspan="3" class="text-center text-muted">${t('Lade Historie...')}</td></tr>
637                            </tbody>
638                        </table>
639                    </div>
640                    <div id="callSummaryCardsContainer" class="d-block d-md-none">
641                        <div class="text-center text-muted">${t('Lade Historie...')}</div>
642                    </div>
643                </div>
644            </div>`;
645    }
646
647    _cardNotes() {
648        return `
649            <div class="card shadow-sm mb-4">
650                <div class="card-body">
651                    <h6 class="card-title d-flex justify-content-between align-items-center">
652                        <span>${t('Notizen')}</span>
653                        <button class="btn btn-sm btn-primary" id="addNoteBtn">
654                            <i class="fas fa-plus"></i> ${t('Neue Notiz')}
655                        </button>
656                    </h6>
657                    <div id="newNoteBox" class="mb-3" style="display:none;">
658                        <textarea class="form-control form-control-sm mb-2" id="newNoteText" rows="3"
659                                  placeholder="${t('Notiz eingeben...')}"></textarea>
660                        <div class="d-flex gap-2">
661                            <button class="btn btn-sm btn-success" id="saveNoteBtn">${t('Speichern')}</button>
662                            <button class="btn btn-sm btn-outline-secondary" id="cancelNoteBtn">${t('Abbrechen')}</button>
663                        </div>
664                    </div>
665                    <div id="notesContainer">
666                        <div class="text-center text-muted">${t('Lade Notizen...')}</div>
667                    </div>
668                </div>
669            </div>`;
670    }
671
672    _cardEmails() {
673        return `
674            <div class="card shadow-sm mb-4">
675                <div class="card-body">
676                    <h6 class="card-title d-flex justify-content-between align-items-center">
677                        <span>${t('E-Mail-Historie')}</span>
678                    </h6>
679                    <div id="emailsTableContainer" class="table-responsive d-none d-md-block">
680                        <table class="table table-sm" style="table-layout: fixed;">
681                            <thead>
682                                <tr>
683                                    <th style="width: 110px; white-space: nowrap;">${t('Datum')}</th>
684                                    <th>${t('Betreff')}</th>
685                                    <th style="width: 55px; text-align: center; white-space: nowrap;">${t('Typ')}</th>
686                                    <th style="width: 70px; text-align: center; white-space: nowrap;">${t('Status')}</th>
687                                    <th style="width: 42px; text-align: center;" title="${t('Öffnungen')}"><i class="fas fa-envelope-open text-muted"></i></th>
688                                    <th style="width: 42px; text-align: center;" title="${t('Klicks')}"><i class="fas fa-mouse-pointer text-muted"></i></th>
689                                </tr>
690                            </thead>
691                            <tbody id="emailsTableBody">
692                                <tr><td colspan="6" class="text-center text-muted">${t('Lade E-Mails...')}</td></tr>
693                            </tbody>
694                        </table>
695                    </div>
696                    <div id="emailsCardsContainer" class="d-block d-md-none">
697                        <div class="text-center text-muted">${t('Lade E-Mails...')}</div>
698                    </div>
699                </div>
700            </div>`;
701    }
702
703    _fieldPhone() {
704        return `
705            <div class="input-group">
706                <input type="tel" class="form-control" name="phone_number" value="${this.esc(this.contact.phone_number)}" required>
707                ${this.contact.phone_number ? `
708                    <button class="btn btn-outline-success phone-call-btn" type="button" title="${t('Anrufen')}">
709                        <i class="fas fa-phone"></i>
710                    </button>
711                ` : ''}
712            </div>`;
713    }
714
715    _fieldEmail() {
716        return `
717            <div class="input-group">
718                <input type="email" class="form-control" name="email" value="${this.esc(this.contact.email)}">
719                ${this.contact.email ? `
720                    <button class="btn btn-outline-primary email-send-btn" type="button" data-contact-id="${this.esc(this.contact.
720id)}" title="${t('E-Mail senden')}">
721                        <i class="fas fa-envelope"></i>
722                    </button>
723                ` : ''}
724            </div>`;
725    }
726
727    // ==================================================================
728    //  Layout A: klassische CRM-Maske (voiceOne / Nicht-Physio-Editionen)
729    // ==================================================================
730    _layoutCrm(allLeadStatuses) {
731        const c = this.contact;
732        const idt = this._identity();   // contact-detail-v1
733        this._injectStyle();
734        return `
735            <div class="container-fluid py-4">
736                <div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-4">
737                    <div class="cd-head">
738                        ${idt.initials
739                            ? `<span class="cd-avatar" style="background:${idt.color}">${this.esc(idt.initials)}</span>`
740                            : `<span class="cd-avatar" style="background:#adb5bd"><i class="fas fa-user"></i></span>`}
741                        <div style="min-width:0">
742                            <h3 class="cd-title">${this.esc(idt.primary)}</h3>
743                            <div class="cd-chips">
744                                ${idt.unknown && idt.phone
745                                    ? `<span class="cd-chip"><i class="fas fa-user-slash"></i>${t('Unbekannter Anrufer')}</span>` : ''}
746                                ${idt.company
747                                    ? `<span class="cd-chip"><i class="fas fa-building"></i>${this.esc(idt.company)}</span>` : ''}
748                                ${idt.phone && !idt.unknown
749                                    ? `<span class="cd-chip"><i class="fas fa-phone"></i><a href="tel:${this.esc(idt.phone)}">${this.esc(idt.phone)}</a></span>` : ''}
750                                ${idt.email
751                                    ? `<span class="cd-chip"><i class="fas fa-envelope"></i><a href="mailto:${this.esc(idt.email)}">${this.esc(idt.email)}</a></span>` : ''}
752                                ${c.city
753                                    ? `<span class="cd-chip"><i class="fas fa-location-dot"></i>${this.esc(c.city)}</span>` : ''}
754                            </div>
755                        </div>
756                    </div>
757                    <div class="d-flex gap-2 align-items-center" id="contactDetailActionButtons">
758                        <button class="btn btn-outline-secondary" id="backButton">
759                            <i class="fas fa-arrow-left me-1"></i>${t('Zurück')}
760                        </button>
761                        <div class="dropdown">
762                            <button class="btn btn-outline-secondary" type="button" id="contactMoreBtn"
763                                    data-bs-toggle="dropdown" data-bs-boundary="viewport"
764                                    aria-expanded="false" title="${t('Weitere Aktionen')}">
765                                <i class="fas fa-ellipsis"></i>
766                            </button>
767                            <ul class="dropdown-menu dropdown-menu-end">
768                                <li><button class="dropdown-item text-danger" type="button" id="deleteButton">
769                                    <i class="fas fa-trash me-2"></i>${t('Löschen')}
770                                </button></li>
771                            </ul>
772                        </div>
773                    </div>
774                </div>
775                <div class="row mt-4">
776                    <div class="col-md-6">
777                        <div class="card shadow-sm mb-4">
778                            <div class="card-body">
779                                <h5 class="card-title mb-4">${t('Kontaktinformationen')}</h5>
780                                <form id="contactForm">
781                                    <p class="cd-sec">${t('Kontaktdaten')}</p>
782                                    <div class="row g-3">
783                                        <div class="col-md-6">
784                                            <label class="form-label">${t('Name')}</label>
785                                            <input type="text" class="form-control" name="name" value="${this.esc(c.name)}" placeholder="${t('Name')}" required>
786                                        </div>
787                                        <div class="col-md-6">
788                                            <label class="form-label">${t('Firma')}</label>
789                                            <input type="text" class="form-control" name="company" value="${this.esc(c.company)}">
790                                        </div>
791                                        <div class="col-md-6">
792                                            <label class="form-label">${t('Telefonnummer')}</label>
793                                            ${this._fieldPhone()}
794                                        </div>
795                                        <div class="col-md-6">
796                                            <label class="form-label">${t('E-Mail')}</label>
797                                            ${this._fieldEmail()}
798                                        </div>
799                                    </div>
800
801                                    <p class="cd-sec">${t('Adresse')}</p>
802                                    <div class="row g-3">
803                                        <div class="col-md-8">
804                                            <label class="form-label">${t('Straße/Hausnummer')}</label>
805                                            <input type="text" class="form-control" name="street" value="${this.esc(c.street)}">
806                                        </div>
807                                        <div class="col-md-4">
808                                            <label class="form-label">${t('PLZ')}</label>
809                                            <input type="text" class="form-control" name="zip" value="${this.esc(c.zip)}">
810                                        </div>
811                                        <div class="col-md-8">
812                                            <label class="form-label">${t('Ort')}</label>
813                                            <input type="text" class="form-control" name="city" value="${this.esc(c.city)}">
814                                        </div>
815                                        <div class="col-md-4">
816                                            <label class="form-label">${t('Land')}</label>
817                                            <input type="text" class="form-control" name="country" value="${this.esc(c.country)}">
818                                        </div>
819                                    </div>
820
821                                    <p class="cd-sec">${t('Vertrieb')}</p>
822                                    <div class="row g-3">
823                                        <div class="col-md-6">
824                                            <label class="form-label">${t('Lead-Status')}</label>
825                                            <select class="form-select" name="lead_status">
826                                                ${this._optionsWithCurrent(allLeadStatuses, c.lead_status, t('Bitte Lead-Status wählen'))}
827                                            </select>
828                                        </div>
829                                        <div class="col-md-6">
830                                            <label class="form-label">${t('Branche')}</label>
831                                            <select class="form-select" name="industry">
832                                                ${this._optionsWithCurrent(DEFAULT_INDUSTRIES, c.industry, t('Bitte Branche wählen'))}
833                                            </select>
834                                        </div>
835                                        <div class="col-md-6">
836                                            <label class="form-label">${t('Aufgabe')}</label>
837                                            <select class="form-select" name="task">
838                                                ${this._optionsWithCurrent(AUFGABE_OPTIONS, c.task, t('Bitte wählen'))}
839                                            </select>
840                                        </div>
841                                        <div class="col-md-6">
842                                            <label class="form-label">${t('Website')}</label>
843                                            <input type="url" class="form-control" name="website" value="${this.esc(c.website)}">
844                                        </div>
845                                        <div class="col-md-6">
846                                            <label class="form-label">${t('Kundennummer')}</label>
847                                            <input type="text" class="form-control" name="customer_number" value="${this.esc(c.customer_number)}">
848                                        </div>
849                                    </div>
850
851                                    ${this._sectionKundenfelder()}
852
853                                    <p class="cd-sec">${t('Notizen')}</p>
854                                    <div class="row g-3">
855                                        <div class="col-12">
856                                            <textarea class="form-control" name="notes" rows="4"
857                                                      placeholder="${t('Interne Notizen zu diesem Kontakt...')}">${this.esc(c.notes)}</textarea>
858                                        </div>
859                                    </div>
860
861                                    <div class="cd-save">
862                                        <button type="submit" class="btn btn-primary">
863                                            <i class="fas fa-save me-1"></i>${t('Speichern')}
864                                        </button>
865                                    </div>
866                                </form>
867                            </div>
868                        </div>
869                        ${this._cardAppointments()}
870                        <div id="patientRecordsCardPlaceholder"></div>
871                        ${this._cardTasks()}
872                    </div>
873                    <div class="col-md-6">
874                        <div id="kteamKartePlatz"></div>
875                        ${this._cardCallSummary()}
876                        ${this._cardNotes()}
877                        ${this._cardEmails()}
878                    </div>
879                </div>
880            </div>`;
881    }
882
883    // ==================================================================
884    //  Layout B: Patientenansicht (Physio-/mediOne-Edition)
885    //
886    //  Unterschiede zur alten Maske:
887    //   - Sprache durchgaengig "Patient" statt "Kontakt"
888    //   - Kopfzeile mit Identifikations-Merkmalen (Geburtsdatum/Alter,
889    //     Kasse, Versichertennummer) statt nur Name
890    //   - Praxisfelder Geburtsdatum/Krankenkasse/Versichertennummer/
891    //     Versichertenstatus/Hausarzt ergaenzt (fehlten komplett)
892    //   - Vertriebsfelder (Firma/Website/Branche/Lead-Status/Kundennummer/
893    //     Aufgabe) ausgeblendet, Werte bleiben als hidden erhalten
894    //   - Stammdaten eingeklappt; die Patientenakte steht ueber die volle
895    //     Breite oben statt in einer halben Spalte unter dem Formular
896    //   - Redundanz raus: keine separate Termine-Karte (Akte-Tab "Termine"
897    //     gruppiert nach Verordnung) und keine E-Mail-Tracking-Karte
898    // ==================================================================
899    // kundenfelder-v1: Abschnitt "Eigene Felder" im CRM-Layout.
900    // Definitionen kommen je Mandant vom Server, Werte aus contact.custom.
901    _sectionKundenfelder() {
902        const defs = this.customFieldDefs || [];
903        const kopf = `<p class="cd-sec d-flex justify-content-between align-items-center">${t('Eigene Felder')}
904            <button type="button" class="btn btn-sm btn-outline-secondary" id="cfManageBtn" title="${t('Eigene Felder verwalten')}">
905                <i class="fas fa-sliders me-1"></i>${t('Felder verwalten')}
906            </button></p>`;
907        if (!defs.length) {
908            return `${kopf}<div class="text-muted small mb-2">${t('Noch keine eigenen Felder definiert. Über "Felder verwalten" können Sie z.B. "Gekaufte Produkte" ergänzen.')}</div>`;
909        }
910        return `${kopf}<div class="row g-3">${renderKundenfelder(defs, this.contact.custom || {})}</div>`;
911    }
912
913    _layoutPhysio(allLeadStatuses) {
914        const c = this.contact;
915        const name = this.esc(c.name) || t('Unbekannt');
916        const gebIso = this._deToIso(c.geburtsdatum);
917        const age = this._ageFrom(gebIso);
918        const open = this._stammdatenOpen === true;
919
920        const chip = (icon, label, value) => value
921            ? `<span class="me-3 text-nowrap"><i class="fas ${icon} me-1 text-muted"></i>${this.esc(label)}: <strong>${this.esc(value)}</strong></span>`
922            : '';
923
924        const gebChip = c.geburtsdatum
925            ? `<span class="me-3 text-nowrap"><i class="fas fa-cake-candles me-1 text-muted"></i>${t('geb.')} <strong>${this.esc(c.geburtsdatum)}</strong>${age !== null ? ` (${age} ${t('J.')})` : ''}</span>`
926            : `<span class="me-3 text-warning text-nowrap"><i class="fas fa-triangle-exclamation me-1"></i>${t('Geburtsdatum fehlt')}</span>`;
927
928        const statusOptions = ['gesetzlich', 'privat', 'Selbstzahler', 'BG / Unfall', 'Beihilfe'];
929
930        // Vertriebsfelder unsichtbar mitfuehren, damit sie beim Speichern
931        // nicht verloren gehen (FormData enthaelt nur vorhandene Felder).
932        const hidden = ['company', 'website', 'industry', 'task', 'lead_status', 'customer_number']
933            .map(k => `<input type="hidden" name="${k}" value="${this.esc(c[k])}">`).join('');
934
935        return `
936            <div class="container-fluid py-4">
937                <!-- Kopfzeile: Identifikation auf einen Blick -->
938                <div class="d-flex justify-content-between align-items-start flex-wrap gap-2 mb-3">
939                    <div>
940                        <h3 class="mb-1">${t('Patient:')} ${name}</h3>
941                        <div class="small text-muted d-flex flex-wrap align-items-center">
942                            ${gebChip}
943                            ${chip('fa-shield-heart', t('Kasse'), c.krankenkasse)}
944                            ${chip('fa-id-card', t('Vers.-Nr.'), c.versichertennummer)}
945                            ${chip('fa-phone', t('Tel.'), c.phone_number)}
946                        </div>
947                    </div>
948                    <div class="d-flex gap-2" id="contactDetailActionButtons">
949                        <button class="btn btn-secondary" id="backButton">
950                            <i class="fas fa-arrow-left me-1"></i>${t('Zurück')}
951                        </button>
952                        <button class="btn btn-danger" id="deleteButton">
953                            <i class="fas fa-trash me-1"></i>${t('Löschen')}
954                        </button>
955                    </div>
956                </div>
957
958                <!-- Stammdaten: eingeklappt, volle Breite, nur Praxisfelder -->
959                <div class="card shadow-sm mb-4">
960                    <div class="card-body py-3">
961                        <h6 class="card-title d-flex justify-content-between align-items-center mb-0">
962                            <span><i class="fas fa-address-card me-2 text-muted"></i>${t('Stammdaten')}</span>
963                            <button type="button" class="btn btn-sm btn-outline-secondary" id="toggleStammdatenBtn"
964                                    aria-expanded="${open ? 'true' : 'false'}">
965                                <i class="fas fa-chevron-${open ? 'up' : 'down'} me-1"></i>${t('Bearbeiten')}
966                            </button>
967                        </h6>
968                        <form id="contactForm" class="mt-3" style="display:${open ? 'block' : 'none'};">
969                            ${hidden}
970                            <div class="row g-3">
971                                <div class="col-md-4">
972                                    <label class="form-label">${t('Name')}</label>
973                                    <input type="text" class="form-control" name="name" value="${this.esc(c.name)}" placeholder="${t('Vor- und Nachname')}" required>
974                                </div>
975                                <div class="col-md-4">
976                                    <label class="form-label">${t('Geburtsdatum')}</label>
977                                    <input type="date" class="form-control" id="physioGeburtsdatum" value="${this.esc(gebIso)}">
978                                    <input type="hidden" name="geburtsdatum" value="${this.esc(c.geburtsdatum)}">
979                                </div>
980                                <div class="col-md-4">
981                                    <label class="form-label">${t('Telefonnummer')}</label>
982                                    ${this._fieldPhone()}
983                                </div>
984                                <div class="col-md-4">
985                                    <label class="form-label">${t('E-Mail')}</label>
986                                    ${this._fieldEmail()}
987                                </div>
988                                <div class="col-md-4">
989                                    <label class="form-label">${t('Straße/Hausnummer')}</label>
990                                    <input type="text" class="form-control" name="street" value="${this.esc(c.street)}">
991                                </div>
992                                <div class="col-md-2">
993                                    <label class="form-label">${t('PLZ')}</label>
994                                    <input type="text" class="form-control" name="zip" value="${this.esc(c.zip)}">
995                                </div>
996                                <div class="col-md-2">
997                                    <label class="form-label">${t('Ort')}</label>
998                                    <input type="text" class="form-control" name="city" value="${this.esc(c.city)}">
999                                </div>
1000
1001                                <div class="col-12"><hr class="my-1"></div>
1002
1003                                <div class="col-md-4">
1004                                    <label class="form-label">${t('Krankenkasse')}</label>
1005                                    <input type="text" class="form-control" name="krankenkasse" value="${this.esc(c.krankenkasse)}" placeholder="${t('z.B. AOK Bayern')}">
1006                                </div>
1007                                <div class="col-md-4">
1008                                    <label class="form-label">${t('Versichertennummer')}</label>
1009                                    <input type="text" class="form-control" name="versichertennummer" value="${this.esc(c.versichertennummer)}">
1010                                </div>
1011                                <div class="col-md-4">
1012                                    <label class="form-label">${t('Versichertenstatus')}</label>
1013                                    <select class="form-select" name="versichertenstatus">
1014                                        ${this._optionsWithCurrent(statusOptions, c.versichertenstatus, t('Bitte wählen'))}
1015                                    </select>
1016                                </div>
1017                                <div class="col-md-4">
1018                                    <label class="form-label">${t('Hausarzt / verordnender Arzt')}</label>
1019                                    <input type="text" class="form-control" name="hausarzt" value="${this.esc(c.hausarzt)}">
1020                                </div>
1021                                ${renderKundenfelder(this.customFieldDefs || [], c.custom || {}, { colClass: 'col-md-4' })}
1022                                <div class="col-12">
1023                                    <button type="button" class="btn btn-sm btn-outline-secondary" id="cfManageBtn">
1024                                        <i class="fas fa-sliders me-1"></i>${t('Eigene Felder verwalten')}
1025                                    </button>
1026                                </div>
1027
1028                                <div class="col-md-8">
1029                                    <label class="form-label">${t('Interne Bemerkung')}</label>
1030                                    <textarea class="form-control" name="notes" rows="2">${this.esc(c.notes)}</textarea>
1031                                </div>
1032
1033                                <div class="col-12">
1034                                    <button type="submit" class="btn btn-primary">
1035                                        <i class="fas fa-save me-1"></i>${t('Speichern')}
1036                                    </button>
1037                                </div>
1038                            </div>
1039                        </form>
1040                    </div>
1041                </div>
1042
1043                <!-- Patientenakte: volle Breite, direkt unter den Stammdaten -->
1044                <div id="patientRecordsCardPlaceholder"></div>
1045
1046                <!-- Kommunikation: Anruf-Historie + Notizen + Aufgaben -->
1047                <div class="row">
1048                    <div class="col-lg-7">
1049                        ${this._cardCallSummary()}
1050                        ${this._cardTasks()}
1051                    </div>
1052                    <div class="col-lg-5">
1053                        ${this._cardNotes()}
1054                    </div>
1055                </div>
1056            </div>`;
1057    }
1058
1059    /** DD.MM.YYYY -> YYYY-MM-DD (fuer <input type="date">). */
1060    _deToIso(s) {
1061        const v = String(s || '').trim();
1062        if (!v) return '';
1063        let m = v.match(/^(\d{2})\.(\d{2})\.(\d{4})$/);
1064        if (m) return `${m[3]}-${m[2]}-${m[1]}`;
1065        if (/^\d{4}-\d{2}-\d{2}$/.test(v)) return v;
1066        return '';
1067    }
1068
1069    /** YYYY-MM-DD -> DD.MM.YYYY (Speicherformat, identisch zu verordnungen.js). */
1070    _isoToDe(s) {
1071        const v = String(s || '').trim();
1072        const m = v.match(/^(\d{4})-(\d{2})-(\d{2})$/);
1073        return m ? `${m[3]}.${m[2]}.${m[1]}` : v;
1074    }
1075
1076    async renderConversations() {
1077        const tbody = document.getElementById('conversationsTable');
1078        if (!tbody || !this.contact.conversations) return;
1079
1080        tbody.innerHTML = this.contact.conversations
1081            .sort((a, b) => _tsToMillis(b.date) - _tsToMillis(a.date))
1082            .map(conv => `
1083                <tr>
1084                    <td>${new Date(_tsToMillis(conv.date)).toLocaleDateString()}</td>
1085                    <td>${conv.summary}</td>
1086                </tr>
1087            `).join('');
1088    }
1089
1090    async addNote(noteContent) {
1091        if (!noteContent || noteContent.trim() === '') {
1092            UI.showError(t('Bitte geben Sie einen gültigen Notiztext ein.'));
1093            return;
1094        }
1095
1096        try {
1097            const user = auth.currentUser;
1098            if (!user) {
1099                UI.showError(t('Benutzer ist nicht eingeloggt.'));
1100                return;
1101            }
1102
1103            await notesApi.create({
1104                message: noteContent.trim(),
1105                customer_id: this.contactId.toString(),
1106                notes_id: Date.now()
1107            });
1108
1109            UI.showSuccess(t('Notiz erfolgreich hinzugefügt.'));
1110            await this.renderNotes(); // Aktualisiert die Anzeige der Notizen
1111        } catch (error) {
1112            console.error('Fehler beim Hinzufügen der Notiz:', error);
1113            UI.showError(t('Fehler beim Speichern der Notiz.'));
1114        }
1115    }
1116
1117    async renderNotes() {
1118        const notesContainer = document.getElementById('notesContainer');
1119        if (!notesContainer) {
1120            return;
1121        }
1122
1123        try {
1124            const notes = await notesApi.listForCustomer(this.contactId.toString());
1125            if (!notes || notes.length === 0) {
1126                notesContainer.innerHTML = `<p>${t('Keine Notizen vorhanden.')}</p>`;
1127            } else {
1128                notesContainer.innerHTML = notes.map(note => {
1129                    const noteId = note.id;
1130                    return `
1131                        <div class="note-item">
1132                            <p style="white-space:pre-wrap;">${this.esc(note.message)}</p>
1133                            <small>
1134                                <i class="far fa-clock"></i>
1135                                ${this.esc(this.formatTimestamp(note.created_at))}
1136                            </small>
1137                            <button class="btn btn-sm delete-note" data-note-id="${this.esc(noteId)}">
1138                                <i class="fas fa-trash"></i>
1139                            </button>
1140                        </div>
1141                    `;
1142                }).join('');
1143
1144                // Add event listeners for delete buttons
1145                notesContainer.querySelectorAll('.delete-note').forEach(button => {
1146                    button.addEventListener('click', () => this.deleteNote(button.dataset.noteId));
1147                });
1148            }
1149
1150        } catch (error) {
1151            console.error('Fehler beim Laden der Notizen:', error);
1152            UI.showError(t('Fehler beim Laden der Notizen.'));
1153        }
1154    }
1155
1156    async deleteNote(noteId) {
1157        if (!confirm(t('Möchten Sie diese Notiz wirklich löschen?'))) return;
1158
1159        try {
1160            await notesApi.delete(noteId);
1161
1162            UI.showSuccess(t('Notiz erfolgreich gelöscht.'));
1163            await this.renderNotes(); // Aktualisiert die Anzeige der Notizen
1164        } catch (error) {
1165            console.error('Fehler beim Löschen der Notiz:', error);
1166            UI.showError(t('Fehler beim Löschen der Notiz.'));
1167        }
1168    }
1169
1170    async renderCalls() {
1171        const tbody = document.getElementById('callsTable');
1172        if (!tbody || !this.contact.calls) return;
1173
1174        tbody.innerHTML = this.contact.calls
1175            .sort((a, b) => _tsToMillis(b.date) - _tsToMillis(a.date))
1176            .map(call => `
1177                <tr>
1178                    <td>${new Date(_tsToMillis(call.date)).toLocaleDateString()}</td>
1179                    <td>${call.task}</td>
1180                    <td>${call.message}</td>
1181                    <td>${call.dueDate ? new Date(_tsToMillis(call.dueDate)).toLocaleDateString() : '-'}</td>
1182                </tr>
1183            `).join('');
1184    }
1185
1186    async renderTasks() {
1187        const tableContainer = document.getElementById('tasksTableContainer');
1188        const cardsContainer = document.getElementById('tasksCardsContainer');
1189
1190        if (!tableContainer || !cardsContainer) {
1191            return;
1192        }
1193
1194        // Clear containers initially
1195        tableContainer.innerHTML = `<div class="text-center text-muted">${t('Lade Aufgaben...')}</div>`;
1196        cardsContainer.innerHTML = `<div class="text-center text-muted">${t('Lade Aufgaben...')}</div>`;
1197
1198        try {
1199            const user = auth.currentUser;
1200            if (!user) {
1201                console.error('Benutzer nicht eingeloggt.');
1202                const msg = `<p class="text-center text-muted">${t('Bitte melden Sie sich an.')}</p>`;
1203                tableContainer.innerHTML = `<div class="table-responsive"><table class="table"><tbody><tr><td colspan="5">${msg}</td></tr></tbody></table></div>`;
1204                cardsContainer.innerHTML = msg;
1205                return;
1206            }
1207
1208            const _tasksList = await contactsApi.listTasks(this.contactId);
1209            // kd-aufgaben-team-v1: Der Filter auf die eigene Benutzerkennung
1210            // blendete Aufgaben aus, die ein Kollege desselben Mandanten am
1211            // selben Kontakt angelegt hat - und dauerhaft alle Aufgaben
1212            // ausgeschiedener Mitarbeiter (gemessen: 18). Notizen, Anrufe und
1213            // Termine an diesem Kontakt sind mandantenweit sichtbar, die
1214            // Aufgaben waren die einzige Ausnahme. Die Abgrenzung leistet seit
1215            // kd-aufgaben-mandant-v1 der Server ueber den Kontakt.
1216            const _filteredTasks = (_tasksList || []);
1217            const snapshot = {
1218                empty: _filteredTasks.length === 0,
1219                size: _filteredTasks.length,
1220                docs: _filteredTasks.map(t => ({ id: t.id, data: () => t })),
1221                forEach: function(fn) { this.docs.forEach(fn); }
1222            };
1223            
1224            if (snapshot.empty) {
1225                const noTasksMsg = `<p class="text-center text-muted">${t('Keine Aufgaben vorhanden.')}</p>`;
1226                // Add table structure for the empty message in the table view
1227                 tableContainer.innerHTML = `
1228                <div class="table-responsive">
1229                    <table class="table table-hover">
1230                        <thead>
1231                            <tr>
1232                                <th style="width: 120px;">${t('Datum')}</th>
1233                                <th>${t('Aufgabe')}</th>
1234                                <th>${t('Nachricht')}</th>
1235                                <th style="width: 80px;">${t('Status')}</th>
1236                                <th style="width: 50px;">${t('Aktion')}</th>
1237                            </tr>
1238                        </thead>
1239                        <tbody>
1240                                <tr><td colspan="5" class="text-center text-muted">${t('Keine Aufgaben vorhanden.')}</td></tr>
1241                            </tbody>
1242                        </table>
1243                    </div>`;
1244                cardsContainer.innerHTML = noTasksMsg;
1245                return;
1246            }
1247
1248            let tableRowsHtml = '';
1249            let cardsHtml = '';
1250
1251            snapshot.docs.forEach(doc => {
1252                                const task = doc.data();
1253                const taskId = doc.id;
1254                                const date = task.created_at ? new Date(_tsToMillis(task.created_at)) : new Date();
1255                // More concise date formatting
1256                const formattedDate = date.toLocaleString(getLocale(), {
1257                    day: '2-digit', month: '2-digit', year: 'numeric',
1258                    hour: '2-digit', minute: '2-digit'
1259                });
1260                                
1261                                let statusClass = '';
1262                                let statusText = task.status || t('Offen');
1263                                
1264                switch (statusText.toLowerCase()) {
1265                                    case 'läuft':
1266                        statusClass = 'bg-warning text-dark'; // Added text-dark for better contrast
1267                        statusText = t('Läuft'); // Consistent capitalization
1268                                        break;
1269                                    case 'erledigt':
1270                                        statusClass = 'bg-success';
1271                        statusText = t('Erledigt');
1272                                        break;
1273                                    case 'offen':
1274                                    default:
1275                                        statusClass = 'bg-primary';
1276                                        statusText = t('Offen');
1277                                        break;
1278                                }
1279
1280                const isDone = statusText.toLowerCase() === 'erledigt';
1281                const rowClass = isDone ? 'table-light text-muted' : ''; // Added text-muted for done tasks
1282                const cardClass = isDone ? 'bg-light text-muted' : '';
1283
1284                // Generate Table Row HTML
1285                tableRowsHtml += `
1286                                    <tr class="${rowClass}">
1287                                        <td>${this.esc(formattedDate)}</td>
1288                                        <td>${this.esc(task.task)}</td>
1289                                        <td>${this.esc(task.message)}</td>
1290                                        <td><span class="badge ${statusClass}">${this.esc(statusText)}</span></td>
1291                                        <td>
1292                            <button class="btn btn-sm btn-danger delete-task" data-id="${this.esc(taskId)}" title="${t('Aufgabe löschen')}">
1293                                                <i class="fas fa-trash"></i>
1294                                            </button>
1295                                        </td>
1296                                    </tr>
1297                                `;
1298
1299                // Generate Card HTML
1300                 cardsHtml += `
1301                    <div class="card mb-2 task-card ${cardClass}">
1302                        <div class="card-body py-2 px-3">
1303                            <div class="d-flex justify-content-between align-items-start mb-1">
1304                                <h6 class="card-title mb-0 me-2">${this.esc(task.task) || t('Keine Aufgabe')}</h6>
1305                                <span class="badge ${statusClass} ms-auto flex-shrink-0">${this.esc(statusText)}</span>
1306                            </div>
1307                            <p class="card-text mb-1 small">${this.esc(task.message) || t('Keine Nachricht')}</p>
1308                            <div class="d-flex justify-content-between align-items-center">
1309                                <small class="text-muted">
1310                                    <i class="far fa-clock"></i> ${this.esc(formattedDate)}
1311                                </small>
1312                                <button class="btn btn-sm btn-danger delete-task" data-id="${this.esc(taskId)}" title="${t('Aufgabe löschen')}">
1313                                    <i class="fas fa-trash"></i>
1314                                </button>
1315                            </div>
1316                        </div>
1317                    </div>
1318                `;
1319            });
1320
1321            // Populate Table Container
1322            tableContainer.innerHTML = `
1323                <div class="table-responsive">
1324                    <table class="table table-hover">
1325                        <thead>
1326                            <tr>
1327                                <th style="width: 120px;">${t('Datum')}</th>
1328                                <th>${t('Aufgabe')}</th>
1329                                <th>${t('Nachricht')}</th>
1330                                <th style="width: 80px;">${t('Status')}</th>
1331                                <th style="width: 50px;">${t('Aktion')}</th>
1332                            </tr>
1333                        </thead>
1334                        <tbody>
1335                            ${tableRowsHtml}
1336                        </tbody>
1337                    </table>
1338                </div>
1339            `;
1340
1341            // Populate Cards Container
1342            cardsContainer.innerHTML = cardsHtml;
1343
1344            // Add event listeners for delete buttons in BOTH containers
1345            tableContainer.querySelectorAll('.delete-task').forEach(button => {
1346                button.addEventListener('click', () => this.deleteTask(button.dataset.id));
1347            });
1348            cardsContainer.querySelectorAll('.delete-task').forEach(button => {
1349                button.addEventListener('click', () => this.deleteTask(button.dataset.id));
1350            });
1351
1352        } catch (error) {
1353            console.error('Fehler beim Laden der Tasks:', error);
1354            const errorMsg = `<div class="alert alert-danger">Fehler beim Laden der Tasks: ${error.message}</div>`;
1355            tableContainer.innerHTML = errorMsg;
1356            cardsContainer.innerHTML = errorMsg;
1357        }
1358    }
1359
1360    async deleteTask(taskId) {
1361        if (!confirm(t('Möchten Sie diese Aufgabe wirklich löschen?'))) return;
1362
1363        try {
1364            await contactsApi.deleteTask(this.contactId, taskId);
1365
1366            UI.showSuccess(t('Aufgabe erfolgreich gelöscht.'));
1367            await this.renderTasks(); // Aktualisiert die Anzeige der Tasks
1368        } catch (error) {
1369            console.error('Fehler beim Löschen des Tasks:', error);
1370            UI.showError(t('Fehler beim Löschen des Tasks.'));
1371        }
1372    }
1373
1374    async renderCallSummary() {
1375        const tableBody = document.getElementById('callSummaryTableBody');
1376        const cardsContainer = document.getElementById('callSummaryCardsContainer');
1377        const tableContainer = document.getElementById('callSummaryTableContainer'); // To access the wrapper for structure
1378
1379        if (!tableBody || !cardsContainer || !tableContainer) {
1380            console.error('Container für Anrufhistorie (Tabelle, Karten oder Tabellen-Wrapper) nicht gefunden.');
1381            return; // Avoid further errors if containers are missing
1382        }
1383
1384        // Initial loading state
1385        tableBody.innerHTML = '<tr><td colspan="3" class="text-center text-muted">Lade Historie...</td></tr>';
1386        cardsContainer.innerHTML = '<div class="text-center text-muted p-3">Lade Historie...</div>'; // Added padding for card view
1387
1388        try {
1389            const user = auth.currentUser;
1390            if (!user) {
1391                console.error('Benutzer nicht eingeloggt.');
1392                const msg = 'Bitte melden Sie sich an, um die Historie zu sehen.';
1393                tableBody.innerHTML = `<tr><td colspan="3" class="text-center text-muted">${msg}</td></tr>`;
1394                cardsContainer.innerHTML = `<p class="text-center text-muted p-3">${msg}</p>`;
1395                return;
1396            }
1397
1398            // Get user's tenant ID and audio playback permission
1399                        const userSnap = await _phase8UserSnap(user.uid); /* phase8 */
1400            
1401            if (!userSnap.exists()) {
1402                console.error('Benutzerdaten nicht gefunden');
1403                 const msg = 'Benutzerdaten nicht gefunden.';
1404                 tableBody.innerHTML = `<tr><td colspan="6" class="text-center text-muted">${msg}</td></tr>`;
1405                 cardsContainer.innerHTML = `<p class="text-center text-muted p-3">${msg}</p>`;
1406                return;
1407            }
1408
1409            const userData = userSnap.data();
1410            const tenantId = userData.tenant_id;
1411            // NEU: Buttons anzeigen, wenn audio_playback NICHT explizit false ist
1412            const showAudioButtons = userData.audio_playback !== false;
1413
1414            if (!tenantId) {
1415                console.error('Keine Tenant-ID für Benutzer gefunden');
1416                 const msg = 'Keine Tenant-ID gefunden.';
1417                 tableBody.innerHTML = `<tr><td colspan="6" class="text-center text-muted">${msg}</td></tr>`;
1418                 cardsContainer.innerHTML = `<p class="text-center text-muted p-3">${msg}</p>`;
1419                return;
1420            }
1421
1422            // Check for contact - now also supports phone number fallback
1423            if (!this.contact) {
1424                const msg = t('Keine Anrufe vorhanden.');
1425                tableBody.innerHTML = `<tr><td colspan="3" class="cd-empty"><i class="fas fa-phone-slash"></i>${msg}</td></tr>`;
1426                cardsContainer.innerHTML = `<div class="cd-empty"><i class="fas fa-phone-slash"></i>${msg}</div>`;
1427                return;
1428            }
1429
1430            // Fetch decrypted call summaries via backend API
1431            let callSummaries = [];
1432            try {
1433                const idToken = await user.getIdToken();
1434                const resp = await fetch(`/api/contacts/${this.contactId}/call-summaries`, {
1435                    headers: { 'Authorization': `Bearer ${idToken}` }
1436                });
1437                if (resp.ok) {
1438                    callSummaries = await resp.json();
1439                } else {
1440                    console.error('Fehler beim Laden der Anrufhistorie:', resp.status);
1441                    throw new Error(`API error ${resp.status}`);
1442                }
1443            } catch(queryError) {
1444                 console.error("Fehler bei der Abfrage der Anrufhistorie:", queryError);
1445                 throw queryError;
1446            }
1447
1448            if (!callSummaries.length) {
1449                const msg = t('Keine Anrufe vorhanden.');
1450                tableBody.innerHTML = `<tr><td colspan="3" class="cd-empty"><i class="fas fa-phone-slash"></i>${msg}</td></tr>`;
1451                cardsContainer.innerHTML = `<div class="cd-empty"><i class="fas fa-phone-slash"></i>${msg}</div>`;
1452                return;
1453            }
1454
1455            let tableRowsHtml = '';
1456            let cardsHtml = '';
1457            const summariesData = [];
1458
1459            callSummaries.forEach((summary, index) => {
1460                const summaryId = summary.id;
1461                summariesData.push(summary);
1462
1463                const timestamp = this.esc(summary.created_at ? this.formatTimestamp(summary.created_at) : t('Unbekannt'));
1464                // Zusammenfassung stammt aus dem LLM ueber Anrufer-Sprache -> escapen
1465                const summaryText = this.esc(summary.summary) || t('Keine Zusammenfassung verfügbar.');
1466                const transcriptText = summary.transcript || '';
1467                // Get the best audio URL (Firebase or local)
1468                const audioUrl = this.audioHandler.getPlaybackUrl({
1469                    firebase_audio_url: summary.firebase_audio_url,
1470                    recording_url: summary.recording_url,
1471                    audio: summary.audio
1472                }) || summary.audio || '';
1473
1474                // Common HTML parts
1475                const transcriptLinkHtml = transcriptText
1476                    ? `<br><span class="transcript-link" data-summary-index="${index}" style="cursor: pointer; color: #007bff; font-size: 0.9em;">${t('Transkript')} <i class="fas fa-file-alt"></i></span>`
1477                    : '';
1478
1479                const audioButtonsHtml = showAudioButtons && audioUrl ? `
1480                    <button class="btn btn-sm btn-success play-button me-1" data-audio-url="${this.esc(audioUrl)}" title="${t('Abspielen')}">
1481                                    <i class="fas fa-play"></i>
1482                                </button>
1483                    <button class="btn btn-sm btn-outline-secondary stop-button" style="display: none;" title="${t('Stopp')}">
1484                                    <i class="fas fa-stop"></i>
1485                                </button>
1486                ` : '';
1487
1488                const deleteButtonHtml = `
1489                    <button class="btn btn-sm btn-outline-danger delete-summary" data-summary-id="${this.esc(summaryId)}" title="${t('Zusammenfassung löschen')}">
1490                                <i class="fas fa-trash"></i>
1491                            </button>
1492                `;
1493
1494                // Generate Table Row HTML
1495                // contact-detail-v1b: lange Zusammenfassungen einklappen
1496                const longTxt = (summary.summary || '').length > 260;
1497                const clampCls = longTxt ? ' cd-clamp' : '';
1498                const moreBtn = longTxt
1499                    ? `<button type="button" class="cd-more">${t('Mehr anzeigen')}</button>` : '';
1500
1501                tableRowsHtml += `
1502                    <tr>
1503                        <td class="align-top text-nowrap">${timestamp}</td>
1504                        <td class="align-top"><div class="summary-cell${clampCls}">${summaryText}${transcriptLinkHtml}</div>${moreBtn}</td>
1505                        <td class="text-nowrap align-top">
1506                            <div class="d-flex gap-1 justify-content-end">${audioButtonsHtml}${deleteButtonHtml}</div>
1507                        </td>
1508                </tr>
1509                `;
1510
1511                // Generate Card HTML
1512                cardsHtml += `
1513                    <div class="card mb-2 call-summary-card shadow-sm">
1514                        <div class="card-body py-2 px-3">
1515                             <div class="d-flex justify-content-between align-items-start mb-2">
1516                                 <small class="text-muted flex-shrink-0 me-2">
1517                                     <i class="far fa-calendar-alt me-1"></i>${timestamp}
1518                                </small>
1519                                 <div class="ms-auto text-nowrap d-flex align-items-center gap-1">
1520                                     ${audioButtonsHtml}${deleteButtonHtml}
1521                        </div>
1522                                </div>
1523                            <div class="card-text summary-cell small mb-0${clampCls}">${summaryText}${transcriptLinkHtml}</div>${moreBtn}
1524                </div>
1525            </div>
1526        `;
1527            });
1528
1529            // Populate containers
1530            tableBody.innerHTML = tableRowsHtml;
1531            cardsContainer.innerHTML = cardsHtml;
1532
1533            // --- Bind Events ---
1534            // Use event delegation on the containers for better performance and dynamic content handling
1535            const bindEventsToContainer = (containerElement) => {
1536                 containerElement.addEventListener('click', (event) => {
1537                     const target = event.target.closest('button, .transcript-link'); // Find the relevant clicked element
1538                     if (!target) return; // Ignore clicks elsewhere
1539
1540                     // Transcript links
1541                     if (target.classList.contains('transcript-link')) {
1542                         event.stopPropagation();
1543                         const index = parseInt(target.dataset.summaryIndex, 10);
1544                         const summary = summariesData[index];
1545                         if (summary && summary.transcript) {
1546                             this.showTranscriptModal(summary.transcript, summary.name || t('Anonym'), summary.phone_number || '');
1547                         }
1548                     }
1549                     // Delete buttons
1550                     else if (target.classList.contains('delete-summary')) {
1551                         this.deleteCallSummary(target.dataset.summaryId);
1552                     }
1553                     // Play buttons
1554                     else if (target.classList.contains('play-button') && showAudioButtons) {
1555                         const audioUrl = target.dataset.audioUrl;
1556                         const stopButton = target.nextElementSibling; // Assumes stop is immediately after play
1557                         this.playAudio(audioUrl, target, stopButton);
1558                     }
1559                     // Stop buttons
1560                     else if (target.classList.contains('stop-button') && showAudioButtons) {
1561                         const playButton = target.previousElementSibling; // Assumes play is immediately before stop
1562                         this.stopAudio(playButton, target);
1563                     }
1564                 });
1565            };
1566
1567            // Bind events to both containers
1568            bindEventsToContainer(tableContainer); // Delegate from the table's container
1569            bindEventsToContainer(cardsContainer); // Delegate from the cards' container
1570
1571
1572        } catch (error) {
1573            console.error('Fehler beim Laden oder Rendern der Call Summary:', error);
1574            const errorMsg = t('Fehler beim Laden der Anrufhistorie.'); // Keep error user-friendly
1575            tableBody.innerHTML = `<tr><td colspan="3" class="text-center text-danger">${errorMsg} <small>(${error.message})</small></td></tr>`;
1576            cardsContainer.innerHTML = `<div class="alert alert-danger">${errorMsg}</div>`;
1577        }
1578    }
1579
1580    async renderEmails() {
1581        const tableBody = document.getElementById('emailsTableBody');
1582        const cardsContainer = document.getElementById('emailsCardsContainer');
1583        const tableContainer = document.getElementById('emailsTableContainer'); // Wrapper
1584
1585        if (!tableBody || !cardsContainer || !tableContainer) {
1586            // In der Patientenansicht bewusst nicht vorhanden -> kein Fehler
1587            return;
1588        }
1589
1590        // Initial loading state
1591        tableBody.innerHTML = `<tr><td colspan="6" class="text-center text-muted">${t('Lade E-Mails...')}</td></tr>`;
1592        cardsContainer.innerHTML = `<div class="text-center text-muted p-3">${t('Lade E-Mails...')}</div>`;
1593
1594        try {
1595            // Check prerequisites: contact, customer_id, tenant_id
1596            if (!this.contact) {
1597                 console.error('Kontaktinformationen nicht geladen.');
1598                 throw new Error('Kontaktinformationen nicht geladen.');
1599            }
1600             const customerId = this.contact.customer_id;
1601             const tenantId = this.contact.tenant_id || this.contact.tenantID; // Handle potential variations
1602
1603             if (!customerId) {
1604// console.log('Keine customer_id für Kontakt gefunden:', this.contactId);
1605                const msg = t('Keine E-Mail-Historie vorhanden (Keine Kunden-ID).');
1606                 tableBody.innerHTML = `<tr><td colspan="6" class="text-center text-muted">${msg}</td></tr>`;
1607                 cardsContainer.innerHTML = `<p class="text-center text-muted p-3">${msg}</p>`;
1608                return;
1609             }
1610             if (!tenantId) {
1611                 console.error('Keine tenant_id für Kontakt gefunden:', this.contactId);
1612                 const msg = t('Keine E-Mail-Historie vorhanden (Keine Mandanten-ID).');
1613                 tableBody.innerHTML = `<tr><td colspan="6" class="text-center text-muted">${msg}</td></tr>`;
1614                 cardsContainer.innerHTML = `<p class="text-center text-muted p-3">${msg}</p>`;
1615                 return; // Cannot query without tenantId
1616             }
1617
1618
1619            const _emails = await contactsApi.listEmailHistory(this.contactId);
1620            const _emailsArr = (_emails && _emails.emails) ? _emails.emails : (Array.isArray(_emails) ? _emails : []);
1621            const snapshotCustomer = {
1622                empty: _emailsArr.length === 0,
1623                size: _emailsArr.length,
1624                forEach: (fn) => _emailsArr.forEach((e, i) => fn({ id: e.id, data: () => e }))
1625            };
1626            const snapshotRecipient = { empty: true, size: 0, forEach: () => {} };
1627            
1628// console.log('Email query results:', {
1629            //     byCustomerId: snapshotCustomer.size,
1630            //     byRecipientId: snapshotRecipient.size,
1631            //     expectedTenantId: tenantIdString,
1632            //     contactTenantId: tenantId
1633            // });
1634            
1635            
1636            // Combine results
1637            const allEmails = [];
1638            snapshotCustomer.forEach(doc => allEmails.push({ id: doc.id, ...doc.data() }));
1639            snapshotRecipient.forEach(doc => {
1640                const data = doc.data();
1641                // Filter by tenant_id and avoid duplicates
1642                const emailTenantId = data.tenant_id || data.tenantID;
1643                
1644                // Check if email belongs to current user (either by tenant_id or by user_id)
1645                const currentUserId = auth.currentUser?.uid;
1646                const emailBelongsToUser = !emailTenantId || 
1647                    emailTenantId.toString() === tenantIdString || 
1648                    emailTenantId === currentUserId;
1649                
1650// console.log(`Email ${doc.id}: tenant_id=${emailTenantId}, expected=${tenantIdString}, userId=${currentUserId}, match=${emailBelongsToUser}`);
1651                
1652                if (!allEmails.find(e => e.id === doc.id)) {
1653                    if (emailBelongsToUser) {
1654                        allEmails.push({ id: doc.id, ...data });
1655                    }
1656                }
1657            });
1658            
1659            // Sort by sent_at descending
1660            allEmails.sort((a, b) => {
1661                const dateA = a.sent_at?.toMillis ? a.sent_at.toMillis() : 0;
1662                const dateB = b.sent_at?.toMillis ? b.sent_at.toMillis() : 0;
1663                return dateB - dateA;
1664            });
1665            
1666// console.log('DEBUG - Total email results:', allEmails.length);
1667
1668
1669            if (allEmails.length === 0) {
1670                 const noEmailsMsg = t('Keine E-Mail-Historie vorhanden.');
1671                 tableBody.innerHTML = `<tr><td colspan="6" class="text-center text-muted">${noEmailsMsg}</td></tr>`;
1672                 cardsContainer.innerHTML = `<p class="text-center text-muted p-3">${noEmailsMsg}</p>`;
1673                return;
1674            }
1675
1676            let tableRowsHtml = '';
1677            let cardsHtml = '';
1678
1679            allEmails.forEach(email => {
1680
1681                 const date = this.esc(email.sent_at ? this.formatTimestamp(email.sent_at) : t('Unbekannt'));
1682                 const subject = this.esc(email.subject) || t('(Kein Betreff)');
1683                 const type = email.type === 'group' ? t('Gruppe') : (this.esc(email.type) || t('Standard')); // Handle missing type
1684                 const readStatus = email.read_status || false;
1685                 const statusIcon = readStatus
1686                    ? `<i class="fas fa-check-circle text-success" title="${t('Gelesen')}"></i>`
1687                    : `<i class="fas fa-envelope text-primary" title="${t('Gesendet/Ungelesen')}"></i>`;
1688                const statusText = readStatus ? t('Gelesen') : t('Gesendet');
1689
1690                 // Calculate tracking stats
1691                 const openCount = email.open_count || 0;
1692                 const totalClicks = email.total_clicks || 0;
1693                 
1694                 // Count link clicks
1695                 let linkClicks = 0;
1696                 if (email.links && Array.isArray(email.links)) {
1697                     email.links.forEach(link => {
1698                         linkClicks += link.click_count || 0;
1699                     });
1700                 }
1701                 
1702                 const clicksToShow = totalClicks || linkClicks;
1703                 
1704                 // Format open and click badges
1705                 const openBadge = openCount > 0 
1706                     ? `<span class="badge bg-success">${openCount}</span>`
1707                     : '<span class="badge bg-secondary">0</span>';
1708                 
1709                 const clickBadge = clicksToShow > 0
1710                     ? `<span class="badge bg-info">${clicksToShow}</span>`
1711                     : '<span class="badge bg-secondary">0</span>';
1712
1713                 // Generate Table Row HTML
1714                 tableRowsHtml += `
1715                    <tr class="${readStatus ? 'table-light text-muted' : ''}">
1716                        <td style="width: 110px; max-width: 110px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${date}</td>
1717                        <td style="overflow: hidden; text-overflow: ellipsis;">${subject}</td>
1718                        <td style="width: 60px; max-width: 60px;">${type}</td>
1719                        <td class="text-center" style="width: 60px;">${statusIcon}</td>
1720                        <td class="text-center" style="width: 85px;">${openBadge}</td>
1721                        <td class="text-center" style="width: 90px; min-width: 90px;">${clickBadge}</td>
1722                    </tr>
1723                `;
1724
1725                 // Generate Card HTML
1726                 cardsHtml += `
1727                    <div class="card mb-2 email-card shadow-sm ${readStatus ? 'bg-light text-muted' : ''}">
1728                        <div class="card-body py-2 px-3">
1729                             <div class="d-flex justify-content-between align-items-start mb-1">
1730                                 <h6 class="card-title mb-0 me-2 small">${subject}</h6>
1731                                 <span class="ms-auto flex-shrink-0" style="font-size: 1.1em;">${statusIcon}</span>
1732                            </div>
1733                             <div class="d-flex justify-content-between align-items-center mb-1">
1734                                  <small class="text-muted">
1735                                     <i class="far fa-calendar-alt me-1"></i>${date}
1736                                 </small>
1737                                 <small class="badge bg-secondary">${type}</small>
1738                            </div>
1739                            <div class="d-flex justify-content-start gap-2">
1740                                <small>
1741                                    <i class="fas fa-envelope-open text-muted me-1"></i>
1742                                    Geöffnet: ${openBadge}
1743                                </small>
1744                                <small>
1745                                    <i class="fas fa-mouse-pointer text-muted me-1"></i>
1746                                    Geklickt: ${clickBadge}
1747                                </small>
1748                            </div>
1749                        </div>
1750                    </div>
1751                </div>
1752            `;
1753            });
1754
1755            // Populate containers
1756            tableBody.innerHTML = tableRowsHtml;
1757            cardsContainer.innerHTML = cardsHtml;
1758
1759            // Optional: Add event listeners for click events if modals are implemented
1760            // bindEventsToContainer(tableContainer);
1761            // bindEventsToContainer(cardsContainer);
1762
1763
1764        } catch (error) {
1765            console.error('Fehler beim Laden oder Rendern der E-Mails:', error);
1766            const errorMsg = `Fehler beim Laden der E-Mail-Historie.`;
1767             tableBody.innerHTML = `<tr><td colspan="3" class="text-center text-danger">${errorMsg} <small>(${error.message})</small></td></tr>`;
1768             cardsContainer.innerHTML = `<div class="alert alert-danger">${errorMsg}</div>`;
1769        }
1770    }
1771
1772    async renderAppointments() {
1773        const placeholder = document.getElementById('appointmentsCardPlaceholder');
1774        if (!placeholder) {
1775            // In der Patientenansicht bewusst nicht vorhanden (Akte-Tab "Termine")
1776            return;
1777        }
1778
1779        // Create the main structure within the placeholder
1780        placeholder.innerHTML = `
1781            <div class="card shadow-sm mb-4">
1782                <div class="card-body">
1783                    <h5 class="card-title mb-3">${t('Termine')}</h5>
1784                    <!-- Desktop Table View -->
1785                    <div id="appointmentsTableContainer" class="table-responsive d-none d-md-block">
1786                        <table class="table table-hover">
1787                                <thead>
1788                                    <tr>
1789                                    <th style="width: 100px;">${t('Datum')}</th>
1790                                    <th style="width: 60px;">${t('Zeit')}</th>
1791                                        <th>${t('Titel')}</th>
1792                                    </tr>
1793                                </thead>
1794                            <tbody id="appointmentsTableBody">
1795                                <tr><td colspan="3" class="text-center text-muted">${t('Lade Termine...')}</td></tr>
1796                            </tbody>
1797                        </table>
1798                    </div>
1799                    <!-- Mobile Card View -->
1800                    <div id="appointmentsCardsContainer" class="d-block d-md-none">
1801                        <div class="text-center text-muted p-3">${t('Lade Termine...')}</div>
1802                    </div>
1803                </div>
1804            </div>
1805        `;
1806
1807        const tableBody = document.getElementById('appointmentsTableBody');
1808        const cardsContainer = document.getElementById('appointmentsCardsContainer');
1809        // Get containers for event delegation
1810        const tableContainer = document.getElementById('appointmentsTableContainer');
1811        // cardsContainer already defined
1812
1813        if (!tableBody || !cardsContainer || !tableContainer) {
1814             console.error('Container für Termine (Tabelle oder Karten) nach Erstellung nicht gefunden.');
1815             return;
1816        }
1817
1818
1819        try {
1820            const appointments = await this.loadAppointments();
1821// console.log('Appointments loaded:', appointments);
1822
1823            if (!appointments || appointments.length === 0) {
1824                const noAppointmentsMsg = `<p class="text-center text-muted p-3">${t('Keine Termine vorhanden.')}</p>`;
1825                 tableBody.innerHTML = `<tr><td colspan="3" class="text-center text-muted">${t('Keine Termine vorhanden.')}</td></tr>`;
1826                 cardsContainer.innerHTML = noAppointmentsMsg;
1827                return;
1828            }
1829
1830            let tableRowsHtml = '';
1831            let cardsHtml = '';
1832
1833            appointments.forEach(appointment => {
1834                const startTime = appointment.startTime;
1835                // Phase 3.4: robustes Datum-Parsing (Firestore-Timestamp ODER ISO-String aus PG)
1836                let dateObj = null;
1837                if (startTime && typeof startTime.toDate === 'function') {
1838                    dateObj = startTime.toDate();
1839                } else if (startTime) {
1840                    let _raw = startTime;
1841                    if (typeof startTime === 'object') {
1842                        _raw = startTime.iso || (startTime.seconds != null ? new Date(startTime.seconds * 1000).toISOString() : null) || startTime;
1843                    }
1844                    const _d = new Date(_raw);
1845                    dateObj = isNaN(_d.getTime()) ? null : _d;
1846                }
1847                if (!dateObj) {
1848                    console.warn('Ungültiges Startdatum für Termin:', appointment.id, appointment);
1849                    return; // Skip this appointment
1850                }
1851                 const formattedDate = dateObj.toLocaleDateString(getLocale(), { day: '2-digit', month: '2-digit', year: 'numeric' });
1852                 const formattedTime = dateObj.toLocaleTimeString(getLocale(), { hour: '2-digit', minute: '2-digit' });
1853                 const title = appointment.title || t('(Kein Titel)');
1854                 const appointmentId = appointment.id; // Firestore document ID
1855                 // Phase 3.4: Behandler-Badge aus serverseitig aufgeloesten resource_names
1856                 const _rn = Array.isArray(appointment.resource_names) ? appointment.resource_names : [];
1857                 const _behandlerBadge = _rn.length ? ` <span class="badge bg-info-subtle text-info-emphasis ms-1" style="font-weight:500;">${t('bei')} ${_rn.map(function(n){return String(n).replace(/</g,'&lt;');}).join(', ')}</span>` : '';
1858
1859                // Common data attributes for click handler
1860                const dataAttributes = `data-appointment-id="${appointmentId}"`; // Simplified, assuming ID is enough
1861
1862                // Generate Table Row HTML
1863                tableRowsHtml += `
1864                    <tr class="appointment-item" style="cursor: pointer" ${dataAttributes} title="Klicken um Details zu sehen">
1865                        <td>${formattedDate}</td>
1866                        <td>${formattedTime}</td>
1867                        <td>${title}${_behandlerBadge}</td>
1868                                            </tr>
1869                                        `;
1870
1871                // Generate Card HTML
1872                cardsHtml += `
1873                    <div class="card mb-2 appointment-item shadow-sm" style="cursor: pointer" ${dataAttributes} title="Klicken um Details zu sehen">
1874                        <div class="card-body py-2 px-3">
1875                             <h6 class="card-title mb-1 small">${title}${_behandlerBadge}</h6>
1876                             <div class="d-flex justify-content-between align-items-center">
1877                                 <small class="text-muted">
1878                                    <i class="far fa-calendar-alt me-1"></i>${formattedDate}
1879                                 </small>
1880                                 <small class="text-muted">
1881                                    <i class="far fa-clock me-1"></i>${formattedTime} Uhr
1882                                </small>
1883                        </div>
1884                </div>
1885            </div>
1886        `;
1887            });
1888
1889            // Populate containers
1890            tableBody.innerHTML = tableRowsHtml;
1891            cardsContainer.innerHTML = cardsHtml;
1892
1893            // --- Bind Event Listener (Delegation) ---
1894            const handleAppointmentClick = async (event) => {
1895                 const targetItem = event.target.closest('.appointment-item');
1896                 if (!targetItem) return; // Click was not on an appointment item
1897
1898                 const appointmentId = targetItem.dataset.appointmentId;
1899                 if (!appointmentId) {
1900                     console.error('Keine Termin-ID am geklickten Element gefunden.');
1901                     return;
1902                 }
1903
1904// console.log('Appointment clicked:', appointmentId);
1905
1906                 try {
1907                     // Fetch event details from Firestore using the appointmentId
1908                    let _evRaw = null;
1909                    try { _evRaw = await contactsApi.getCalendarEvent(appointmentId); } catch (e) { _evRaw = null; }
1910                    const eventDoc = { exists: () => !!_evRaw, data: () => _evRaw };
1911                    if (eventDoc.exists()) {
1912                        const eventData = eventDoc.data();
1913                        
1914                         // Ensure Calendar module and dependencies are ready
1915                          if (typeof Calendar === 'undefined' || !Calendar.createEventModal || !Calendar.showEventDetails) {
1916                             console.error('Calendar Modul oder Funktionen nicht verfügbar.');
1917                             UI.showError('Fehler beim Laden der Kalenderfunktionen.');
1918                             return;
1919                          }
1920                          // Ensure tenant context is set if needed by Calendar
1921                           window.currentTenantId = this.contact.tenant_id; // Assuming Calendar needs this globally
1922
1923                         // Create/ensure the modal exists
1924                        if (!document.getElementById('eventDetailsModal')) {
1925                            Calendar.createEventModal();
1926                              // Load necessary types if Calendar requires it (might need await)
1927                              if (Calendar.loadCalendarTypes) await Calendar.loadCalendarTypes();
1928                        }
1929                        
1930                         // Prepare event object for the modal
1931                        const event = {
1932                            id: appointmentId,
1933                             title: eventData.title || eventData.name || t('(Kein Titel)'), // Use name as fallback
1934                             start: eventData.startTime?.toDate ? eventData.startTime.toDate() : null,
1935                             end: eventData.endTime?.toDate ? eventData.endTime.toDate() : null,
1936                             backgroundColor: eventData.calendar_type?.color || '#3788d8', // Default color
1937                            borderColor: eventData.calendar_type?.color || '#3788d8',
1938                            extendedProps: {
1939                                description: eventData.description || '',
1940                                 calendar_type: eventData.calendar_type, // Pass the whole type object if needed
1941                                 // Include other relevant fields from eventData if the modal uses them
1942                                 phone: eventData.phone || this.contact.phone_number, // Use contact phone as fallback
1943                                 email: eventData.email || this.contact.email,     // Use contact email as fallback
1944                                 name: eventData.name || this.contact.name, // Use contact name as fallback
1945                                 // Add any other fields needed by showEventDetails
1946                             }
1947                         };
1948
1949                          if (!event.start || !event.end) {
1950                             console.error("Ungültige Start/End-Zeiten für Termin:", event.id, eventData);
1951                             UI.showError("Termindaten sind unvollständig.");
1952                             return;
1953                          }
1954
1955
1956                         // Show the event details modal
1957                         await Calendar.showEventDetails(event); // Assuming this handles the display
1958
1959                     } else {
1960                         console.error('Termindokument nicht gefunden in Firestore:', appointmentId);
1961                         UI.showError('Termindaten konnten nicht geladen werden.');
1962                    }
1963                } catch (error) {
1964                     console.error('Fehler beim Laden oder Anzeigen der Termindetails:', error);
1965                     UI.showError('Fehler beim Öffnen der Termindetails.');
1966                 }
1967            };
1968
1969             // Attach listener to both containers
1970             tableContainer.addEventListener('click', handleAppointmentClick);
1971             cardsContainer.addEventListener('click', handleAppointmentClick);
1972
1973
1974        } catch (error) {
1975            console.error('Fehler beim Laden oder Rendern der Termine:', error);
1976            const errorMsg = `Fehler beim Laden der Termine.`;
1977             tableBody.innerHTML = `<tr><td colspan="3" class="text-center text-danger">${errorMsg} <small>(${error.message})</small></td></tr>`;
1978             cardsContainer.innerHTML = `<div class="alert alert-danger">${errorMsg}</div>`;
1979        }
1980    }
1981
1982    async loadAppointments() {
1983        try {
1984            const user = auth.currentUser;
1985            if (!user) {
1986                console.error('Benutzer nicht eingeloggt für Terminabfrage.');
1987                return [];
1988            }
1989
1990            // Ensure contact data is loaded, especially phone_number and tenant_id
1991            if (!this.contact || !this.contact.phone_number) {
1992                 console.warn('Kontaktdaten (Telefonnummer) fehlen für Terminabfrage.');
1993                return [];
1994            }
1995            const tenantId = this.contact.tenant_id || this.contact.tenantID;
1996             if (!tenantId) {
1997                 console.warn('Tenant-ID fehlt für Terminabfrage.');
1998                 return [];
1999             }
2000
2001
2002            const _appResp = await contactsApi.listAppointments(this.contactId);
2003            const appointments = (_appResp && _appResp.appointments) ? _appResp.appointments
2004                                : (Array.isArray(_appResp) ? _appResp : []);
2005            
2006// console.log(`DEBUG - loadAppointments: Found ${appointments.length} appointments for phone ${this.contact.phone_number} and tenant ${tenantId}`);
2007            return appointments;
2008
2009        } catch (error) {
2010            console.error('Error loading appointments:', error);
2011            // Avoid breaking the UI, return empty array
2012            return [];
2013        }
2014    }
2015
2016    bindEvents() {
2017        try {
2018            const form = document.getElementById('contactForm');
2019            if (form) {
2020                // Remove previous listener to avoid duplicates if re-rendered
2021                const newForm = form.cloneNode(true);
2022                form.parentNode.replaceChild(newForm, form);
2023                newForm.addEventListener('submit', (e) => {
2024                    e.preventDefault();
2025                    this.updateContactDetails();
2026                });
2027            }
2028
2029            // kundenfelder-v1: Verwaltung der eigenen Felder. Nach dem Speichern
2030            // Definitionen neu laden und die Ansicht komplett neu aufbauen
2031            // (renderAll bindet Events und Sub-Renderer wieder an).
2032            const cfBtn = document.getElementById('cfManageBtn');
2033            if (cfBtn) {
2034                cfBtn.addEventListener('click', () => {
2035                    zeigeFeldVerwaltung(async () => {
2036                        this.customFieldDefs = await ladeKundenfelder(true);
2037                        await this.renderAll();
2038                    });
2039                });
2040            }
2041
2042            // Event-Listener für Email-Button
2043            const emailBtn = document.querySelector('.email-send-btn');
2044            if (emailBtn) {
2045                emailBtn.addEventListener('click', async (e) => {
2046                    e.preventDefault();
2047                    const contactId = e.currentTarget.getAttribute('data-contact-id');
2048                    if (contactId) {
2049                        await ContactDetail.openEmailCompose(contactId);
2050                    }
2051                });
2052            }
2053
2054            // Event-Listener für Anrufen-Button neben der Telefonnummer
2055            const phoneCallBtn = document.querySelector('.phone-call-btn');
2056            if (phoneCallBtn) {
2057                phoneCallBtn.addEventListener('click', (e) => {
2058                    e.preventDefault();
2059                    const phoneInput = document.querySelector('#contactForm input[name="phone_number"]');
2060                    const number = (phoneInput?.value || '').trim();
2061                    if (!number) {
2062                        alert(t('Bitte zuerst eine Telefonnummer eingeben.'));
2063                        return;
2064                    }
2065                    if (window.voSoftphone && typeof window.voSoftphone.dial === 'function') {
2066                        window.voSoftphone.dial(number);
2067                    } else {
2068                        console.error('[ContactDetail] voSoftphone not available');
2069                        alert(t('Softphone ist nicht geladen.'));
2070                    }
2071                });
2072            }
2073
2074            // --- NEU: Event Delegation für Aktionsbuttons ---
2075            const actionButtonContainer = document.getElementById('contactDetailActionButtons');
2076            if (actionButtonContainer) {
2077                // Entferne vorsichtshalber alte Listener (obwohl Delegation dies weniger anfällig macht)
2078                const newContainer = actionButtonContainer.cloneNode(true);
2079                actionButtonContainer.parentNode.replaceChild(newContainer, actionButtonContainer);
2080
2081                newContainer.addEventListener('click', async (event) => {
2082                    const button = event.target.closest('button');
2083                    if (!button) {
2084                        return;
2085                    }
2086
2087                    if (button.id === 'backButton') {
2088                        console.log('Zurück-Button geklickt');
2089                        // CHANGE: Verwende die neue Zurück-Navigation mit Zustandswiederherstellung
2090                        try {
2091                            await this.navigateBackToContacts();
2092                        } catch (error) {
2093                            console.error('Fehler beim Navigieren zurück:', error);
2094                            // Fallback: Direkte Navigation
2095                            window.location.href = '?page=contacts';
2096                        }
2097                    } else if (button.id === 'deleteButton') {
2098                        // Call the method directly, it's already bound in constructor
2099                        this.deleteContact();
2100                    }
2101                });
2102            } else {
2103                console.error('Action button container #contactDetailActionButtons not found!');
2104            }
2105            // --- Ende Event Delegation ---
2106
2107            // FIX: natives prompt() ersetzt durch ein Inline-Feld.
2108            // prompt() ist einzeilig, nicht formatierbar und wird in einigen
2109            // Browsern (und in eingebetteten Views) stillschweigend blockiert.
2110            const addNoteBtn = document.getElementById('addNoteBtn');
2111            const noteBox = document.getElementById('newNoteBox');
2112            const noteText = document.getElementById('newNoteText');
2113            if (addNoteBtn && noteBox && noteText) {
2114                const newAddNoteBtn = addNoteBtn.cloneNode(true);
2115                addNoteBtn.parentNode.replaceChild(newAddNoteBtn, addNoteBtn);
2116                newAddNoteBtn.addEventListener('click', () => {
2117                    const showing = noteBox.style.display !== 'none';
2118                    noteBox.style.display = showing ? 'none' : 'block';
2119                    if (!showing) noteText.focus();
2120                });
2121                const cancelBtn = document.getElementById('cancelNoteBtn');
2122                if (cancelBtn) cancelBtn.addEventListener('click', () => {
2123                    noteText.value = '';
2124                    noteBox.style.display = 'none';
2125                });
2126                const saveBtn = document.getElementById('saveNoteBtn');
2127                if (saveBtn) saveBtn.addEventListener('click', async () => {
2128                    const content = noteText.value.trim();
2129                    if (!content) {
2130                        UI.showError(t('Bitte zuerst einen Text eingeben.'));
2131                        return;
2132                    }
2133                    saveBtn.disabled = true;
2134                    try {
2135                        await this.addNote(content);
2136                        noteText.value = '';
2137                        noteBox.style.display = 'none';
2138                    } finally {
2139                        saveBtn.disabled = false;
2140                    }
2141                });
2142            }
2143
2144            // Physio: Stammdaten ein-/ausklappen. Der Zustand ueberlebt den
2145            // Re-Render nach dem Speichern.
2146            const toggleBtn = document.getElementById('toggleStammdatenBtn');
2147            if (toggleBtn) {
2148                toggleBtn.addEventListener('click', () => {
2149                    const f = document.getElementById('contactForm');
2150                    if (!f) return;
2151                    const willOpen = f.style.display === 'none';
2152                    f.style.display = willOpen ? 'block' : 'none';
2153                    this._stammdatenOpen = willOpen;
2154                    toggleBtn.setAttribute('aria-expanded', willOpen ? 'true' : 'false');
2155                    const ic = toggleBtn.querySelector('i');
2156                    if (ic) ic.className = `fas fa-chevron-${willOpen ? 'up' : 'down'} me-1`;
2157                });
2158            }
2159
2160            // Physio: Datepicker (ISO) auf das gespeicherte Format DD.MM.YYYY
2161            // spiegeln - das Hidden-Feld ist das, was in FormData landet.
2162            const gebInput = document.getElementById('physioGeburtsdatum');
2163            if (gebInput) {
2164                const sync = () => {
2165                    const hiddenGeb = document.querySelector('#contactForm input[name="geburtsdatum"]');
2166                    if (hiddenGeb) hiddenGeb.value = this._isoToDe(gebInput.value);
2167                };
2168                gebInput.addEventListener('change', sync);
2169                gebInput.addEventListener('input', sync);
2170            }
2171
2172            // Event listener for transcript links (delegated in renderCallSummary)
2173            // Event listeners for task deletion (delegated in renderTasks)
2174            // Event listeners for note deletion (delegated in renderNotes)
2175            // Event listeners for audio controls (delegated in renderCallSummary)
2176            // Event listener for appointment click (delegated in renderAppointments)
2177
2178        } catch (error) {
2179            console.error("Error binding events:", error);
2180            UI.showError(t('Fehler beim Binden der Event Listener.'));
2181        }
2182    }
2183
2184    async deleteContact() {
2185        const q = this.isPhysio()
2186            ? t('Diesen Patienten wirklich löschen? Die Patientenakte wird mitgelöscht.')
2187            : t('Sind Sie sicher, dass Sie diesen Kontakt löschen möchten?');
2188        if (!confirm(q)) {
2189            return;
2190        }
2191
2192        try {
2193            await contactsApi.delete(this.contactId);
2194
2195            UI.showSuccess(this.isPhysio() ? t('Patient erfolgreich gelöscht.') : t('Kontakt erfolgreich gelöscht.'));
2196            Pages.loadPage('contacts');
2197        } catch (error) {
2198            console.error('Fehler beim Löschen des Kontakts:', error);
2199            UI.showError(t('Fehler beim Löschen des Kontakts.'));
2200        }
2201    }
2202
2203    async updateContactDetails() {
2204        const form = document.getElementById('contactForm');
2205        if (!form) {
2206            console.error('Kontaktformular nicht gefunden');
2207            UI.showError(t('Speichern fehlgeschlagen: Formular nicht gefunden.'));
2208            return;
2209        }
2210
2211        try {
2212            const formData = new FormData(form);
2213            const updateData = {
2214                updated_at: new Date().toISOString()
2215            };
2216
2217            // Iterate over form data and add to updateData
2218            formData.forEach((value, key) => {
2219                // Skip empty values unless it's a field that should be clearable (like notes)
2220                if (value || key === 'notes') { 
2221                    updateData[key] = value;
2222                } else if (this.contact[key]) { 
2223                    // If the form field is empty, but the contact had a value, 
2224                    // explicitly set it to an empty string to clear it in Firestore.
2225                    // Handle potential exceptions like phone_number or name if they are required.
2226                    if (key !== 'phone_number' && key !== 'name') { // Example: Don't clear required fields implicitly
2227                       updateData[key] = '';
2228                    } else if (!value && (key === 'phone_number' || key === 'name')) {
2229                        // Optionally, show an error if required fields are cleared
2230                        console.warn(`Required field "${key}" was cleared.`);
2231                        // You might want to prevent saving or handle this case specifically
2232                    }
2233                }
2234            });
2235
2236            // kundenfelder-v1: custom__-Eingaben in contact.custom buendeln.
2237            // Bestehende Werte bleiben erhalten; Werte entfernter Definitionen
2238            // werden nicht angetastet (Formular zeigt nur definierte Felder).
2239            const cfDefs = this.customFieldDefs || [];
2240            if (cfDefs.length) {
2241                const cfWerte = sammleKundenfelder(form, cfDefs);
2242                cfDefs.forEach(d => { delete updateData['custom__' + d.key]; });
2243                updateData.custom = Object.assign({}, this.contact.custom || {}, cfWerte);
2244            }
2245
2246            // Ensure tenant ID remains consistent (important for security rules)
2247            updateData.tenant_id = this.contact.tenant_id; 
2248            // Also handle tenantID case if needed
2249            if (this.contact.tenantID) {
2250                 updateData.tenantID = this.contact.tenantID;
2251            }
2252
2253
2254            await contactsApi.update(this.contactId, updateData);
2255
2256            UI.showSuccess(this.isPhysio() ? t('Patientendaten gespeichert.') : t('Kontaktdaten erfolgreich aktualisiert.'));
2257
2258            // Clear the contacts cache after successful update
2259            try {
2260                await contacts.clearContactsCache();
2261                
2262                // Zusätzlich explizit alle Cache-Keys löschen
2263                const user = auth.currentUser;
2264                if (user) {
2265                    const userDoc = await _phase8UserSnap(user.uid); /* phase8 */
2266                    const userData = userDoc.data();
2267                    const tenantId = userData.tenantID || userData.tenant_id;
2268                    if (tenantId) {
2269                        // Lösche alle möglichen Cache-Keys
2270                        localStorage.removeItem(`contacts_${user.uid}_${tenantId}_desktop`);
2271                        localStorage.removeItem(`contacts_${user.uid}_${tenantId}_mobile`);
2272                        localStorage.removeItem(`contacts_${user.uid}_${tenantId}`);
2273                        // Lösche auch sessionStorage
2274                        sessionStorage.removeItem('contactsListDOM');
2275                        sessionStorage.removeItem('contactsListState');
2276                        sessionStorage.removeItem('contactsData');
2277                    }
2278                }
2279                
2280                // Markiere dass Cache gelöscht werden muss
2281                localStorage.setItem('contacts_force_refresh', 'true');
2282                
2283// console.log('Contacts cache cleared successfully after update.');
2284            } catch (cacheError) {
2285                console.error('Error clearing contacts cache:', cacheError);
2286            }
2287            
2288            // Reload the contact data to reflect changes immediately
2289            await this.loadContact();
2290
2291            // FIX: kompletter Re-Render inkl. bindEvents() und aller Sub-Renderer.
2292            // Vorher wurde nur renderContact() aufgerufen -> Akte verschwand,
2293            // Karten hingen auf "Lade...", Buttons ohne Listener.
2294            await this.renderAll(document.getElementById('contactDetailContainer'));
2295
2296        } catch (error) {
2297            console.error('Fehler beim Aktualisieren der Kontaktdaten:', error);
2298            UI.showError(t('Fehler beim Speichern') + ': ' + error.message);
2299        }
2300    }
2301
2302    showTranscriptModal(transcriptText, name = 'Unbekannt', phoneNumber = '') {
2303        try {
2304            const modalElement = document.getElementById('transcriptModal');
2305            if (!modalElement) {
2306                console.error('Transcript modal element #transcriptModal not found in the DOM.');
2307                UI.showError('Fehler: Modal-Container für Transkript nicht gefunden.');
2308                return;
2309            }
2310
2311            const modalTitle = modalElement.querySelector('.modal-title');
2312            const modalBody = modalElement.querySelector('.modal-body');
2313            // Optional: Add more details like name/number to the title or body
2314            // const modalFooterInfo = modalElement.querySelector('#transcriptModalFooterInfo'); // Example if you add a footer span
2315
2316            if (!modalTitle || !modalBody) {
2317                console.error('Modal title or body element not found within #transcriptMod
2317al.');
2318                UI.showError('Fehler: Modal-Struktur ist unvollständig.');
2319                return;
2320            }
2321
2322            // Populate the modal
2323            modalTitle.textContent = `Anruf Transkript ${name ? `von ${name}` : ''}`;
2324            // Use <pre> for better formatting of potentially multi-line transcripts
2325            modalBody.innerHTML = `<pre style="white-space: pre-wrap; word-wrap: break-word;">${transcriptText}</pre>`;
2326            // if (modalFooterInfo) {
2327            //     modalFooterInfo.textContent = `Kontakt: ${name} (${phoneNumber})`;
2328            // }
2329
2330
2331            // Initialize and show the Bootstrap modal
2332            const transcriptModal = new bootstrap.Modal(modalElement);
2333            transcriptModal.show();
2334
2335        } catch (error) {
2336            console.error('Error showing transcript modal:', error);
2337            UI.showError('Fehler beim Anzeigen des Transkripts.');
2338        }
2339    }
2340
2341    async deleteCallSummary(summaryId) {
2342        if (!confirm('Möchten Sie diese Zusammenfassung wirklich löschen?')) return;
2343
2344        try {
2345            await callSummaryApi.delete(summaryId);
2346
2347            UI.showSuccess('Zusammenfassung erfolgreich gelöscht.');
2348            await this.renderCallSummary();
2349        } catch (error) {
2350            console.error('Fehler beim Löschen der Zusammenfassung:', error);
2351            UI.showError('Fehler beim Löschen der Zusammenfassung.');
2352        }
2353    }
2354
2355    /**
2356     * Navigiere zurück zur Kontaktliste und stelle den vorherigen Zustand wieder her
2357     */
2358    async navigateBackToContacts() {
2359        try {
2360            // Mobile-Erkennung
2361            const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
2362
2363            if (isMobile) {
2364                // Auf Mobile: Lade die Kontaktseite und initialisiere Mobile-Kontakte
2365                await Pages.loadPage('contacts');
2366
2367                // Warte kurz, damit der Container erstellt wird
2368                setTimeout(() => {
2369                    if (window.contactsMobile && window.contactsMobile.init) {
2370                        // Mobile-Kontakte sind bereits geladen, nur rendern
2371                        if (window.contactsMobile.allContacts && window.contactsMobile.allContacts.length > 0) {
2372                            window.contactsMobile.render();
2373                        } else {
2374                            // Falls noch nicht geladen, initialisieren
2375                            window.contactsMobile.init();
2376                        }
2377                    }
2378                }, 100);
2379            } else {
2380                // Desktop: Normale Navigation
2381                await Pages.loadPage('contacts');
2382
2383                // DEAKTIVIERT: restoreContactsState nicht mehr nötig, da contacts.js DOM-Cache hat
2384                // Die contacts.js verwaltet selbst den DOM-Cache beim Zurückkehren
2385            }
2386
2387        } catch (error) {
2388            console.error('Fehler beim Zurücknavigieren:', error);
2389            UI.showError('Fehler beim Zurücknavigieren zur Kontaktliste');
2390        }
2391    }
2392
2393    /**
2394     * Stelle den gespeicherten Zustand der Kontaktliste wieder her
2395     */
2396    async restoreContactsState() {
2397        const savedState = this.getStoredContactsState();
2398        if (!savedState) {
2399// console.log('Kein gespeicherter Kontaktlisten-Zustand gefunden - zeige alle Kontakte');
2400            // Falls kein State gespeichert ist, zeige alle Kontakte
2401            const { contacts } = await import('./contacts.js?v=20260710-bookdup-soap1-p5f1-rrev1-bill1-intake1-tokfix1-dat1-mod1b-nam1-merk1--kal1-kont1--kd1-prax1-nsl1-nsl2-impauth1-tdup2-dnd1-kt3');
2402            if (contacts) {
2403                await contacts.loadContacts();
2404                contacts.setupContactEventListeners();
2405                contacts.updateSmartListsUI();
2406            }
2407            return;
2408        }
2409
2410// console.log('[restoreContactsState] Stelle Kontaktlisten-Zustand wieder her:', savedState);
2411
2412        // Verwende die globale Contacts-Instanz
2413        const { contacts } = await import('./contacts.js?v=20260710-bookdup-soap1-p5f1-rrev1-bill1-intake1-tokfix1-dat1-mod1b-nam1-merk1--kal1-kont1--kd1-prax1-nsl1-nsl2-impauth1-tdup2-dnd1-kt3');
2414        
2415        // Stelle sicher, dass die Contacts-Instanz verfügbar ist
2416        if (!contacts) {
2417            console.error('Globale Contacts-Instanz nicht verfügbar');
2418            return;
2419        }
2420        
2421        // Warte kurz, bis alle DOM-Elemente verfügbar sind
2422        await new Promise(resolve => setTimeout(resolve, 200));
2423
2424        // Stelle Suchfeld wieder her
2425        if (savedState.searchTerm) {
2426            const searchInput = document.querySelector('#searchInput');
2427            if (searchInput) {
2428                searchInput.value = savedState.searchTerm;
2429            }
2430        }
2431
2432        // Stelle Filter wieder her
2433        if (savedState.leadStatus) {
2434            const leadStatusFilter = document.querySelector('#leadStatusFilter');
2435            if (leadStatusFilter) {
2436                leadStatusFilter.value = savedState.leadStatus;
2437            }
2438        }
2439
2440        if (savedState.task) {
2441            const taskFilter = document.querySelector('#aufgabeFilter');
2442            if (taskFilter) {
2443                taskFilter.value = savedState.task;
2444            }
2445        }
2446
2447        if (savedState.branche) {
2448            const brancheFilter = document.querySelector('#brancheFilter');
2449            if (brancheFilter) {
2450                brancheFilter.value = savedState.branche;
2451            }
2452        }
2453
2454        // Stelle Datumsfilter wieder her
2455        if (savedState.dateFrom) {
2456            const dateFromFilter = document.querySelector('#dateFromFilter');
2457            if (dateFromFilter) {
2458                dateFromFilter.value = savedState.dateFrom;
2459            }
2460        }
2461
2462        if (savedState.dateTo) {
2463            const dateToFilter = document.querySelector('#dateToFilter');
2464            if (dateToFilter) {
2465                dateToFilter.value = savedState.dateTo;
2466            }
2467        }
2468
2469        // Setze die Seitenzahl zurück
2470        if (savedState.currentPage) {
2471            contacts.currentPage = savedState.currentPage;
2472        }
2473
2474        // Wenn eine Smart List aktiv war, stelle sie wieder her
2475        if (savedState.activeSmartListId) {
2476// console.log('Stelle Smart List wieder her:', savedState.activeSmartListId);
2477            localStorage.setItem('activeSmartListId', savedState.activeSmartListId);
2478            contacts.activeSmartListId = savedState.activeSmartListId;
2479            
2480            // Wende die Smart List an
2481            try {
2482                await contacts.applySmartList(savedState.activeSmartListId);
2483                // Stelle die Seitenzahl nach der Smart List Anwendung nochmals sicher
2484                if (savedState.currentPage && savedState.currentPage !== contacts.currentPage) {
2485                    contacts.currentPage = savedState.currentPage;
2486                    await contacts.renderContacts(contacts.currentFilteredContacts);
2487                    contacts.setupContactEventListeners();
2488                    contacts.updateSmartListsUI();
2489                }
2490            } catch (error) {
2491                console.error('Fehler beim Wiederherstellen der Smart List:', error);
2492                // Fallback: Wende normale Filter an
2493                await contacts.applyFilters();
2494                // Stelle die Seitenzahl nach dem Fallback nochmals sicher
2495                if (savedState.currentPage && savedState.currentPage !== contacts.currentPage) {
2496                    contacts.currentPage = savedState.currentPage;
2497                    await contacts.renderContacts(contacts.currentFilteredContacts);
2498                    contacts.setupContactEventListeners();
2499                }
2500            }
2501        } else if (savedState.hasFilters) {
2502            // Wenn Filter aber keine Smart List aktiv waren, wende die Filter an
2503// console.log('Wende gespeicherte Filter an');
2504            await contacts.applyFilters();
2505            // Stelle die Seitenzahl nach der Filter-Anwendung nochmals sicher
2506            if (savedState.currentPage && savedState.currentPage !== contacts.currentPage) {
2507                contacts.currentPage = savedState.currentPage;
2508                await contacts.renderContacts(contacts.currentFilteredContacts);
2509                contacts.setupContactEventListeners();
2510            }
2511        } else {
2512            // Keine Filter, lade alle Kontakte und navigiere zur korrekten Seite
2513            await contacts.loadContacts();
2514            if (savedState.currentPage && savedState.currentPage !== contacts.currentPage) {
2515                contacts.currentPage = savedState.currentPage;
2516                await contacts.renderContacts(contacts.contacts);
2517                contacts.setupContactEventListeners();
2518            }
2519        }
2520
2521        // Debug: Überprüfe den finalen Zustand
2522// console.log('=== Finale Zustandsüberprüfung ===');
2523// console.log('Aktuelle Seite:', contacts.currentPage);
2524// console.log('Erwartete Seite:', savedState.currentPage);
2525// console.log('Aktive Smart List:', contacts.activeSmartListId);
2526// console.log('Gefilterte Kontakte:', contacts.currentFilteredContacts?.length || 0);
2527// console.log('Alle Kontakte:', contacts.contacts?.length || 0);
2528
2529        // Cleanup: Entferne den gespeicherten Zustand nach erfolgreichem Wiederherstellen
2530        sessionStorage.removeItem('contactsListState');
2531        
2532// console.log('[restoreContactsState] Kontaktlisten-Zustand erfolgreich wiederhergestellt');
2533    }
2534
2535    /**
2536     * Hole den gespeicherten Zustand der Kontaktliste
2537     */
2538    getStoredContactsState() {
2539        try {
2540            const storedState = sessionStorage.getItem('contactsListState');
2541            return storedState ? JSON.parse(storedState) : null;
2542        } catch (error) {
2543            console.error('Fehler beim Lesen des gespeicherten Kontaktlisten-Zustands:', error);
2544            return null;
2545        }
2546    }
2547
2548    /**
2549     * Öffne Email-Compose Modal
2550     */
2551    static async openEmailCompose(contactId) {
2552        try {
2553            // Lade Kontakt-Details
2554            const contact = await this.loadContact(contactId);
2555            if (!contact) {
2556                UI.showError('Kontakt konnte nicht geladen werden');
2557                return;
2558            }
2559
2560            // Importiere EmailIntegration
2561            const { default: EmailIntegration } = await import('./emailIntegration.js');
2562
2563            // Erstelle Modal
2564            const modalHtml = `
2565                <div class="modal fade" id="emailComposeModal" tabindex="-1">
2566                    <div class="modal-dialog modal-lg">
2567                        <div class="modal-content">
2568                            <div class="modal-header">
2569                                <h5 class="modal-title">E-Mail an ${contact.name || 'Kontakt'}</h5>
2570                                <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
2571                            </div>
2572                            <div class="modal-body">
2573                                <form id="emailComposeForm">
2574                                    <div class="mb-3">
2575                                        <label for="emailTo" class="form-label">An</label>
2576                                        <input type="email" class="form-control" id="emailTo" value="${contact.email}" readonly>
2577                                    </div>
2578                                    <div class="mb-3">
2579                                        <label for="emailSubject" class="form-label">${t('Betreff')}</label>
2580                                        <div class="input-group">
2581                                            <input type="text" class="form-control" id="emailSubject" required>
2582                                            <button type="button" class="btn btn-outline-secondary" id="emailAiButton" title="Mit KI erstellen">
2583                                                <i class="fas fa-microphone"></i>
2584                                            </button>
2585                                        </div>
2586                                    </div>
2587                                    <div class="mb-3">
2588                                        <label for="emailContent" class="form-label">${t('Nachricht')}</label>
2589                                        <textarea class="form-control" id="emailContent" rows="10" required></textarea>
2590                                        <div class="ai-prompt-container mt-2" id="aiPromptContainer" style="display: none;">
2591                                            <div class="input-group">
2592                                                <input type="text" class="form-control" id="aiPromptInput" placeholder="${t('Beschreiben Sie, was Sie schreiben möchten...')}">
2593                                                <button type="button" class="btn btn-primary" id="generateEmailBtn">
2594                                                    <i class="fas fa-magic"></i> ${t('Generieren')}
2595                                                </button>
2596                                                <button type="button" class="btn btn-secondary" id="cancelAiBtn">
2597                                                    <i class="fas fa-times"></i>
2598                                                </button>
2599                                            </div>
2600                                        </div>
2601                                    </div>
2602                                    <div class="mb-3">
2603                                        <div class="form-check">
2604                                            <input class="form-check-input" type="checkbox" id="includeSignature" checked>
2605                                            <label class="form-check-label" for="includeSignature">
2606                                                Signatur anhängen
2607                                            </label>
2608                                        </div>
2609                                    </div>
2610                                    <div id="emailPreview" class="border p-3" style="display:none; background-color: #f8f9fa;">
2611                                        <!-- Email-Vorschau wird hier angezeigt -->
2612                                    </div>
2613                                </form>
2614                            </div>
2615                            <div class="modal-footer">
2616                                <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">${t('Abbrechen')}</button>
2617                                <button type="button" class="btn btn-outline-primary" id="previewEmailBtn">
2618                                    <i class="fas fa-eye"></i> Vorschau
2619                                </button>
2620                                <button type="button" class="btn btn-primary" id="sendEmailBtn" data-contact-id="${contactId}">
2621                                    <i class="fas fa-paper-plane"></i> Senden
2622                                </button>
2623                            </div>
2624                        </div>
2625                    </div>
2626                </div>
2627            `;
2628
2629            // Füge Modal zum DOM hinzu
2630            const existingModal = document.getElementById('emailComposeModal');
2631            if (existingModal) {
2632                existingModal.remove();
2633            }
2634            document.body.insertAdjacentHTML('beforeend', modalHtml);
2635
2636            // Initialisiere TinyMCE für Email-Content
2637            setTimeout(async () => {
2638                if (window.loadLib) await window.loadLib('tinymce');
2639                if (window.tinymce) {
2640                    await window.tinymce.init({
2641                        selector: '#emailContent',
2642                        height: 300,
2643                        menubar: false,
2644                        plugins: 'link lists',
2645                        toolbar: 'undo redo | formatselect | bold italic | alignleft aligncenter alignright | bullist numlist | link',
2646                        language: 'de'
2647                    });
2648                }
2649            }, 100);
2650            
2651            // AI Button Event Listener
2652            const aiButton = document.getElementById('emailAiButton');
2653            const aiPromptContainer = document.getElementById('aiPromptContainer');
2654            const aiPromptInput = document.getElementById('aiPromptInput');
2655            const generateBtn = document.getElementById('generateEmailBtn');
2656            const cancelBtn = document.getElementById('cancelAiBtn');
2657            
2658            if (aiButton) {
2659                aiButton.addEventListener('click', () => {
2660                    // Starte Spracherkennung
2661                    if ('webkitSpeechRecognition' in window || 'SpeechRecognition' in window) {
2662                        this.startSpeechRecognition(contactId, contact);
2663                    } else {
2664                        // Fallback: Zeige Text-Eingabe
2665                        aiPromptContainer.style.display = 'block';
2666                        aiPromptInput.focus();
2667                    }
2668                });
2669            }
2670            
2671            if (cancelBtn) {
2672                cancelBtn.addEventListener('click', () => {
2673                    aiPromptContainer.style.display = 'none';
2674                    aiPromptInput.value = '';
2675                });
2676            }
2677            
2678            if (generateBtn) {
2679                generateBtn.addEventListener('click', async () => {
2680                    await this.generateEmailWithAI(contactId, contact);
2681                });
2682            }
2683            
2684            if (aiPromptInput) {
2685                aiPromptInput.addEventListener('keypress', async (e) => {
2686                    if (e.key === 'Enter') {
2687                        await this.generateEmailWithAI(contactId, contact);
2688                    }
2689                });
2690            }
2691
2692            // Zeige Modal
2693            const modal = new bootstrap.Modal(document.getElementById('emailComposeModal'));
2694            modal.show();
2695            
2696            // Event-Listener für Modal-Buttons
2697            const previewBtn = document.getElementById('previewEmailBtn');
2698            if (previewBtn) {
2699                previewBtn.addEventListener('click', () => {
2700                    ContactDetail.previewEmail();
2701                });
2702            }
2703            
2704            const sendBtn = document.getElementById('sendEmailBtn');
2705            if (sendBtn) {
2706                sendBtn.addEventListener('click', async () => {
2707                    const contactId = sendBtn.getAttribute('data-contact-id');
2708                    await ContactDetail.sendEmail(contactId);
2709                });
2710            }
2711
2712        } catch (error) {
2713            console.error('Fehler beim Öffnen des Email-Compose-Modals:', error);
2714            UI.showError('Fehler beim Öffnen des E-Mail-Fensters');
2715        }
2716    }
2717
2718    /**
2719     * Starte Spracherkennung für Email-Erstellung
2720     */
2721    static startSpeechRecognition(contactId, contact) {
2722        const SpeechRecognition = window.webkitSpeechRecognition || window.SpeechRecognition;
2723        const recognition = new SpeechRecognition();
2724        
2725        recognition.lang = 'de-DE';
2726        recognition.continuous = true;  // Erlaube kontinuierliche Erkennung
2727        recognition.interimResults = false;
2728        recognition.maxAlternatives = 1;
2729        
2730        const aiButton = document.getElementById('emailAiButton');
2731        const originalHTML = aiButton.innerHTML;
2732        
2733        // Zeige visuelles Feedback
2734        aiButton.innerHTML = '<i class="fas fa-microphone-slash text-danger"></i>';
2735        aiButton.classList.add('btn-danger');
2736        aiButton.classList.remove('btn-outline-secondary');
2737        
2738        recognition.start();
2739        
2740        // Timeout für automatisches Stoppen nach 30 Sekunden
2741        let recognitionTimeout;
2742        const startTimeout = () => {
2743            recognitionTimeout = setTimeout(() => {
2744                recognition.stop();
2745            }, 30000); // 30 Sekunden
2746        };
2747        
2748        startTimeout();
2749        
2750        recognition.onresult = async (event) => {
2751            clearTimeout(recognitionTimeout);
2752            
2753            // Sammle alle Ergebnisse
2754            let fullTranscript = '';
2755            for (let i = event.resultIndex; i < event.results.length; i++) {
2756                if (event.results[i].isFinal) {
2757                    fullTranscript += event.results[i][0].transcript + ' ';
2758                }
2759            }
2760            
2761// console.log('Spracherkennung:', fullTranscript);
2762            
2763            // Setze den Text in das Input-Feld
2764            const aiPromptInput = document.getElementById('aiPromptInput');
2765            const aiPromptContainer = document.getElementById('aiPromptContainer');
2766            
2767            aiPromptContainer.style.display = 'block';
2768            aiPromptInput.value = fullTranscript.trim();
2769            
2770            // Warte 2 Sekunden nach dem letzten Wort, dann stoppe und generiere
2771            clearTimeout(recognitionTimeout);
2772            setTimeout(() => {
2773                recognition.stop();
2774            }, 2000);
2775        };
2776        
2777        // Generiere Email wenn Erkennung beendet ist
2778        recognition.onend = async () => {
2779            clearTimeout(recognitionTimeout);
2780            
2781            // Reset Button
2782            aiButton.innerHTML = originalHTML;
2783            aiButton.classList.remove('btn-danger');
2784            aiButton.classList.add('btn-outline-secondary');
2785            
2786            const aiPromptInput = document.getElementById('aiPromptInput');
2787            if (aiPromptInput.value.trim()) {
2788                // Generiere Email automatisch wenn Text vorhanden
2789                await this.generateEmailWithAI(contactId, contact);
2790            }
2791        };
2792        
2793        recognition.onerror = (event) => {
2794            console.error('Spracherkennungsfehler:', event.error);
2795            UI.showError('Spracherkennung fehlgeschlagen: ' + event.error);
2796            
2797            // Reset Button
2798            aiButton.innerHTML = originalHTML;
2799            aiButton.classList.remove('btn-danger');
2800            aiButton.classList.add('btn-outline-secondary');
2801            
2802            // Zeige Text-Eingabe als Fallback
2803            const aiPromptContainer = document.getElementById('aiPromptContainer');
2804            aiPromptContainer.style.display = 'block';
2805            document.getElementById('aiPromptInput').focus();
2806        };
2807    }
2808    
2809    /**
2810     * Generiere Email mit AI
2811     */
2812    static async generateEmailWithAI(contactId, contact) {
2813        const promptInput = document.getElementById('aiPromptInput');
2814        const prompt = promptInput.value.trim();
2815        
2816        if (!prompt) {
2817            UI.showError('Bitte beschreiben Sie, was Sie schreiben möchten');
2818            return;
2819        }
2820        
2821        const generateBtn = document.getElementById('generateEmailBtn');
2822        const originalText = generateBtn.innerHTML;
2823        generateBtn.disabled = true;
2824        generateBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Generiere...';
2825        
2826        try {
2827            // Erstelle einen detaillierten Prompt für ChatGPT
2828            const fullPrompt = `Schreibe eine professionelle Email an ${contact.name || 'den Empfänger'} (${contact.email}).
2829Firmenkontext: ${contact.company || 'Keine Firma angegeben'}
2830Anweisung des Nutzers: ${prompt}
2831
2832Wichtig: 
2833- Verwende "${contact.name || 'Sehr geehrte Damen und Herren'}" als Anrede in der Email
2834- Die Email sollte höflich und professionell formuliert sein
2835- Verwende HTML-Formatierung für die Email (z.B. <p> für Absätze)
2836- KEINE Grußformel am Ende (kein "Mit freundlichen Grüßen" etc.), da die
2836se automatisch durch die Signatur hinzugefügt wird
2837
2838Bitte erstelle:
28391. Einen passenden Betreff
28402. Den Email-Inhalt mit korrekter Anrede (aber OHNE Grußformel am Ende)
2841
2842Antworte im JSON-Format:
2843{
2844  "subject": "Der Betreff",
2845  "content": "Der Email-Inhalt mit HTML-Formatierung"
2846}`;
2847
2848            // Rufe ChatGPT API auf
2849            const idToken = await auth.currentUser.getIdToken();
2850            const response = await fetch('/api/openai/complete', {
2851                method: 'POST',
2852                headers: {
2853                    'Content-Type': 'application/json',
2854                    'Authorization': `Bearer ${idToken}`
2855                },
2856                body: JSON.stringify({
2857                    messages: [{
2858                        role: 'system',
2859                        content: 'Du bist ein professioneller Email-Assistent. Antworte immer im angegebenen JSON-Format.'
2860                    }, {
2861                        role: 'user',
2862                        content: fullPrompt
2863                    }],
2864                    temperature: 0.7,
2865                    max_tokens: 1000
2866                })
2867            });
2868            
2869            if (!response.ok) {
2870                throw new Error('Fehler beim Generieren der Email');
2871            }
2872            
2873            const data = await response.json();
2874            const aiResponse = JSON.parse(data.choices[0].message.content);
2875            
2876            // Setze Betreff und Inhalt
2877            document.getElementById('emailSubject').value = aiResponse.subject;
2878            
2879            // Setze Content in TinyMCE oder Textarea
2880            if (window.tinymce && window.tinymce.get('emailContent')) {
2881                window.tinymce.get('emailContent').setContent(aiResponse.content);
2882            } else {
2883                document.getElementById('emailContent').value = aiResponse.content;
2884            }
2885            
2886            // Verstecke AI-Prompt Container
2887            document.getElementById('aiPromptContainer').style.display = 'none';
2888            promptInput.value = '';
2889            
2890            UI.showSuccess('Email wurde generiert');
2891            
2892        } catch (error) {
2893            console.error('Fehler beim Generieren der Email:', error);
2894            UI.showError('Fehler beim Generieren der Email');
2895        } finally {
2896            generateBtn.disabled = false;
2897            generateBtn.innerHTML = originalText;
2898        }
2899    }
2900    
2901    /**
2902     * Zeige Email-Vorschau
2903     */
2904    static async previewEmail() {
2905        try {
2906            const content = window.tinymce?.get('emailContent')?.getContent() || document.getElementById('emailContent').value;
2907            const includeSignature = document.getElementById('includeSignature').checked;
2908            
2909            let finalContent = content;
2910            
2911            if (includeSignature) {
2912                const { default: EmailIntegration } = await import('./emailIntegration.js');
2913                finalContent = await EmailIntegration.getEmailWithSignature(content);
2914            }
2915            
2916            const preview = document.getElementById('emailPreview');
2917            if (preview) {
2918                preview.innerHTML = finalContent;
2919                preview.style.display = 'block';
2920            }
2921        } catch (error) {
2922            console.error('Fehler bei der Email-Vorschau:', error);
2923            UI.showError('Fehler bei der Vorschau');
2924        }
2925    }
2926
2927    /**
2928     * Sende Email
2929     */
2930    static async sendEmail(contactId) {
2931        try {
2932            const subject = document.getElementById('emailSubject').value;
2933            const content = window.tinymce?.get('emailContent')?.getContent() || document.getElementById('emailContent').value;
2934            const includeSignature = document.getElementById('includeSignature').checked;
2935            
2936            if (!subject || !content) {
2937                UI.showError('Bitte füllen Sie alle Felder aus');
2938                return;
2939            }
2940            
2941            // Hole finale Email mit Signatur
2942            let finalContent = content;
2943            if (includeSignature) {
2944                const { default: EmailIntegration } = await import('./emailIntegration.js');
2945                finalContent = await EmailIntegration.getEmailWithSignature(content);
2946            }
2947            
2948            const contact = await this.loadContact(contactId);
2949            
2950            // Sende Email
2951            const idToken = await auth.currentUser.getIdToken();
2952            const response = await fetch('/api/send-email', {
2953                method: 'POST',
2954                headers: {
2955                    'Content-Type': 'application/json',
2956                    'Authorization': `Bearer ${idToken}`
2957                },
2958                body: JSON.stringify({
2959                    to: contact.email,
2960                    templateName: 'custom', // Für custom emails
2961                    userId: auth.currentUser.uid, // userId auf Top-Level für Server
2962                    data: {
2963                        subject: subject,
2964                        html: finalContent,
2965                        contactId: contactId,
2966                        contactName: contact.name || 'Kontakt',
2967                        recipientId: contactId, // Für Email-Historie
2968                        tenant_id: contact.tenant_id || auth.currentUser.uid,
2969                        userId: auth.currentUser.uid // Für Email-Historie wenn g.current_user nicht verfügbar
2970                    }
2971                })
2972            });
2973            
2974            if (!response.ok) {
2975                throw new Error('Fehler beim Senden der E-Mail');
2976            }
2977            
2978            UI.showSuccess('E-Mail wurde erfolgreich gesendet');
2979            
2980            // Schließe Modal
2981            const modal = bootstrap.Modal.getInstance(document.getElementById('emailComposeModal'));
2982            modal.hide();
2983            
2984            // Lade Email-Historie neu
2985            if (window.contactDetailInstance) {
2986                await window.contactDetailInstance.renderEmails();
2987            }
2988            
2989        } catch (error) {
2990            console.error('Fehler beim Senden der E-Mail:', error);
2991            UI.showError('Fehler beim Senden der E-Mail');
2992        }
2993    }
2994
2995    /**
2996     * Lade Kontakt-Details statisch
2997     */
2998
2999    // ===== Patientenakte (mediOne Praxis, nur Physio-Edition) =====
3000
3001    _recEscape(s) {
3002        return String(s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;')
3003            .replace(/>/g, '&gt;').replace(/"/g, '&quot;');
3004    }
3005
3006    _recTypeLabel(type) {
3007        const labels = { bodychart: 'Bodychart', befund: 'Befund', behandlung: 'Behandlung', diagnose: 'Diagnose',
3008                         verlauf: 'Verlauf', dokument: 'Dokument', notiz: 'Notiz' };
3009        return labels[type] || type;
3010    }
3011
3012    async _recFetch(url, options = {}) {
3013        const idToken = await auth.currentUser.getIdToken();
3014        return fetch(url, {
3015            ...options,
3016            headers: {
3017                'Content-Type': 'application/json',
3018                'Authorization': `Bearer ${idToken}`,
3019                ...(options.headers || {})
3020            }
3021        });
3022    }
3023
3024    async renderPatientRecords() {
3025        // Nur in der Praxis-Ansicht anzeigen (mediOne-Domain ODER Mandant mit
3026        // Betriebsart 'praxis' - praxis-modus-v1, 13.08.2026).
3027        if (!this.isPhysio()) return;
3028        const placeholder = document.getElementById('patientRecordsCardPlaceholder');
3029        if (!placeholder || !this.contactId) return;
3030
3031        placeholder.innerHTML = `
3032            <div class="card shadow-sm mb-4">
3033                <div class="card-body">
3034                    <h5 class="card-title d-flex justify-content-between align-items-center mb-3">
3035                        <span><i class="fas fa-file-medical me-2"></i>${t('Patientenakte')}</span>
3036                        <span class="d-flex gap-2">
3037                            <button class="btn btn-sm btn-outline-danger" id="recBodychartBtn" title="${t('Koerperskizze / Bodychart')}">
3038                                <i class="fas fa-user me-1"></i>${t('Koerperskizze')}
3039                            </button>
3040                            <button class="btn btn-sm btn-primary" id="recAddBtn">
3041                                <i class="fas fa-plus me-1"></i>${t('Eintrag')}
3042                            </button>
3043                        </span>
3044                    </h5>
3045                    <div class="form-check form-switch mb-3">
3046                        <input class="form-check-input" type="checkbox" id="recRecallOptIn" ${this.contact && this.contact.physioRecallOptIn ? 'checked' : ''}>
3047                        <label class="form-check-label small text-muted" for="recRecallOptIn">${t('KI-Recall-Anrufe erlaubt (Einwilligung des Patienten liegt vor)')}</label>
3048                    </div>
3049                    <div id="recAddForm" class="border rounded p-3 mb-3" style="display:none;">
3050                        <div class="row g-2 mb-2">
3051                            <div class="col-md-4">
3052                                <select class="form-select form-select-sm" id="recType">
3053                                    <option value="behandlung">${t('Behandlung')}</option>
3054                                    <option value="befund">${t('Befund')}</option>
3055                                    <option value="bodychart">${t('Bodychart')}</option>
3056                                    <option value="diagnose">${t('Diagnose')}</option>
3057                                    <option value="verlauf">${t('Verlauf')}</option>
3058                                    <option value="notiz">${t('Notiz')}</option>
3059                                </select>
3060                            </div>
3061                            <div class="col-md-8">
3062                                <input type="text" class="form-control form-control-sm" id="recTitle"
3063                                       placeholder="${t('Titel (optional)')}" maxlength="200">
3064                            </div>
3065                        </div>
3066                        <div class="d-flex gap-2 align-items-center mb-2">
3067                            <select class="form-select form-select-sm" id="recTplSelect" style="max-width:280px;">
3068                                <option value="">${t('Vorlage anwenden…')}</option>
3069                            </select>
3070                            <div class="form-check ms-auto mb-0">
3071                                <input class="form-check-input" type="checkbox" id="recPrivat">
3072                                <label class="form-check-label small text-muted" for="recPrivat"><i class="fas fa-lock me-1"></i>${t('Privat (nur fuer mich)')}</label>
3073                            </div>
3074                        </div>
3075                        <div class="mb-2" id="recVerordnungRow" style="display:none;">
3076                            <select class="form-select form-select-sm" id="recVerordnung">
3077                                <option value="">${t('Keine Verordnung verknuepfen')}</option>
3078                            </select>
3079                            <small class="text-muted">${t('Behandlung wird auf die Verordnung angerechnet')}</small>
3080                        </div>
3081                        <div class="border rounded p-2 mb-2 bg-light" id="recBefundRow" style="display:none;">
3082                            <small class="text-muted d-block mb-2"><i class="fas fa-clipboard-list me-1"></i>${t('Strukturierter Befund')}</small>
3083                            <div class="row g-2 mb-2">
3084                                <div class="col-md-3">
3085                                    <label class="form-label mb-0"><small>${t('Schmerz (NRS 0-10)')}</small></label>
3086                                    <select class="form-select form-select-sm" id="befSchmerz">
3087                                        <option value="">–</option>
3088                                        ${[0,1,2,3,4,5,6,7,8,9,10].map(n => `<option value="${n}">${n}</option>`).join('')}
3089                                    </select>
3090                                </div>
3091                                <div class="col-md-9">
3092                                    <label class="form-label mb-0"><small>${t('Lokalisation')}</small></label>
3093                                    <input type="text" class="form-control form-control-sm" id="befLokalisation" placeholder="${t('z.B. LWS, Schulter rechts')}">
3094                                </div>
3095                            </div>
3096                            <div class="row g-2">
3097                                <div class="col-md-6">
3098                                    <label class="form-label mb-0"><small>${t('Bewegungseinschraenkung')}</small></label>
3099                                    <input type="text" class="form-control form-control-sm" id="befBewegung" placeholder="${t('z.B. Flexion endgradig eingeschraenkt')}">
3100                                </div>
3101                                <div class="col-md-6">
3102                                    <label class="form-label mb-0"><small>${t('Therapieziel')}</small></label>
3103                                    <input type="text" class="form-control form-control-sm" id="befZiel" placeholder="${t('z.B. Schmerzfreiheit im Alltag')}">
3104                                </div>
3105                            </div>
3106                        </div>
3107                        <!-- soap-docu-ui-v1: AP 5.2 SOAP, jedes Feld einzeln korrigierbar -->
3108                        <div class="border rounded p-2 mb-2" id="recSoapRow" style="display:none;background:#f8fbff;">
3109                            <div class="d-flex align-items-center mb-2">
3110                                <small class="text-muted"><i class="fas fa-notes-medical me-1"></i>${t('Behandlungsdokumentation (SOAP)')}</small>
3111                                <span class="badge bg-warning text-dark ms-2" id="recSoapKiBadge" style="display:none;">${t('KI-Entwurf')}</span>
3112                                <span class="small text-muted ms-auto" id="recSoapCount"></span>
3113                            </div>
3114                            <div class="row g-2">
3115                                <div class="col-md-6">
3116                                    <label class="form-label mb-0"><small><b>S</b> — ${t('Was der Patient berichtet')}</small></label>
3117                                    <textarea class="form-control form-control-sm soap-feld" id="soapSubjektiv" rows="2" data-soap="subjektiv"></textarea>
3118                                </div>
3119                                <div class="col-md-6">
3120                                    <label class="form-label mb-0"><small><b>O</b> — ${t('Was Sie erhoben und durchgefuehrt haben')}</small></label>
3121                                    <textarea class="form-control form-control-sm soap-feld" id="soapObjektiv" rows="2" data-soap="objektiv"></textarea>
3122                                </div>
3123                                <div class="col-md-6">
3124                                    <label class="form-label mb-0"><small><b>B</b> — ${t('Ihre fachliche Einschaetzung')}</small></label>
3125                                    <textarea class="form-control form-control-sm soap-feld" id="soapBeurteilung" rows="2" data-soap="beurteilung"></textarea>
3126                                </div>
3127                                <div class="col-md-6">
3128                                    <label class="form-label mb-0"><small><b>P</b> — ${t('Naechste Schritte, Hausaufgabe')}</small></label>
3129                                    <textarea class="form-control form-control-sm soap-feld" id="soapPlan" rows="2" data-soap="plan"></textarea>
3130                                </div>
3131                            </div>
3132                            <div class="form-check mt-2" id="recSoapFreigabeWrap" style="display:none;">
3133                                <input class="form-check-input" type="checkbox" id="recSoapFreigabe">
3134                                <label class="form-check-label small" for="recSoapFreigabe">
3135                                    <b>${t('Ich habe den KI-Entwurf geprueft und uebernehme ihn fachlich')}</b>
3136                                </label>
3137                            </div>
3138                        </div>
3139                        <div id="recKontextRow" class="mb-2" style="display:none;"></div>
3140                        <div class="bc-panel mb-2" id="recBodychartRow" style="display:none;">
3141                            <div class="bc-head">
3142                                <span class="bc-title"><i class="fas fa-person me-1"></i>${t('Bodychart')}</span>
3143                                <span class="bc-hint">${t('Werkzeug waehlen, dann auf den Koerper klicken oder zeichnen. Klick auf eine Markierung entfernt sie.')}</span>
3144                            </div>
3145                            <div class="bc-toolbar" id="bcToolbar"></div>
3146                            <div class="bc-views" id="bcViews"></div>
3147                            <div class="bc-foot">
3148                                <span class="bc-count" id="bcCount"></span>
3149                                <span class="bc-actions">
3150                                    <button type="button" class="btn btn-sm btn-outline-secondary" id="bcUndoBtn"><i class="fas fa-rotate-left me-1"></i>${t('Rueckgaengig')}</button>
3151                                    <button type="button" class="btn btn-sm btn-outline-danger" id="bcClearBtn"><i class="fas fa-trash-can me-1"></i>${t('Leeren')}</button>
3152                                </span>
3153                            </div>
3154                        </div>
3155                        <div class="d-flex gap-2 align-items-center mb-2">
3156                            <button class="btn btn-sm btn-outline-danger" id="recDiktatBtn" title="${t('Diktat aufnehmen - KI strukturiert es als Entwurf, Sie pruefen und speichern')}">
3157                                <i class="fas fa-microphone me-1"></i>${t('Diktat')}
3158                            </button>
3159                            <button class="btn btn-sm btn-outline-primary" id="recKiBtn" title="${t('Text unten per KI in Befundfelder strukturieren')}">
3160                                <i class="fas fa-wand-magic-sparkles me-1"></i>${t('KI strukturieren')}
3161                            </button>
3162                            <span class="small text-muted" id="recKiStatus"></span>
3163                        </div>
3164                        <textarea class="form-control form-control-sm mb-2" id="recContent" rows="4"
3165                                  placeholder="${t('Befund / Behandlungsnotiz...')}"></textarea>
3166                        <div class="d-flex gap-2 align-items-center">
3167                            <button class="btn btn-sm btn-success" id="recSaveBtn">${t('Speichern')}</button>
3168                            <button class="btn btn-sm btn-outline-secondary" id="recCancelBtn">${t('Abbrechen')}</button>
3169                            <button class="btn btn-sm btn-outline-primary ms-auto" id="recUploadBtn" title="${t('PDF/Foto, z.B. Verordnungs-Scan (max 15 MB)')}">
3170                                <i class="fas fa-paperclip me-1"></i>${t('Datei anhaengen')}
3171                            </button>
3172                            <input type="file" id="recFileInput" accept=".pdf,.png,.jpg,.jpeg,.webp,.heic" style="display:none;">
3173                        </div>
3174                    </div>
3175                    
3176            <!-- DSGVO_S18: Druck + Einwilligungen -->
3177            <div style="display:flex;gap:0.5rem;flex-wrap:wrap;margin-top:0.75rem">
3178              <button class="btn btn-sm btn-outline-secondary" data-s18="print-verlauf">🖨 Behandlungsverlauf</button>
3179              <button class="btn btn-sm btn-outline-secondary" data-s18="print-termine">🖨 Terminübersicht</button>
3180              <button class="btn btn-sm btn-outline-secondary" data-s18="print-zeit">🖨 Zeitbestätigung</button>
3181              <button class="btn btn-sm btn-outline-primary" data-s18="brief">✨ Patientenbrief (KI)</button>
3182            </div>
3183            <div id="s18Consents" style="margin-top:1rem"></div>
3184            <div id="anamBox" style="margin-top:1rem"></div>  <!-- anamnese-akte-v1 -->
3185                    <ul class="nav nav-tabs mt-3" id="akteTabs" style="font-size:.85rem;">
3186                <li class="nav-item"><button type="button" class="nav-link active py-1" data-aktetab="timeline"><i class="fas fa-stream me-1"></i>${t('Timeline')}</button></li>
3187                <li class="nav-item"><button type="button" class="nav-link py-1" data-aktetab="media"><i class="fas fa-photo-film me-1"></i>${t('Mediathek')}</button></li>
3188                <li class="nav-item"><button type="button" class="nav-link py-1" data-aktetab="termine"><i class="fas fa-calendar-check me-1"></i>${t('Termine')}</button></li>
3189            </ul>
3190            <div id="recList" class="pt-2"><div class="text-center text-muted p-2">${t('Lade Akte...')}</div></div>
3191            <div id="recMedia" class="pt-2" style="display:none;"></div>
3192            <div id="recTermine" class="pt-2" style="display:none;"></div>
3193                </div>
3194            </div>`;
3195
3196        const optInEl = document.getElementById('recRecallOptIn');
3197        if (optInEl) optInEl.addEventListener('change', async () => {
3198            try {
3199                const resp = await this._recFetch(`/api/patients/${this.contactId}/recall-optin`, {
3200                    method: 'PUT', body: JSON.stringify({ optIn: optInEl.checked })
3201                });
3202                if (!resp.ok) throw new Error('save failed');
3203                if (this.contact) this.contact.physioRecallOptIn = optInEl.checked;
3204                UI.showSuccess(optInEl.checked ? t('Recall-Einwilligung gespeichert') : t('Recall-Einwilligung entfernt'));
3205            } catch (e) {
3206                optInEl.checked = !optInEl.checked;
3207                UI.showError(t('Einwilligung konnte nicht gespeichert werden'));
3208            }
3209        });
3210        document.getElementById('recAddBtn').addEventListener('click', () => {
3211            const f = document.getElementById('recAddForm');
3212            f.style.display = f.style.display === 'none' ? 'block' : 'none';
3213        });
3214        // S19T2: Akte-Tabs (Timeline | Mediathek | Termine)
3215        document.getElementById('akteTabs').addEventListener('click', (ev) => {
3216            const btn = ev.target.closest('[data-aktetab]');
3217            if (!btn) return;
3218            document.querySelectorAll('#akteTabs .nav-link').forEach(b => b.classList.toggle('active', b === btn));
3219            const tab = btn.dataset.aktetab;
3220            document.getElementById('recList').style.display = tab === 'timeline' ? 'block' : 'none';
3221            document.getElementById('recMedia').style.display = tab === 'media' ? 'block' : 'none';
3222            document.getElementById('recTermine').style.display = tab === 'termine' ? 'block' : 'none';
3223            if (tab === 'media') this.loadAkteMedia();
3224            if (tab === 'termine') this.loadAkteTermine();
3225        });
3226        const _bcBtn = document.getElementById('recBodychartBtn');
3227        if (_bcBtn) _bcBtn.addEventListener('click', () => {
3228            const f = document.getElementById('recAddForm');
3229            f.style.display = 'block';
3230            const ts = document.getElementById('recType');
3231            if (ts) { ts.value = 'bodychart'; ts.dispatchEvent(new Event('change')); }
3232            f.scrollIntoView({ behavior: 'smooth', block: 'center' });
3233        });
3234        document.getElementById('recCancelBtn').addEventListener('click', () => {
3235            document.getElementById('recAddForm').style.display = 'none';
3236        });
3237        document.getElementById('recSaveBtn').addEventListener('click', () => this.savePatientRecord());
3238        document.getElementById('recType').addEventListener('change', (e) => {
3239            document.getElementById('recVerordnungRow').style.display =
3240                e.target.value === 'behandlung' ? 'block' : 'none';
3241            document.getElementById('recBefundRow').style.display =
3242                e.target.value === 'befund' ? 'block' : 'none';
3243            // soap-docu-ui-v1: SOAP bei allen dokumentationsrelevanten Typen
3244            const soapRow = document.getElementById('recSoapRow');
3245            if (soapRow) soapRow.style.display =
3246                ['befund', 'behandlung', 'verlauf'].includes(e.target.value) ? 'block' : 'none';
3247            const bcRow = document.getElementById('recBodychartRow');
3248            if (bcRow) {
3249                bcRow.style.display = e.target.value === 'bodychart' ? 'block' : 'none';
3250                if (e.target.value === 'bodychart') this._bcInit();
3251            }
3252        });
3253        // Default-Typ ist 'behandlung' -> Verordnungs-Auswahl direkt zeigen
3254        document.getElementById('recVerordnungRow').style.display = 'block';
3255        // soap-docu-ui-v1: Default-Typ ist dokumentationsrelevant -> SOAP zeigen
3256        const _soapRow0 = document.getElementById('recSoapRow');
3257        if (_soapRow0) _soapRow0.style.display = 'block';
3258        // p5fix-v1: Das Formular wird hier FRISCH aufgebaut, die SOAP-Felder sind
3259        // leer. Die gemerkte KI-Herkunft muss deshalb mit weg. Sie ueberlebte
3260        // vorher einen verworfenen Entwurf und haette handgetippten Text als
3261        // 'ki_diktat' in die Akte geschrieben - ein falscher Herkunftsvermerk in
3262        // einem Rechtsdokument, und der Freigabe-Haken waere grundlos verlangt
3263        // worden. (Gleiche Klasse wie _neLastConflicts im Kalender, 05.08.)
3264        this._soapKiQuelle = null;
3265        const _fw0 = document.getElementById('recSoapFreigabeWrap');
3266        if (_fw0) _fw0.style.display = 'none';
3267        const _kb0 = document.getElementById('recSoapKiBadge');
3268        if (_kb0) _kb0.style.display = 'none';
3269        document.querySelectorAll('.soap-feld').forEach(el =>
3270            el.addEventListener('input', () => this._soapCount()));
3271        this._soapCount();
3272        document.getElementById('recUploadBtn').addEventListener('click', () =>
3273            document.getElementById('recFileInput').click());
3274        document.getElementById('recDiktatBtn').addEventListener('click', () => this._toggleDiktat());
3275        document.getElementById('recKiBtn').addEventListener('click', () => this._kiStructureText());
3276        document.getElementById('recFileInput').addEventListener('change', (e) => {
3277            if (e.target.files && e.target.files[0]) this.uploadPatientRecordFile(e.target.files[0]);
3278        });
3279        this._tplLoad();
3280
3281        await Promise.all([this.loadPatientRecords(), this.loadVerordnungOptions()]);
3282    }
3283
3284    async loadVerordnungOptions() {
3285        // Offene Verordnungen des Tenants laden; die des Patienten zuerst.
3286        const sel = document.getElementById('recVerordnung');
3287        if (!sel) return;
3288        try {
3289            const resp = await this._recFetch('/api/verordnungen');
3290            if (!resp.ok) return;
3291            const data = await resp.json();
3292            const all = (data.tickets || []).filter(v =>
3293                v.status !== 'closed' && v.status !== 'resolved');
3294            const mine = [], other = [];
3295            const cname = (this.contact?.name || '').toLowerCase();
3296            all.forEach(v => {
3297                const isMine = (v.customerId && v.customerId === this.contactId) ||
3298                    (cname && (v.customerName || '').toLowerCase() === cname);
3299                (isMine ? mine : other).push(v);
3300            });
3301            const opt = (v) => {
3302                const pd = v.prescriptionData || {};
3303                const used = pd.einheiten_verbraucht || 0;
3304                const total = pd.anzahl_sitzungen || 0;
3305                const label = `${v.ticketNumber || v.id} · ${pd.heilmittel || t('Verordnung')}` +
3306                    (total ? ` (${used}/${total})` : '') +
3307                    (v.customerName && !v.customerName.startsWith('[') ? ` · ${v.customerName}` : '');
3308                return `<option value="${v.id}">${label}</option>`;
3309            };
3310            sel.innerHTML = `<option value="">${t('Keine Verordnung verknuepfen')}</option>` +
3311                mine.map(opt).join('') +
3312                (other.length && mine.length ? `<option disabled>──</option>` : '') +
3313                other.map(opt).join('');
3314            if (mine.length === 1) sel.value = mine[0].id;
3315        } catch (e) {
3316            console.warn('loadVerordnungOptions failed:', e);
3317        }
3318    }
3319
3320    async uploadPatientRecordFile(file) {
3321        try {
3322            if (file.size > 15 * 1024 * 1024) { UI.showError(t('Datei zu gross (max 15 MB)')); return; }
3323            const fd = new FormData();
3324            fd.append('file', file);
3325            const vId = document.getElementById('recVerordnung')?.value || '';
3326            if (vId) fd.append('verordnungId', vId);
3327            const title = (document.getElementById('recTitle')?.value || '').trim();
3328            if (title) fd.append('title', title);
3329            const idToken = await auth.currentUser.getIdToken();
3330            const resp = await fetch(`/api/patients/${this.contactId}/records/upload`, {
3331                method: 'POST',
3332                headers: { 'Authorization': `Bearer ${idToken}` },  // kein Content-Type: Browser setzt multipart-Boundary
3333                body: fd
3334            });
3335            if (!resp.ok) {
3336                const err = await resp.json().catch(() => ({}));
3337                throw new Error(err.error || `HTTP ${resp.status}`);
3338            }
3339            document.getElementById('recFileInput').value = '';
3340            await this.loadPatientRecords();
3341        } catch (e) {
3342            console.error('uploadPatientRecordFile failed:', e);
3343            UI.showError(t('Datei-Upload fehlgeschlagen') + (e.message ? `: ${e.message}` : ''));
3344        }
3345    }
3346
3347    async openPatientRecordFile(recordId) {
3348        try {
3349            const resp = await this._recFetch(`/api/medical-records/${recordId}/file`);
3350            if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
3351            const blob = await resp.blob();
3352            const url = URL.createObjectURL(blob);
3353            window.open(url, '_blank');
3354            setTimeout(() => URL.revokeObjectURL(url), 60000);
3355        } catch (e) {
3356            console.error('openPatientRecordFile failed:', e);
3357            UI.showError(t('Datei konnte nicht geoeffnet werden'));
3358        }
3359    }
3360
3361    // ===== S19 Teil 2: Mediathek-Tab =====
3362    _mediaState = { view: 'grid', q: '', type: '' };
3363
3364    async loadAkteMedia() {
3365        const host = document.getElementById('recMedia');
3366        if (!host || !this.contactId) return;
3367        if (!this._mediaRecords) {
3368            host.innerHTML = `<div class="text-center text-muted p-3">${t('Lade Mediathek...')}</div>`;
3369            try {
3370                const resp = await this._recFetch(`/api/patients/${this.contactId}/records`);
3371                if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
3372                this._mediaRecords = ((await resp.json()).records || []).filter(r => r.hasFile);
3373            } catch (e) {
3374                host.innerHTML = `<div class="text-center text-danger p-3">${t('Mediathek konnte nicht geladen werden')}</div>`;
3375                return;
3376            }
3377        }
3378        this._mediaRenderInto(host);
3379    }
3380
3381    _mediaRenderInto(host) {
3382        const st = this._mediaState;
3383        const types = [...new Set(this._mediaRecords.map(r => r.recordType))];
3384        const q = st.q.toLowerCase();
3385        const items = this._mediaRecords.filter(r =>
3386            (!st.type || r.recordType === st.type) &&
3387            (!q || (r.fileName || '').toLowerCase().includes(q) || (r.title || '').toLowerCase().includes(q)));
3388        const fmtSize = (n) => !n ? '' : n > 1048576 ? (n / 1048576).toFixed(1) + ' MB' : Math.round(n / 1024) + ' KB';
3389        const fmtDate = (x) => x ? new Date(x).toLocaleDateString('de-DE') : '';
3390        const tile = (r) => {
3391            const isImg = (r.fileMime || '').startsWith('image/');
3392            return `
3393            <div class="border rounded media-tile" data-recid="${r.id}" style="width:150px;cursor:pointer;overflow:hidden;">
3394                <div class="d-flex align-items-center justify-content-center bg-light" style="height:100px;">
3395                    ${isImg ? `<img class="media-thumb" data-recid="${r.id}" alt="" style="max-height:100px;max-width:148px;display:none;">
3396                               <i class="fas fa-image text-muted media-thumb-ph" data-recid="${r.id}" style="font-size:1.6rem;"></i>`
3397                            : `<i class="fas ${(r.fileMime || '').includes('pdf') ? 'fa-file-pdf text-danger' : 'fa-file text-secondary'}" style="font-size:1.8rem;"></i>`}
3398                </div>
3399                <div class="p-1">
3400                    <div class="small text-truncate" title="${this._recEscape(r.fileName || '')}">${this._recEscape(r.fileName || t('Datei'))}</div>
3401                    <div class="text-muted" style="font-size:.7rem;">${this._recTypeLabel(r.recordType)} · ${fmtDate(r.createdAt)}${r.fileSize ? ' · ' + fmtSize(r.fileSize) : ''}</div>
3402                </div>
3403            </div>`;
3404        };
3405        const row = (r) => `
3406            <tr class="media-tile" data-recid="${r.id}" style="cursor:pointer;">
3407                <td class="small"><i class="fas ${(r.fileMime || '').startsWith('image/') ? 'fa-image' : 'fa-file-pdf text-danger'} me-2"></i>${this._recEscape(r.fileName || t('Datei'))}</td>
3408                <td class="small">${this._recTypeLabel(r.recordType)}</td>
3409                <td class="small">${this._recEscape(r.title || '')}</td>
3410                <td class="small text-nowrap">${fmtDate(r.createdAt)}</td>
3411                <td class="small text-nowrap">${fmtSize(r.fileSize)}</td>
3412            </tr>`;
3413        host.innerHTML = `
3414            <div class="d-flex flex-wrap gap-2 align-items-center mb-2">
3415                <input type="search" class="form-control form-control-sm" id="mediaSearch" placeholder="${t('Dateien durchsuchen...')}" value="${this._recEscape(st.q)}" style="max-width:230px;">
3416                <select class="form-select form-select-sm" id="mediaType" style="max-width:170px;">
3417                    <option value="">${t('Alle Kategorien')}</option>
3418                    ${types.map(ty => `<option value="${ty}" ${st.type === ty ? 'selected' : ''}>${this._recTypeLabel(ty)}</option>`).join('')}
3419                </select>
3420                <div class="btn-group btn-group-sm ms-auto">
3421                    <button type="button" class="btn btn-outline-secondary ${st.view === 'grid' ? 'active' : ''}" id="mediaGridBtn" title="${t('Kacheln')}"><i class="fas fa-th"></i></button>
3422                    <button type="button" class="btn btn-outline-secondary ${st.view === 'list' ? 'active' : ''}" id="mediaListBtn" title="${t('Liste')}"><i class="fas fa-list"></i></button>
3423                </div>
3424                <span class="small text-muted">${items.length} ${t('Dateien')}</span>
3425            </div>
3426            ${!items.length ? `<div class="text-center text-muted p-3">${t('Keine Dateien in der Akte')}</div>`
3427              : st.view === 'grid'
3428                ? `<div class="d-flex flex-wrap gap-2">${items.map(tile).join('')}</div>`
3429                : `<div class="table-responsive"><table class="table table-sm table-hover mb-0"><thead>
3430                     <tr><th class="small">${t('Datei')}</th><th class="small">${t('Kategorie')}</th><th class="small">${t('Titel')}</th><th class="small">${t('Datum')}</th><th class="small">${t('Groesse')}</th></tr>
3431                   </thead><tbody>${items.map(row).join('')}</tbody></table></div>`}`;
3432        document.getElementById('mediaSearch').addEventListener('input', (e) => { this._mediaState.q = e.target.value; this._mediaRenderInto(host); });
3433        document.getElementById('mediaType').addEventListener('change', (e) => { this._mediaState.type = e.target.value; this._mediaRenderInto(host); });
3434        document.getElementById('mediaGridBtn').addEventListener('click', () => { this._mediaState.view = 'grid'; this._mediaRenderInto(host); });
3435        document.getElementById('mediaListBtn').addEventListener('click', () => { this._mediaState.view = 'list'; this._mediaRenderInto(host); });
3436        host.querySelectorAll('.media-tile').forEach(el =>
3437            el.addEventListener('click', () => this.openPatientRecordFile(el.dataset.recid)));
3438        this._mediaLoadThumbs(host);
3439    }
3440
3441    async _mediaLoadThumbs(host) {
3442        this._mediaThumbCache = this._mediaThumbCache || {};
3443        const imgs = Array.from(host.querySelectorAll('img.media-thumb')).slice(0, 24);
3444        for (const img of imgs) {
3445            const rid = img.dataset.recid;
3446            try {
3447                if (!this._mediaThumbCache[rid]) {
3448                    const resp = await this._recFetch(`/api/medical-records/${rid}/file`);
3449                    if (!resp.ok) continue;
3450                    this._mediaThumbCache[rid] = URL.createObjectURL(await resp.blob());
3451                }
3452                img.src = this._mediaThumbCache[rid];
3453                img.style.display = 'block';
3454                const ph = host.querySelector(`.media-thumb-ph[data-recid="${rid}"]`);
3455                if (ph) ph.style.display = 'none';
3456            } catch (e) { /* Thumbnail optional */ }
3457        }
3458    }
3459
3460    // ===== S19 Teil 2: Termine-Tab (nach Verordnung gruppiert) =====
3461    async loadAkteTermine() {
3462        const host = document.getElementById('recTermine');
3463        if (!host || !this.contactId) return;
3464        host.innerHTML = `<div class="text-center text-muted p-3">${t('Lade Termine...')}</div>`;
3465        try {
3466            const resp = await this._recFetch(`/api/patients/${this.contactId}/termine-nach-verordnung`);
3467            if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
3468            const data = await resp.json();
3469            host.innerHTML = this._termineRender(data);
3470            host.querySelectorAll('.vo-group-head').forEach(h => h.addEventListener('click', () => {
3471                const body = host.querySelector(`.vo-group-body[data-vo="${h.dataset.vo}"]`);
3472                const chev = h.querySelector('.vo-chev');
3473                const open = body.style.display !== 'none';
3474                body.style.display = open ? 'none' : 'block';
3475                if (chev) chev.className = `fas fa-chevron-${open ? 'right' : 'down'} vo-chev me-2 text-muted`;
3476            }));
3477        } catch (e) {
3478            console.error('loadAkteTermine failed:', e);
3479            host.innerHTML = `<div class="text-center text-danger p-3">${t('Termine konnten nicht geladen werden')}</div>`;
3480        }
3481    }
3482
3483    _termineRender(data) {
3484        const groups = data.groups || [], unassigned = data.unassigned || [];
3485        if (!groups.length && !unassigned.length) {
3486            return `<div class="text-center text-muted p-3">${t('Keine Termine vorhanden')}</div>`;
3487        }
3488        const statusBadge = (st) => {
3489            if (st === 'cancelled') return `<span class="badge bg-secondary">${t('Abgesagt')}</span>`;
3490            if (st === 'no-show') return `<span class="badge bg-danger" title="${t('Patient ist nicht erschienen')}">${t('No-Show')}</span>`;
3491            if (st === 'dokumentiert') return `<span class="badge bg-success">${t('Dokumentiert')}</span>`;
3492            return '';
3493        };
3494        const apptRow = (a) => {
3495            const noshow = a.status === 'no-show' || a.status === 'cancelled';
3496            const d = a.date ? new Date(a.date + 'T12:00:00').toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: '2-digit', year: 'numeric' }) : '';
3497            return `<tr>
3498                <td class="small text-nowrap ${noshow ? 'text-decoration-line-through text-muted' : ''}" ${noshow ? `title="${t('Termin fand nicht statt')}"` : ''}>${d}${a.time ? ' · ' + a.time : ''}</td>
3499                <td class="small ${noshow ? 'text-decoration-line-through text-muted' : ''}">${this._recEscape(a.title)}</td>
3500                <td class="small">${this._recEscape(a.mitarbeiter || '')}</td>
3501                <td class="small">${statusBadge(a.status)}</td>
3502            </tr>`;
3503        };
3504        const tbl = (items) => `<div class="table-responsive"><table class="table table-sm mb-0"><thead>
3505            <tr><th class="small">${t('Datum')}</th><th class="small">${t('Behandlung')}</th><th class="small">${t('Mitarbeiter')}</th><th class="small"></th></tr>
3506            </thead><tbody>${items.map(apptRow).join('')}</tbody></table></div>`;
3507        const voHead = (g) => {
3508            const total = g.einheitenVerordnet, used = g.einheitenVerbraucht || 0;
3509            const done = g.status === 'closed' || g.status === 'resolved' || (total && used >= total);
3510            return `${t('Verordnung')}${g.ausstellungsdatum ? ' ' + t('vom') + ' ' + new Date(g.ausstellungsdatum + 'T12:00:00').toLocaleDateString('de-DE') : (g.ticketNumber ? ' ' + g.ticketNumber : '')}
3511                — ${this._recEscape(g.heilmittel)} · ${used}${total ? ' / ' + total : ''} ${t('Einheiten')}${done ? ` <span class="text-success">(${t('abgeschlossen')})</span>` : ''}`;
3512        };
3513        return groups.map(g => `
3514            <div class="border rounded mb-2">
3515                <div class="vo-group-head d-flex align-items-center p-2 bg-light" data-vo="${g.verordnungId}" style="cursor:pointer;">
3516                    <i class="fas fa-chevron-down vo-chev me-2 text-muted"></i>
3517                    <span class="small fw-bold">${voHead(g)}</span>
3518                </div>
3519                <div class="vo-group-body" data-vo="${g.verordnungId}">
3520                    ${g.termine.length ? tbl(g.termine) : `<div class="small text-muted p-2">${t('Noch keine Termine auf dieser Verordnung')}</div>`}
3521                </div>
3522            </div>`).join('') + (unassigned.length ? `
3523            <div class="border rounded mb-2">
3524                <div class="vo-group-head d-flex align-items-center p-2 bg-light" data-vo="_none" style="cursor:pointer;">
3525                    <i class="fas fa-chevron-down vo-chev me-2 text-muted"></i>
3526                    <span class="small fw-bold">${t('Ohne Verordnung')} · ${unassigned.length} ${t('Termine')}</span>
3527                </div>
3528                <div class="vo-group-body" data-vo="_none">${tbl(unassigned)}</div>
3529            </div>` : '');
3530    }
3531
3532
3533    // ===== anamnese-ui-v2 (war anamnese-akte-v1): Aufnahmebögen (AP 2.2 + 2.3) =====
3534
3535    _anamEsc(v) {
3536      return String(v == null ? '' : v).replace(/[&<>"']/g,
3537        c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
3538    }
3539
3540    _anamDatum(iso) {
3541      if (!iso) return '';
3542      const d = new Date(iso);
3543      return isNaN(d) ? '' : d.toLocaleString('de-DE',
3544        { day: '2-digit', month: '2-digit', year: 'numeric',
3545          hour: '2-digit', minute: '2-digit' });
3546    }
3547
3548    async _anamLoad(patientId) {
3549      const box = document.getElementById('anamBox');
3550      if (!box || !patientId) return;
3551      try {
3552        // intake-v1: der Aufnahmestand kommt mit - EIN Vorgang statt zweier
3553        // getrennter Strecken (Bogen + Einwilligungen).
3554        const [rB, rT, rI] = await Promise.all([
3555          this._recFetch(`/api/patients/${patientId}/anamnese`),
3556          this._recFetch('/api/anamnese/templates?full=1'),
3557          this._recFetch(`/api/patients/${patientId}/intake`)
3558        ]);
3559        if (!rB.ok) { box.innerHTML = ''; return; }
3560        const d = await rB.json();
3561        const tpls = rT.ok ? ((await rT.json()).templates || []).filter(x => x.active) : [];
3562        this._anamTemplates = tpls;
3563        this._anamFlow = rI.ok ? ((await rI.json()).flow || null) : null;
3564        this._anamRender(patientId, d.responses || [], d.openInvites || 0);
3565      } catch (e) { console.warn('[anamnese-akte-v1] laden', e); }
3566    }
3567
3568    _anamRender(patientId, rows, offen) {
3569      const box = document.getElementById('anamBox');
3570      if (!box) return;
3571      const esc = v => this._anamEsc(v);
3572      const tpls = this._anamTemplates || [];
3573
3574      const zeilen = rows.map(r => {
3575        const badge = r.status === 'uebernommen'
3576          ? '<span class="badge bg-success">' + t('übernommen') + '</span>'
3577          : (r.status === 'eingereicht'
3578             ? '<span class="badge bg-warning text-dark">' + t('neu eingegangen') + '</span>'
3579             : '<span class="badge bg-secondary">' + t('Entwurf') + '</span>');
3580        const knopf = r.status === 'eingereicht'
3581          ? `<button class="btn btn-sm btn-primary" data-anamact="review"
3582                data-id="${esc(r.id)}">${t('Ansehen & übernehmen')}</button>`
3583          : `<button class="btn btn-sm btn-outline-secondary" data-anamact="review"
3584                data-id="${esc(r.id)}">${t('Ansehen')}</button>`;
3585        const drucken = (r.status === 'entwurf') ? '' :
3586          `<button class="btn btn-sm btn-outline-secondary me-1" data-anamact="pdf"
3587              data-id="${esc(r.id)}" title="${t('Drucken')}">
3588             <i class="fas fa-print"></i></button>`;
3589        // anam-alarm-v1: gekennzeichnete Angaben schon in der Liste sichtbar.
3590        const warn = r.alertCount
3591          ? ` <span class="badge bg-danger" title="${t('Angaben, die vor der Behandlung zu beachten sind')}">
3592               <i class="fas fa-triangle-exclamation"></i> ${r.alertCount}</span>` : '';
3593        return `<tr${r.alertCount ? ' class="table-warning"' : ''}>
3594            <td class="small">${esc(r.templateName)}${warn}</td>
3595            <td class="small text-muted">${this._anamDatum(r.submittedAt || r.createdAt)}</td>
3596            <td>${badge}</td>
3597            <td class="text-end">${drucken}${r.status === 'entwurf' ? '' : knopf}</td>
3598          </tr>`;
3599      }).join('');
3600
3601      box.innerHTML = `
3602      <div class="card border-0" style="background:#f8f9fa">
3603        <div class="card-body p-3">
3604          <div class="d-flex justify-content-between align-items-center flex-wrap gap-2 mb-2">
3605            <strong style="font-size:.95rem">
3606              <i class="fas fa-clipboard-list me-2"></i>${t('Aufnahmebögen')}</strong>
3607            <div class="d-flex gap-2 align-items-center flex-wrap">
3608              ${offen ? `<span class="badge bg-info text-dark">${offen} ${t('offene Einladung(en)')}</span>` : ''}
3609              ${tpls.length ? `
3610                <button class="btn btn-sm btn-success" data-anamact="intake">
3611                  <i class="fas fa-user-check me-1"></i>${t('Aufnahme starten')}</button>
3612                <select class="form-select form-select-sm" id="anamTplWahl" style="width:auto">
3613                  ${tpls.map(x => `<option value="${esc(x.id)}">${esc(x.name)}</option>`).join('')}
3614                </select>
3615                <button class="btn btn-sm btn-outline-primary" data-anamact="invite">
3616                  <i class="fas fa-paper-plane me-1"></i>${t('Nur diesen Bogen')}</button>`
3617              : `<a class="btn btn-sm btn-outline-primary" href="?page=anamnese-templates">
3618                   ${t('Bogen anlegen')}</a>`}
3619            </div>
3620          </div>
3621          ${this._anamFlowHtml()}
3622          ${rows.length ? `<div class="table-responsive">
3623            <table class="table table-sm mb-0 align-middle bg-white rounded">
3624              <tbody>${zeilen}</tbody></table></div>`
3625            : `<div class="small text-muted">${t('Noch kein Bogen ausgefüllt.')}</div>`}
3626          <div id="anamLink" class="mt-2"></div>
3627        </div>
3628      </div>`;
3629
3630      box.querySelectorAll('[data-anamact]').forEach(b => {
3631        b.onclick = () => {
3632          const a = b.dataset.anamact;
3633          if (a === 'invite') this._anamInvite(patientId);
3634          else if (a === 'review') this._anamReview(patientId, b.dataset.id);
3635          else if (a === 'intake') this._anamIntake(patientId);
3636          else if (a === 'pdf') this._anamPdf(b.dataset.id);
3637          else if (a === 'intakeSend') this._anamIntake(patientId, b.dataset.weg);
3638        };
3639      });
3640    }
3641
3642    // ---------------------------------------------- intake-v1 Aufnahme
3643    _anamFlowHtml() {
3644      const f = this._anamFlow;
3645      if (!f) return '';
3646      const esc = v => this._anamEsc(v);
3647      const anteil = f.gesamt ? Math.round(f.erledigt / f.gesamt * 100) : 0;
3648      const farbe = f.vollstaendig ? 'success' : (f.erledigt ? 'warning' : 'secondary');
3649      return `<div class="alert alert-${farbe === 'success' ? 'success' : 'light'} border py-2 px-3 mb-2">
3650        <div class="d-flex justify-content-between align-items-center flex-wrap gap-2">
3651          <div class="small">
3652            <strong>${t('Aufnahme läuft')}</strong> —
3653            ${f.erledigt} ${t('von')} ${f.gesamt} ${t('erledigt')}
3654            ${f.einwilligungenOffen.length ? ` · ${f.einwilligungenOffen.length} ${t('Einwilligung(en) offen')}` : ''}
3655            ${f.boegenOffen.length ? ` · ${f.boegenOffen.length} ${t('Bogen offen')}` : ''}
3656          </div>
3657          <div class="d-flex gap-1">
3658            <button class="btn btn-sm btn-outline-secondary" data-anamact="intakeSend"
3659                    data-weg="email"><i class="fas fa-envelope me-1"></i>${t('Erneut senden')}</button>
3660            <a class="btn btn-sm btn-outline-secondary" href="${esc(f.link || '#')}"
3661               target="_blank" rel="noopener"><i class="fas fa-up-right-from-square"></i></a>
3662          </div>
3663        </div>
3664        <div class="progress mt-2" style="height:5px">
3665          <div class="progress-bar bg-${farbe}" style="width:${anteil}%"></div></div>
3666      </div>`;
3667    }
3668
3669    async _anamIntake(patientId, weg) {
3670      const ziel = document.getElementById('anamLink');
3671      if (ziel) ziel.innerHTML = `<div class="small text-muted">${t('Aufnahme wird vorbereitet…')}</div>`;
3672      try {
3673        const r = await this._recFetch(`/api/patients/${patientId}/intake`, {
3674          method: 'POST',
3675          body: JSON.stringify(weg ? { send: weg } : {})
3676        });
3677        const d = await r.json().catch(() => ({}));
3678        if (!r.ok) {
3679          if (ziel) ziel.innerHTML = `<div class="alert alert-warning py-2 px-3 small mb-0">
3680            ${this._anamEsc(d.error || t('Aufnahme konnte nicht gestartet werden.'))}</div>`;
3681          return;
3682        }
3683        const esc = v => this._anamEsc(v);
3684        const versand = weg
3685          ? (d.queued ? `<div class="text-success small mt-1">
3686                <i class="fas fa-check me-1"></i>${t('Nachricht eingereiht.')}</div>`
3687             : `<div class="text-warning small mt-1">
3688                <i class="fas fa-triangle-exclamation me-1"></i>
3689                ${t('Der Versand wurde nicht eingereiht')}${d.queueError ? ' (' + esc(d.queueError) + ')' : ''}.
3690                ${t('Der Link unten funktioniert trotzdem.')}</div>`)
3691          : '';
3692        if (ziel) ziel.innerHTML = `
3693          <div class="alert alert-info py-2 px-3 mb-0">
3694            <div class="small mb-1">
3695              <strong>${t('Aufnahme-Link')}</strong>
3696              ${d.reused ? `<span class="badge bg-light text-dark border ms-1">${t('bestehender Vorgang')}</span>` : ''}
3697              <span class="text-muted ms-1">(${t('gültig bis')} ${this._anamDatum(d.expiresAt)})</span>
3698            </div>
3699            <div class="d-flex gap-2 flex-wrap align-items-center">
3700              <input class="form-control form-control-sm" readonly
3701                     value="${esc(d.link)}" style="flex:1;min-width:220px">
3702              <button class="btn btn-sm btn-outline-secondary" data-anamsend="email">
3703                <i class="fas fa-envelope me-1"></i>${t('Per E-Mail')}</button>
3704              <button class="btn btn-sm btn-outline-secondary" data-anamsend="sms">
3705                <i class="fas fa-comment-sms me-1"></i>${t('Per SMS')}</button>
3706            </div>
3707            <div class="small text-muted mt-1">
3708              ${(d.templateIds || []).length} ${t('Bogen')} ·
3709              ${(d.consentTypes || []).length} ${t('Einwilligung(en)')} —
3710              ${t('EIN Link, EINE Unterschrift.')}
3711            </div>${versand}</div>`;
3712        if (ziel) ziel.querySelectorAll('[data-anamsend]').forEach(b => {
3713          b.onclick = () => this._anamIntake(patientId, b.dataset.anamsend);
3714        });
3715        this._anamLoad(patientId);
3716      } catch (e) {
3717        if (ziel) ziel.innerHTML = `<div class="alert alert-warning py-2 px-3 small mb-0">
3718          ${t('Aufnahme konnte nicht gestartet werden.')}</div>`;
3719      }
3720    }
3721
3722    async _anamPdf(respId) {
3723      try {
3724        const r = await this._recFetch(`/api/anamnese/responses/${respId}/pdf`);
3725        if (!r.ok) {
3726          const d = await r.json().catch(() => ({}));
3727          alert(d.error || t('Der Ausdruck konnte nicht erzeugt werden.'));
3728          return;
3729        }
3730        const u = URL.createObjectURL(await r.blob());
3731        const w = window.open(u, '_blank');
3732        if (!w) { const a = document.createElement('a'); a.href = u; a.download = 'bogen.pdf'; a.click(); }
3733        setTimeout(() => URL.revokeObjectURL(u), 60000);
3734      } catch (e) { alert(t('Der Ausdruck konnte nicht erzeugt werden.')); }
3735    }
3736
3737    async _anamInvite(patientId) {
3738      const wahl = document.getElementById('anamTplWahl');
3739      if (!wahl || !wahl.value) return;
3740      const ziel = document.getElementById('anamLink');
3741      ziel.innerHTML = `<div class="small text-muted">${t('Erzeuge Link…')}</div>`;
3742      try {
3743        const r = await this._recFetch(`/api/patients/${patientId}/anamnese/invite`, {
3744          method: 'POST',
3745          body: JSON.stringify({ templateId: wahl.value })
3746        });
3747        const d = await r.json().catch(() => ({}));
3748        // Kennung merken: der Versand soll GENAU diese Einladung verschicken.
3749        // Sonst zeigt das Feld einen anderen Link als der Patient bekommt.
3750        this._anamInviteId = d.inviteId || null;
3751        if (!r.ok) {
3752          ziel.innerHTML = `<div class="alert alert-danger py-2 px-3 small mb-0">
3753            ${this._anamEsc(d.error || t('Link konnte nicht erzeugt werden.'))}</div>`;
3754          return;
3755        }
3756        ziel.innerHTML = `
3757          <div class="alert alert-light border py-2 px-3 small mb-0">
3758            <div class="fw-semibold mb-1">${t('Link für den Patienten')}
3759              <span class="text-muted fw-normal">(${t('gültig bis')}
3760              ${this._anamDatum(d.expiresAt)})</span></div>
3761            <div class="d-flex gap-2 align-items-center flex-wrap">
3762              <input class="form-control form-control-sm" id="anamLinkFeld" readonly
3763                     value="${this._anamEsc(d.link)}" style="flex:1;min-width:220px">
3764              <button class="btn btn-sm btn-outline-secondary" data-anamcopy="1">
3765                <i class="fas fa-copy me-1"></i>${t('Kopieren')}</button>
3766              <button class="btn btn-sm btn-outline-primary" data-anamsend="email">
3767                <i class="fas fa-envelope me-1"></i>${t('Per E-Mail')}</button>
3768              <button class="btn btn-sm btn-outline-primary" data-anamsend="sms">
3769                <i class="fas fa-comment-sms me-1"></i>${t('Per SMS')}</button>
3770            </div>
3771            <div class="mt-1 text-muted" id="anamSendStatus"></div>
3772          </div>`;
3773        ziel.querySelector('[data-anamcopy]').onclick = () => {
3774          const f = document.getElementById('anamLinkFeld');
3775          f.select();
3776          navigator.clipboard?.writeText(f.value);
3777          document.getElementById('anamSendStatus').textContent = t('Link kopiert.');
3778        };
3779        ziel.querySelectorAll('[data-anamsend]').forEach(b => {
3780          b.onclick = () => this._anamSend(patientId, wahl.value, b.dataset.anamsend,
3781                                           this._anamInviteId);
3782        });
3783      } catch (e) {
3784        ziel.innerHTML = `<div class="alert alert-danger py-2 px-3 small mb-0">
3785          ${t('Link konnte nicht erzeugt werden.')}</div>`;
3786      }
3787    }
3788
3789    async _anamSend(patientId, templateId, weg, inviteId) {
3790      const st = document.getElementById('anamSendStatus');
3791      if (st) st.textContent = t('Wird versendet…');
3792      try {
3793        const r = await this._recFetch(`/api/patients/${patientId}/anamnese/invite`, {
3794          method: 'POST',
3795          body: JSON.stringify({ templateId, send: weg,
3796                                 ...(inviteId ? { inviteId } : {}) })
3797        });
3798        const d = await r.json().catch(() => ({}));
3799        if (!r.ok) {
3800          if (st) st.textContent = d.error || t('Der Versand konnte nicht eingereiht werden.');
3801          return;
3802        }
3803        if (st) {
3804          st.textContent = d.queued
3805            ? t('Der Versand wurde eingereiht. Der Patient erhält den Bogen in Kürze.')
3806            : (t('Der Versand konnte nicht eingereiht werden.')
3807               + (d.queueError ? ' (' + d.queueError + ')' : ''));
3808        }
3809        const feld = document.getElementById('anamLinkFeld');
3810        if (feld && d.link) feld.value = d.link;
3811      } catch (e) {
3812        if (st) st.textContent = t('Der Versand konnte nicht eingereiht werden.');
3813      }
3814    }
3815
3816    // anam-alarm-v1 — Was die Praxis als beachtenswert gekennzeichnet hat,
3817    // steht ganz oben. Ein Bogen mit 33 Feldern wird sonst gelesen und nicht
3818    // geprueft; genau dort gehen Blutverduenner und Schwangerschaft unter.
3819    _anamAlarmHtml(alerts) {
3820      const a = alerts || [];
3821      if (!a.length) return '';
3822      const esc = v => this._anamEsc(v);
3823      return `
3824        <div class="alert alert-danger py-2 px-3 mb-3">
3825          <div class="fw-semibold mb-2">
3826            <i class="fas fa-triangle-exclamation me-2"></i>${t('Vor der Behandlung beachten')}</div>
3827          <ul class="mb-0 ps-3">
3828            ${a.map(x => `<li class="mb-1">
3829              <span class="fw-semibold">${esc(x.label)}:</span> ${esc(x.wert)}
3830              ${x.hinweis ? `<div class="small">${esc(x.hinweis)}</div>` : ''}
3831            </li>`).join('')}
3832          </ul>
3833        </div>`;
3834    }
3835
3836    /** anam-modal-v1b: Wert fuer die Vorschlagsliste. Ein reines ISO-Datum wird
3837     *  deutsch dargestellt (wie in der Eingangsliste), alles andere bleibt
3838     *  Rohwert und wird maskiert. Der GESPEICHERTE Wert bleibt unveraendert -
3839     *  das hier ist reine Darstellung. */
3840    _anamWert(s) {
3841      const v = String(s == null ? '' : s).trim();
3842      const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(v);
3843      return m ? `${m[3]}.${m[2]}.${m[1]}` : this._anamEsc(v);
3844    }
3845
3846    /** anam-modal-v1: Stil des Uebernahme-Dialogs, einmalig in die Seite haengen.
3847     *  Hebt zwei globale Altlasten NUR fuer diesen Dialog auf:
3848     *   - .modal-content{max-width:500px;padding:20px;margin:50px auto} (index_v2.html)
3849     *     deckelt jeden Dialog der App; deshalb greift modal-lg/80vw nirgends.
3850     *   - .table{table-layout:fixed} + .table td{white-space:nowrap;overflow:hidden;
3851     *     max-width:0} (styles.css) schneidet Zellinhalte unsichtbar ab.
3852     *  Bewusst lokal: global waeren 108 Fundstellen modal-lg/xl betroffen. */
3853    _anamStil() {
3854      if (document.getElementById('anam-modal-v1-style')) return;
3855      const st = document.createElement('style');
3856      st.id = 'anam-modal-v1-style';
3857      st.textContent = `
3858        .modal-dialog.anam-review,
3859        .modal-dialog.modal-lg.anam-review{
3860          max-width:min(1040px,calc(100vw - 2rem))!important;width:100%}
3861        .anam-review .modal-content{
3862          max-width:none!important;padding:0!important;margin:0!important;width:100%}
3863        .anam-review .modal-body{padding:1.25rem 1.5rem}
3864        .anam-review .table{table-layout:auto!important;width:100%}
3865        .anam-review .table th,.anam-review .table td{
3866          white-space:normal!important;overflow:visible!important;
3867          max-width:none!important;vertical-align:top}
3868        .anam-review .anam-c-chk{width:2.5rem}
3869        .anam-review .anam-c-feld{width:11rem}
3870        .anam-review .anam-wert{overflow-wrap:anywhere}
3871        .anam-review pre{max-height:none;overflow-wrap:anywhere}
3872      `;
3873      document.head.appendChild(st);
3874    }
3875
3876    async _anamReview(patientId, respId) {
3877      let d;
3878      try {
3879        const r = await this._recFetch(`/api/anamnese/responses/${respId}`);
3880        d = await r.json().catch(() => ({}));
3881        if (!r.ok) { alert(d.error || t('Bogen nicht abrufbar.')); return; }
3882      } catch (e) { alert(t('Bogen nicht abrufbar.')); return; }
3883
3884      const esc = v => this._anamEsc(v);
3885      const schon = d.status === 'uebernommen';
3886      const vor = d.suggestions || [];
3887
3888      const vorschlagHtml = vor.length ? `
3889        <div class="mb-3">
3890          <div class="fw-semibold small mb-2">${t('Stammdaten aktualisieren')}
3891            <span class="text-muted fw-normal">— ${t('bitte einzeln bestätigen')}</span></div>
3892          <table class="table table-sm align-middle mb-1">
3893            <thead><tr class="small text-muted">
3894              <th class="anam-c-chk"></th><th class="anam-c-feld">${t('Feld')}</th>
3895              <th>${t('Bisher')}</th><th>${t('Neu laut Bogen')}</th></tr></thead>
3896            <tbody>${vor.map(v => `
3897              <tr${v.warnung ? ' class="table-warning"' : ''}>
3898                <td><input type="checkbox" class="form-check-input" data-anamapply="${esc(v.key)}"
3899                     ${v.warnung ? 'data-anamwarn="1"' : ''}
3900                     ${schon ? 'disabled' : ''}></td>
3901                <td class="small">${esc(v.label)}</td>
3902                <td class="small text-muted anam-wert">${v.alt ? this._anamWert(v.alt)
3903                    : '<em>' + t('leer') + '</em>'}
3904                  ${v.altHerkunft ? `<div class="small text-muted fw-normal">
3905                    ${t('aus dem Namen')} „${esc(v.altHerkunft)}“</div>` : ''}</td>
3906                <td class="small fw-semibold anam-wert">${this._anamWert(v.neu)}
3907                  ${v.warnung ? `<div class="small text-danger fw-normal">
3908                    <i class="fas fa-triangle-exclamation me-1"></i>${esc(v.warnung)}</div>` : ''}
3909                  ${v.abweichend ? `<div class="small text-muted fw-normal">
3910                    ${t('Am Tablet angegeben')}: ${this._anamWert(v.abweichend)}</div>` : ''}</td>
3911              </tr>`).join('')}</tbody>
3912          </table>
3913          ${schon ? '' : `<button class="btn btn-sm btn-link p-0" data-anamall="1">
3914            ${t('Alle auswählen')}</button>`}
3915        </div>` : `<div class="small text-muted mb-3">
3916            ${t('Keine abweichenden Stammdaten in diesem Bogen.')}</div>`;
3917
3918      this._anamStil();
3919      const modal = document.createElement('div');
3920      modal.className = 'modal fade show';
3921      modal.style.cssText = 'display:block;background:rgba(0,0,0,.5)';
3922      modal.innerHTML = `
3923        <div class="modal-dialog modal-lg modal-dialog-scrollable anam-review">
3924          <div class="modal-content">
3925            <div class="modal-header">
3926              <h6 class="modal-title">${esc(d.templateName)}
3927                <span class="text-muted small ms-2">${this._anamDatum(d.submittedAt)}</span></h6>
3928              <button type="button" class="btn-close" data-anamclose="1"></button>
3929            </div>
3930            <div class="modal-body">
3931              ${schon ? `<div class="alert alert-success py-2 px-3 small">
3932                 ${t('Dieser Bogen wurde bereits in die Akte übernommen.')}</div>` : ''}
3933              ${this._anamAlarmHtml(d.alerts)}
3934              ${vorschlagHtml}
3935              <div class="fw-semibold small mb-2">${t('Antworten')}</div>
3936              <pre class="bg-light border rounded p-3 small mb-2"
3937                   style="white-space:pre-wrap;font-family:inherit">${esc(d.text)}</pre>
3938              ${(d.signatures || []).map(sg => `
3939                <div class="mt-3">
3940                  <div class="fw-semibold small mb-1">${esc(sg.label)}</div>
3941                  <img src="${esc(sg.dataUrl)}" alt="${t('Unterschrift')}"
3942                       style="max-width:100%;max-height:170px;border:1px solid #dee2e6;
3943                              border-radius:6px;background:#fff;padding:4px">
3944                </div>`).join('')}
3945            </div>
3946            <div class="modal-footer">
3947              <button class="btn btn-outline-secondary" data-anamclose="1">${t('Schließen')}</button>
3948              ${schon ? '' : `<button class="btn btn-success" data-anamadopt="1">
3949                <i class="fas fa-file-import me-1"></i>${t('In die Akte übernehmen')}</button>`}
3950            </div>
3951          </div>
3952        </div>`;
3953      document.body.appendChild(modal);
3954
3955      const zu = () => modal.remove();
3956      modal.querySelectorAll('[data-anamclose]').forEach(b => b.onclick = zu);
3957      // anam-datum-v1: "Alle auswaehlen" ueberspringt beanstandete Werte -
3958      // sonst wandert genau der unplausible Wert mit einem Klick in die
3959      // Stammdaten. Einzeln bleibt er ausdruecklich waehlbar.
3960      const alle = modal.querySelector('[data-anamall]');
3961      if (alle) alle.onclick = () => modal.querySelectorAll('[data-anamapply]')
3962        .forEach(c => { if (!c.dataset.anamwarn) c.checked = true; });
3963
3964      const knopf = modal.querySelector('[data-anamadopt]');
3965      if (knopf) knopf.onclick = async () => {
3966        knopf.disabled = true;
3967        const felder = [...modal.querySelectorAll('[data-anamapply]')]
3968          .filter(c => c.checked).map(c => c.dataset.anamapply);
3969        try {
3970          const r = await this._recFetch(`/api/anamnese/responses/${respId}/adopt`, {
3971            method: 'POST', body: JSON.stringify({ applyFields: felder })
3972          });
3973          const res = await r.json().catch(() => ({}));
3974          if (!r.ok) { alert(res.error || t('Übernahme fehlgeschlagen.')); knopf.disabled = false; return; }
3975          zu();
3976          this._anamLoad(patientId);
3977          if (typeof this.renderPatientRecords === 'function') this.renderPatientRecords();
3978          if (typeof this.loadContact === 'function') this.loadContact(patientId);
3979        } catch (e) {
3980          alert(t('Übernahme fehlgeschlagen.'));
3981          knopf.disabled = false;
3982        }
3983      };
3984    }
3985
3986
3987    async s18LoadConsents(patientId) { // DSGVO_S18
3988
3989      try {
3990
3991        const r = await this._recFetch(`/api/patients/${patientId}/consents`);
3992
3993        if (!r.ok) return;
3994
3995        const d = await r.json();
3996
3997        this._s18Templates = d.templates || {};
3998
3999        this._s18Render(patientId, d.consents || {});
4000
4001      } catch (e) { console.warn('consents load', e); }
4002
4003    }
4004
4005
4006    _s18Render(patientId, state) { // s18-consent-v2
4007
4008      const box = document.getElementById('s18Consents');
4009
4010      if (!box) return;
4011
4012      const esc = s => String(s == null ? '' : s).replace(/[&<>"']/g,
4013        c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
4014
4015      const KAN = { email: 'E-Mail', sms: 'SMS', whatsapp: 'WhatsApp', phone: 'Anruf' };
4016
4017      const deDat = iso => { if (!iso) return ''; const d = new Date(iso);
4018        return isNaN(d) ? '' : d.toLocaleDateString('de-DE'); };
4019
4020      const rows = Object.entries(this._s18Templates).map(([type, tpl]) => {
4021
4022        const st = state[type];
4023
4024        let badge;
4025
4026        if (st && st.granted && st.expired) {
4027
4028          badge = `<span class="badge bg-warning text-dark">abgelaufen ${esc(deDat(st.expiresAt))}</span>`;
4029
4030        } else if (st && st.granted) {
4031
4032          badge = `<span class="badge bg-success">erteilt ${st.signed ? '&#9997;' : ''} ${esc(deDat(st.at))}</span>`;
4033
4034        } else if (st) {
4035
4036          badge = `<span class="badge bg-danger">widerrufen ${esc(deDat(st.at))}</span>`;
4037
4038        } else {
4039
4040          badge = `<span class="badge bg-secondary">offen</span>`;
4041
4042        }
4043
4044        // Zusatzzeile: Wege + Ablauf + Herkunft. Nur was gesetzt ist.
4045        const zusatz = [];
4046
4047        if (st && st.granted) {
4048
4049          if (st.channels) {
4050
4051            const erl = Object.keys(st.channels).filter(k => st.channels[k]).map(k => KAN[k] || k);
4052
4053            zusatz.push(erl.length ? 'nur ' + erl.join(', ') : 'kein Weg erlaubt');
4054
4055          }
4056
4057          if (st.expiresAt && !st.expired) zusatz.push('bis ' + deDat
4057(st.expiresAt));
4058
4059          if (st.source === 'patient') zusatz.push('selbst erteilt');
4060
4061        }
4062
4063        const sub = zusatz.length
4064
4065          ? `<div class="text-muted" style="font-size:0.75rem">${esc(zusatz.join(' &middot; ')).replace(/&amp;middot;/g,'&middot;')}</div>` : '';
4066
4067        const btn = st && st.granted && !st.expired
4068
4069          ? `<button class="btn btn-sm btn-outline-danger" data-s18-revoke="${esc(type)}">Widerrufen</button>`
4070
4071          : `<button class="btn btn-sm btn-success" data-s18-grant="${esc(type)}">Erteilen&hellip;</button>`;
4072
4073        return `<tr><td style="font-size:0.85rem">${esc(tpl.title)} <span class="text-muted">v${esc(tpl.version)}</span>${sub}</td><td>${badge}</td><td class="text-end">${btn}</td></tr>`;
4074
4075      }).join('');
4076
4077      box.innerHTML = `<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:0.4rem">
4078
4079          <h6 style="margin:0">&#128271; Einwilligungen (DSGVO)</h6>
4080
4081          <button class="btn btn-sm btn-outline-primary" data-s18-invite="1">Patient selbst unterschreiben lassen</button>
4082
4083        </div><table class="table table-sm">${rows}</table>`;
4084
4085      box.querySelectorAll('[data-s18-grant]').forEach(b => b.onclick = () => this._s18Grant(patientId, b.dataset.s18Grant));
4086
4087      box.querySelectorAll('[data-s18-revoke]').forEach(b => b.onclick = () => this._s18Save(patientId, b.dataset.s18Revoke, false, null));
4088
4089      const inv = box.querySelector('[data-s18-invite]');
4090
4091      if (inv) inv.onclick = () => this._s18Invite(patientId);
4092
4093    }
4094
4095
4096    async _s18Grant(patientId, type) { // s18-consent-v2
4097
4098      const tpl = this._s18Templates[type];
4099
4100      const res = await this._s18SignaturePad(tpl);
4101
4102      if (res === undefined) return; // abgebrochen
4103
4104      await this._s18Save(patientId, type, true, res.signature, res.channels, res.expiresAt);
4105
4106    }
4107
4108
4109    _s18SignaturePad(tpl) { // s18-consent-v2: Text + Wege + Ablauf + Unterschrift. resolve({signature, channels, expiresAt}|undefined)
4110
4111      return new Promise(resolve => {
4112
4113        const KAN = [['email','E-Mail'],['sms','SMS'],['whatsapp','WhatsApp'],['phone','Anruf']];
4114
4115        const kanHtml = KAN.map(([k,l]) =>
4116
4117          `<label style="display:inline-flex;align-items:center;gap:4px;margin-right:12px;font-size:0.85rem">
4118
4119             <input type="checkbox" data-kan="${k}" checked> ${l}</label>`).join('');
4120
4121        const ov = document.createElement('div');
4122
4123        ov.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:9999;display:flex;align-items:center;justify-content:center';
4124
4125        ov.innerHTML = `<div style="background:#fff;border-radius:8px;max-width:520px;width:94%;padding:1.25rem;max-height:92vh;overflow:auto">
4126
4127          <h6>${tpl.title} <span class="text-muted">v${tpl.version}</span></h6>
4128
4129          <div style="font-size:0.85rem;max-height:150px;overflow:auto;background:#f8f9fa;padding:0.6rem;border-radius:6px">${tpl.text}</div>
4130
4131          <div style="margin-top:0.75rem;font-size:0.8rem;color:#666">Auf diesen Wegen darf der Patient dafür kontaktiert werden:</div>
4132
4133          <div style="margin-top:0.3rem">${kanHtml}</div>
4134
4135          <div style="margin-top:0.75rem;font-size:0.8rem;color:#666">Gültig bis (optional – leer = unbefristet):</div>
4136
4137          <input type="date" id="s18Exp" class="form-control form-control-sm" style="max-width:200px">
4138
4139          <div style="margin-top:0.75rem;font-size:0.8rem;color:#666">Unterschrift (optional, Finger/Maus):</div>
4140
4141          <canvas id="s18Sig" width="460" height="130" style="border:1px dashed #aaa;border-radius:6px;touch-action:none;width:100%"></canvas>
4142
4143          <div style="display:flex;gap:0.5rem;justify-content:flex-end;margin-top:0.75rem">
4144
4145            <button class="btn btn-sm btn-outline-secondary" data-a="cancel">Abbrechen</button>
4146
4147            <button class="btn btn-sm btn-outline-secondary" data-a="clear">Löschen</button>
4148
4149            <button class="btn btn-sm btn-success" data-a="ok">Einwilligung erteilen</button>
4150
4151          </div></div>`;
4152
4153        document.body.appendChild(ov);
4154
4155        const cv = ov.querySelector('#s18Sig'), ctx = cv.getContext('2d');
4156
4157        ctx.lineWidth = 2; ctx.lineCap = 'round'; let drawing = false, drawn = false;
4158
4159        const pos = e => { const r = cv.getBoundingClientRect(), t = e.touches ? e.touches[0] : e;
4160
4161          return [(t.clientX - r.left) * cv.width / r.width, (t.clientY - r.top) * cv.height / r.height]; };
4162
4163        const down = e => { drawing = true; drawn = true; const [x,y] = pos(e); ctx.beginPath(); ctx.moveTo(x,y); e.preventDefault(); };
4164
4165        const move = e => { if (!drawing) return; const [x,y] = pos(e); ctx.lineTo(x,y); ctx.stroke(); e.preventDefault(); };
4166
4167        cv.onmousedown = down; cv.onmousemove = move; window.addEventListener('mouseup', () => drawing = false);
4168
4169        cv.ontouchstart = down; cv.ontouchmove = move; cv.ontouchend = () => drawing = false;
4170
4171        ov.querySelector('[data-a=cancel]').onclick = () => { ov.remove(); resolve(undefined); };
4172
4173        ov.querySelector('[data-a=clear]').onclick = () => { ctx.clearRect(0,0,cv.width,cv.height); drawn = false; };
4174
4175        ov.querySelector('[data-a=ok]').onclick = () => {
4176
4177          const channels = {};
4178
4179          ov.querySelectorAll('[data-kan]').forEach(i => { channels[i.dataset.kan] = i.checked; });
4180
4181          const alle = Object.values(channels).every(v => v);
4182
4183          const d = ov.querySelector('#s18Exp').value;
4184
4185          // Alle angehakt = keine Einschraenkung -> null, damit die
4186          // Einwilligung nicht unnoetig eingeengt gespeichert wird.
4187          const exp = d ? new Date(d + 'T23:59:59').toISOString() : null;
4188
4189          const sig = drawn ? cv.toDataURL('image/png') : null;
4190
4191          ov.remove();
4192
4193          resolve({ signature: sig, channels: alle ? null : channels, expiresAt: exp });
4194
4195        };
4196
4197      });
4198
4199    }
4200
4201
4202    async _s18Save(patientId, type, granted, signature, channels, expiresAt) { // s18-consent-v2
4203
4204      try {
4205
4206        const body = { type, granted, signature };
4207
4208        if (granted && channels) body.channels = channels;
4209
4210        if (granted && expiresAt) body.expiresAt = expiresAt;
4211
4212        const r = await this._recFetch(`/api/patients/${patientId}/consents`, { method: 'POST', body: JSON.stringify(body) });
4213
4214        if (!r.ok) { const e = await r.json().catch(() => ({})); alert(e.error || 'Speichern fehlgeschlagen'); return; }
4215
4216        const d = await r.json();
4217
4218        this._s18Render(patientId, d.consents || {});
4219
4220        if (type === 'ki_recall') { const t = document.getElementById('recRecallOptIn'); if (t) t.checked = granted; }
4221
4222      } catch (e) { alert('Netzwerkfehler beim Speichern'); }
4223
4224    }
4225
4226
4227    async _s18Invite(patientId) { // s18-consent-v2: Link zum Selbst-Unterschreiben
4228
4229      const typen = Object.keys(this._s18Templates || {});
4230
4231      if (!typen.length) { alert('Keine Einwilligungsvorlagen vorhanden'); return; }
4232
4233      try {
4234
4235        const r = await this._recFetch(`/api/patients/${patientId}/consents/invite`,
4236
4237          { method: 'POST', body: JSON.stringify({ types: typen }) });
4238
4239        if (!r.ok) { const e = await r.json().catch(() => ({})); alert(e.error || 'Link konnte nicht erzeugt werden'); return; }
4240
4241        const d = await r.json();
4242
4243        this._s18ShowLink(patientId, d.link, d.expiresAt);
4244
4245      } catch (e) { alert('Netzwerkfehler'); }
4246
4247    }
4248
4249
4250    _s18ShowLink(patientId, link, expiresAt) { // s18-consent-v2
4251
4252      const bis = expiresAt ? new Date(expiresAt).toLocaleDateString('de-DE') : '';
4253
4254      const ov = document.createElement('div');
4255
4256      ov.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:9999;display:flex;align-items:center;justify-content:center';
4257
4258      ov.innerHTML = `<div style="background:#fff;border-radius:8px;max-width:520px;width:94%;padding:1.25rem">
4259
4260        <h6>Link zum Selbst-Unterschreiben</h6>
4261
4262        <div style="font-size:0.85rem;color:#666;margin-bottom:0.5rem">
4263
4264          Der Patient wählt dabei auch selbst, auf welchem Weg er erreicht werden darf.
4265
4266          Gültig bis ${bis}.</div>
4267
4268        <input id="s18Link" class="form-control form-control-sm" readonly value="">
4269
4270        <div style="display:flex;gap:0.5rem;flex-wrap:wrap;justify-content:flex-end;margin-top:0.75rem">
4271
4272          <button class="btn btn-sm btn-outline-secondary" data-a="close">Schließen</button>
4273
4274          <button class="btn btn-sm btn-outline-primary" data-a="copy">Link kopieren</button>
4275
4276          <button class="btn btn-sm btn-outline-success" data-a="sms">Per SMS senden</button>
4277
4278          <button class="btn btn-sm btn-success" data-a="mail">Per E-Mail senden</button>
4279
4280        </div></div>`;
4281
4282      document.body.appendChild(ov);
4283
4284      ov.querySelector('#s18Link').value = link || '';
4285
4286      const senden = async (weg) => {
4287
4288        const typen = Object.keys(this._s18Templates || {});
4289
4290        try {
4291
4292          const r = await this._recFetch(`/api/patients/${patientId}/consents/invite`,
4293
4294            { method: 'POST', body: JSON.stringify({ types: typen, send: weg }) });
4295
4296          const d = await r.json().catch(() => ({}));
4297
4298          ov.remove();
4299
4300          alert(r.ok && d.versand && d.versand.eingereiht
4301
4302            ? 'Die Anfrage wurde zum Versand eingereiht.'
4303
4304            : (d.error || 'Der Versand konnte nicht eingereiht werden.'));
4305
4306        } catch (e) { ov.remove(); alert('Netzwerkfehler'); }
4307
4308      };
4309
4310      ov.querySelector('[data-a=close]').onclick = () => ov.remove();
4311
4312      ov.querySelector('[data-a=copy]').onclick = () => {
4313
4314        const i = ov.querySelector('#s18Link'); i.select();
4315
4316        try { document.execCommand('copy'); } catch (e) {}
4317
4318        if (navigator.clipboard) navigator.clipboard.writeText(i.value).catch(() => {});
4319
4320      };
4321
4322      ov.querySelector('[data-a=sms]').onclick = () => senden('sms');
4323
4324      ov.querySelector('[data-a=mail]').onclick = () => senden('email');
4325
4326    }
4327
4328
4329    async _s18Pdf(patientId, path, opts) { // PDF im Inline-Modal via Ticket-URL (kein blob: - CSP object-src blockt Chromes PDF-Viewer in blob-iframes)
4330      let ov = null;
4331      try {
4332        const sep = path.includes('?') ? '&' : '?';
4333        const r = await this._recFetch(`/api/patients/${patientId}/print/${path}${sep}mode=ticket`, opts);
4334        if (!r.ok) { const e = await r.json().catch(() => ({})); alert(e.error || 'PDF fehlgeschlagen'); return; }
4335        const d = await r.json();
4336        ov = this._s18PdfModal(d.url, path.split('?')[0]);
4337      } catch (e) { if (ov) ov.remove(); alert('PDF fehlgeschlagen: ' + (e && e.message || e)); }
4338    }
4339
4340    _s18PdfModal(blobUrl, path) {
4341      const names = { behandlungsverlauf: 'Behandlungsverlauf', terminuebersicht: 'Terminübersicht', zeitbestaetigung: 'Zeitbestätigung', patientenbrief: 'Patientenbrief' };
4342      const ov = document.createElement('div');
4343      ov.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:9999;display:flex;align-items:center;justify-content:center;padding:1.5rem';
4344      ov.innerHTML = `
4345        <div style="background:#fff;border-radius:8px;width:min(900px,96vw);height:min(92vh,1100px);display:flex;flex-direction:column;overflow:hidden">
4346          <div style="display:flex;align-items:center;justify-content:space-between;padding:.6rem 1rem;border-bottom:1px solid #dee2e6">
4347            <strong>🖨 ${names[path] || 'Dokument'}</strong>
4348            <div style="display:flex;gap:.5rem">
4349              <a class="btn btn-sm btn-outline-primary" href="${blobUrl}${blobUrl.startsWith('blob:') ? '' : '?dl=1'}" download="${path}.pdf">⬇ Herunterladen</a>
4350              <button type="button" class="btn btn-sm btn-secondary" data-s18pdf="close">Schließen</button>
4351            </div>
4352          </div>
4353          <iframe src="${blobUrl}" style="flex:1;border:0;background:#525659" title="PDF"></iframe>
4354        </div>`;
4355      const close = () => { ov.remove(); if (blobUrl.startsWith('blob:')) setTimeout(() => URL.revokeObjectURL(blobUrl), 1000); };
4356      ov.addEventListener('click', ev => { if (ev.target === ov) close(); });
4357      ov.querySelector('[data-s18pdf=close]').onclick = close;
4358      document.body.appendChild(ov);
4359      return ov;
4360    }
4361
4362    async _s18Brief(patientId) { // KI-Entwurf -> editierbares Modal -> PDF (Mensch bestaetigt)
4363
4364      const btn = document.querySelector('[data-s18=brief]');
4365
4366      if (btn) { btn.disabled = true; btn.textContent = '✨ KI erstellt Entwurf…'; }
4367
4368      try {
4369
4370        const r = await this._recFetch('/api/physio/patientenbrief/draft', { method: 'POST', body: JSON.stringify({ patient_id: patientId }) });
4371
4372        if (!r.ok) { const e = await r.json().catch(() => ({})); alert(e.error || 'KI-Entwurf fehlgeschlagen'); return; }
4373
4374        const { draft } = await r.json();
4375
4376        const ov = document.createElement('div');
4377
4378        ov.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:9999;display:flex;align-items:center;justify-content:center';
4379
4380        ov.innerHTML = `<div style="background:#fff;border-radius:8px;max-width:640px;width:94%;padding:1.25rem">
4381
4382          <h6>Patientenbrief — KI-Entwurf (bitte prüfen)</h6>
4383
4384          <input id="s18Betreff" class="form-control form-control-sm" value="${(draft.betreff||'').replace(/"/g,'&quot;')}">
4385
4386          <textarea id="s18Text" class="form-control" rows="14" style="margin-top:0.5rem;font-size:0.85rem"></textarea>
4387
4388          <div style="display:flex;gap:0.5rem;justify-content:flex-end;margin-top:0.75rem">
4389
4390            <button class="btn btn-sm btn-outline-secondary" data-a="cancel">Abbrechen</button>
4391
4392            <button class="btn btn-sm btn-success" data-a="pdf">Als PDF erstellen</button>
4393
4394          </div></div>`;
4395
4396        document.body.appendChild(ov);
4397
4398        ov.querySelector('#s18Text').value = draft.text || '';
4399
4400        ov.querySelector('[data-a=cancel]').onclick = () => ov.remove();
4401
4402        ov.querySelector('[data-a=pdf]').onclick = async () => {
4403
4404          const body = { betreff: ov.querySelector('#s18Betreff').value, text: ov.querySelector('#s18Text').value };
4405
4406          ov.remove();
4407
4408          await this._s18Pdf(patientId, 'patientenbrief', { method: 'POST',
4409
4410            body: JSON.stringify(body) });
4411
4412        };
4413
4414      } finally { if (btn) { btn.disabled = false; btn.textContent = '✨ Patientenbrief (KI)'; } }
4415
4416    }
4417
4418
4419
4420    async loadPatientRecords() {
4421
4422      setTimeout(() => { // DSGVO_S18 wiring (nach Render)
4423
4424        const q = s => document.querySelector(`[data-s18="${s}"]`);
4425
4426        if (q('print-verlauf')) q('print-verlauf').onclick = () => this._s18Pdf(this.contactId, 'behandlungsverlauf');
4427
4428        if (q('print-termine')) q('print-termine').onclick = () => this._s18Pdf(this.contactId, 'terminuebersicht');
4429
4430        if (q('print-zeit')) q('print-zeit').onclick = () => this._s18Pdf(this.contactId, 'zeitbestaetigung');
4431
4432        if (q('brief')) q('brief').onclick = () => this._s18Brief(this.contactId);
4433
4434        this.s18LoadConsents(this.contactId);
4435
4436        this._anamLoad(this.contactId);  // anamnese-akte-v1
4437
4438      }, 0);
4439        const list = document.getElementById('recList');
4440        if (!list) return;
4441        this._mediaRecords = null; // S19T2: Mediathek beim naechsten Tab-Wechsel neu laden
4442        try {
4443            const resp = await this._recFetch(`/api/patients/${this.contactId}/records`);
4444            if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
4445            const data = await resp.json();
4446            const records = data.records || [];
4447            if (!records.length) {
4448                list.innerHTML = `<div class="text-center text-muted p-2">${t('Noch keine Eintraege in der Akte')}</div>`;
4449                return;
4450            }
4451            list.innerHTML = this._renderSchmerzChart(records) + this._tlRender(records);
4452            list.querySelectorAll('.rec-del-btn').forEach(btn => {
4453                btn.addEventListener('click', () => this.deletePatientRecord(btn.dataset.recid));
4454            });
4455            list.querySelectorAll('.rec-file-link').forEach(a => {
4456                a.addEventListener('click', (ev) => {
4457                    ev.preventDefault();
4458                    this.openPatientRecordFile(a.dataset.recid);
4459                });
4460            });
4461            this._tlWire(list);
4462        } catch (e) {
4463            console.error('loadPatientRecords failed:', e);
4464            list.innerHTML = `<div class="text-center text-danger p-2">
4464${t('Akte konnte nicht geladen werden')}</div>`;
4465        }
4466    }
4467
4468    // ===== Akte-Timeline (S19) =====
4469    _tlTypeMeta() {
4470        return {
4471            behandlung: { color: '#28a745', icon: 'fa-hand-holding-medical' },
4472            befund: { color: '#17a2b8', icon: 'fa-clipboard-list' },
4473            bodychart: { color: '#dc3545', icon: 'fa-user' },
4474            diagnose: { color: '#e0a800', icon: 'fa-stethoscope' },
4475            verlauf: { color: '#6c757d', icon: 'fa-chart-line' },
4476            dokument: { color: '#343a40', icon: 'fa-file' },
4477            notiz: { color: '#8d959d', icon: 'fa-note-sticky' }
4478        };
4479    }
4480    _tlRender(records) {
4481        const groups = [];
4482        let cur = null;
4483        records.forEach(r => {
4484            const d = r.createdAt ? new Date(r.createdAt) : null;
4485            const key = d ? `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}` : '';
4486            const label = d ? d.toLocaleDateString('de-DE', { month: 'long', year: 'numeric' }) : t('Ohne Datum');
4487            if (!cur || cur.key !== key) { cur = { key, label, items: [] }; groups.push(cur); }
4488            cur.items.push(r);
4489        });
4490        return groups.map(g => `
4491            <div class="text-muted small fw-bold mt-3 mb-2 border-bottom pb-1" style="letter-spacing:.03em;">${g.label} <span class="fw-normal">· ${g.items.length}</span></div>
4492            ${g.items.map(r => this._tlEntry(r)).join('')}`).join('');
4493    }
4494    _tlEntry(r) {
4495        const meta = this._tlTypeMeta()[r.recordType] || { color: '#6c757d', icon: 'fa-file-lines' };
4496        const fmt = (x) => new Date(x).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' });
4497        const dt = r.createdAt ? fmt(r.createdAt) : '';
4498        const updated = r.updatedBy && r.updatedAt && r.createdAt && (new Date(r.updatedAt) - new Date(r.createdAt) > 60000)
4499            ? `${t('Zuletzt bearbeitet von')} ${this._recEscape(r.updatedBy)} ${t('am')} ${fmt(r.updatedAt)}` : '';
4500        const isImg = r.hasFile && (r.fileMime || '').startsWith('image/');
4501        return `
4502        <div class="border rounded p-2 mb-2" style="border-left:4px solid ${meta.color} !important;">
4503            <div class="d-flex justify-content-between align-items-start">
4504                <div>
4505                    <i class="fas ${meta.icon} me-1" style="color:${meta.color};"></i>
4506                    <span class="badge me-2" style="background:${meta.color};color:#fff;">${this._recTypeLabel(r.recordType)}</span>
4507                    ${r.isPrivate ? `<i class="fas fa-lock text-muted me-1" title="${t('Privater Eintrag - nur fuer Sie sichtbar')}"></i>` : ''}
4508                    <strong class="small">${this._recEscape(r.title)}</strong>
4509                </div>
4510                <div class="text-nowrap position-relative">
4511                    <small class="text-muted me-2">${dt}${r.authorName ? ' · ' + this._recEscape(r.authorName) : ''}</small>
4512                    <button class="btn btn-sm btn-link text-muted p-0 tl-menu-btn" data-recid="${r.id}" title="${t('Aktionen')}"><i class="fas fa-ellipsis-vertical px-1"></i></button>
4513                    <div class="tl-menu shadow border rounded bg-white py-1" data-menu="${r.id}" style="display:none;position:absolute;right:0;top:1.4rem;z-index:100;
4513min-width:200px;text-align:left;">
4514                        <button class="dropdown-item small py-1" data-tlact="print" data-recid="${r.id}"><i class="fas fa-print me-2"></i>${t('Drucken')}</button>
4515                        <button class="dropdown-item small py-1" data-tlact="dup" data-recid="${r.id}"><i class="fas fa-copy me-2"></i>${t('Duplizieren')}</button>
4516                        <button class="dropdown-item small py-1" data-tlact="tpl" data-recid="${r.id}"><i class="fas fa-bookmark me-2"></i>${t('Als neue Vorlage speichern')}</button>
4517                        <button class="dropdown-item small py-1" data-tlact="date" data-recid="${r.id}"><i class="fas fa-calendar-day me-2"></i>${t('Datum bearbeiten')}</button>
4518                        <button class="dropdown-item small py-1" data-tlact="hist" data-recid="${r.id}"><i class="fas fa-clock-rotate-left me-2"></i>${t('Aenderungsverlauf')}</button>
4519                        <button class="dropdown-item small py-1" data-tlact="bill" data-recid="${r.id}"><i class="fas fa-file-invoice me-2"></i>${t('Abrechnung vorschlagen')}</button>
4520                        <button class="dropdown-item small py-1 text-danger" data-tlact="del" data-recid="${r.id}"><i class="fas fa-trash-alt me-2"></i>${t('Loeschen')}</button>
4521                    </div>
4522                </div>
4523            </div>
4524            ${this._renderStructuredBefund(r.structuredData)}
4525            ${this._renderBodychartView(r.structuredData)}
4526            <div class="small mt-1" style="white-space:pre-wrap;">${this._recEscape(r.content)}</div>
4527            ${isImg ? `<div class="mt-1"><img class="tl-thumb rounded border" data-recid="${r.id}" alt="" style="max-height:110px;max-width:180px;cursor:pointer;display:none;"></div>` : ''}
4528            ${r.hasFile ? `<div class="mt-1"><a href="#" class="small rec-file-link" data-recid="${r.id}"><i class="fas fa-paperclip me-1"></i>${this._recEscape(r.fileName || t('Datei'))}</a></div>` : ''}
4529            ${updated ? `<div class="text-muted fst-italic mt-1" style="font-size:.72rem;">${updated}</div>` : ''}
4530        </div>`;
4531    }
4532    // --- billing-ui-v1 (Phase 7): Abrechnungsvorschlag -----------------------
4533    // E3: Diese Ansicht erzeugt KEINE Rechnung. Sie zeigt einen Vorschlag, den
4534    // ein Mensch pruefen, korrigieren und ausdruecklich freigeben muss.
4535    _billEuro(ct) {
4536        return ((Number(ct) || 0) / 100).toLocaleString('de-DE',
4537            { style: 'currency', currency: 'EUR' });
4538    }
4539    _billPosHtml(p, i, gesperrt) {
4540        const pflicht = p.begruendungPflicht;
4541        const nichtBelegt = (p.nichtBelegt || []).length
4542            ? `<div class="text-muted" style="font-size:.7rem;">${t('Nicht belegt im Text')}:
4543                 ${this._recEscape((p.nichtBelegt || []).join(', '))}</div>` : '';
4544        const konf = (p.konfidenz !== undefined && p.konfidenz !== null)
4545            ? `<span class="badge bg-light text-dark border ms-1"
4546                     title="${t('Wie eindeutig der Text die Leistung benennt')}">
4547                 ${Math.round(Number(p.konfidenz) * 100)} %</span>` : '';
4548        return `
4549        <div class="border rounded p-2 mb-2 bill-pos" data-i="${i}">
4550          <div class="d-flex justify-content-between align-items-start">
4551            <div class="me-2">
4552              <strong class="small">${this._recEscape(p.ziffer)}</strong>${konf}
4553              <div class="small">${this._recEscape(p.bezeichnung || '')}</div>
4554              <div class="text-muted" style="font-size:.72rem;">
4555                ${p.punktzahl != null ? p.punktzahl + ' ' + t('Punkte') : t('ohne Punktzahl')}
4556                ${p.abschnitt ? ' · ' + t('Abschnitt') + ' ' + this._recEscape(p.abschnitt) : ''}
4557              </div>
4558              ${p.begruendungVorschlag
4559                ? `<div class="text-muted fst-italic" style="font-size:.72rem;">
4560                     ${this._recEscape(p.begruendungVorschlag)}</div>` : ''}
4561              ${nichtBelegt}
4562            </div>
4563            <div class="text-nowrap text-end">
4564              <div class="fw-semibold small">${this._billEuro(p.betragCt)}</div>
4565              <div class="d-flex align-items-center gap-1 mt-1">
4566                <label class="text-muted" style="font-size:.7rem;">${t('Anzahl')}</label>
4567                <input type="number" min="1" step="1" class="form-control form-control-sm bill-anzahl"
4568                       value="${Number(p.anzahl) || 1}" style="width:60px;" ${gesperrt ? 'disabled' : ''}>
4569                <label class="text-muted" style="font-size:.7rem;">${t('Faktor')}</label>
4570                <input type="number" min="1" step="0.1" class="form-control form-control-sm bill-faktor"
4571                       value="${this._recEscape(String(p.faktor || ''))}" style="width:72px;"
4572                       ${gesperrt ? 'disabled' : ''}>
4573              </div>
4574            </div>
4575          </div>
4576          <div class="mt-1 ${pflicht ? '' : 'd-none'} bill-begr-wrap">
4577            <input type="text" class="form-control form-control-sm bill-begr"
4578                   placeholder="${t('Begruendung fuer den erhoehten Steigerungssatz (Pflicht nach § 12 Abs. 3 GOÄ)')}"
4579                   value="${this._recEscape(p.begruendung || '')}" ${gesperrt ? 'disabled' : ''}>
4580          </div>
4581        </div>`;
4582    }
4583    _billKoerperHtml(prop, frei) {
4584        const gesperrt = prop.status !== 'entwurf';
4585        const sperrend = (prop.hinweise || []).filter(h => h.schwere === 'sperrend');
4586        const warnung = (prop.hinweise || []).filter(h => h.schwere !== 'sperrend');
4587        const liste = h => h.map(x => `<li>${this._recEscape(x.text)}</li>`).join('');
4588        return `
4589        ${sperrend.length ? `<div class="alert alert-danger py-2 px-2 small mb-2">
4590            <strong>${t('Freigabe noch nicht moeglich')}
4590</strong>
4591            <ul class="mb-0 ps-3">${liste(sperrend)}</ul></div>` : ''}
4592        ${warnung.length ? `<div class="alert alert-warning py-2 px-2 small mb-2">
4593            <ul class="mb-0 ps-3">${liste(warnung)}</ul></div>` : ''}
4594        ${prop.positionen && prop.positionen.length
4595            ? prop.positionen.map((p, i) => this._billPosHtml(p, i, gesperrt)).join('')
4596            : `<div class="text-muted small text-center py-4">
4597                 ${t('Aus dieser Dokumentation liess sich keine Ziffer ableiten.')}<br>
4598                 <span style="font-size:.8rem;">${t('Das ist kein Fehler - eine fehlende Position ist harmlos, eine falsche waere ein Abrechnungsfehler.')}</span>
4599               </div>`}
4600        <div class="d-flex justify-content-between border-top pt-2 mt-2">
4601          <span class="small text-muted">${t('Status')}:
4602            <strong>${this._recEscape(prop.status)}</strong>
4603            ${prop.freigabeName ? ' · ' + t('freigegeben von') + ' ' + this._recEscape(prop.freigabeName) : ''}</span>
4604          <strong>${this._billEuro(prop.summeCt)}</strong>
4605        </div>`;
4606    }
4607    async _billZeigen(recId) {
4608        let daten;
4609        try {
4610            const resp = await this._recFetch('/api/billing/proposals', {
4611                method: 'POST', headers: { 'Content-Type': 'application/json' },
4612                body: JSON.stringify({ katalog: 'goae', recordId: recId })
4613            });
4614            if (!resp.ok) {
4615                UI.showError(resp.status === 403
4616                    ? t('Fuer Abrechnungsvorschlaege fehlt Ihnen die Berechtigung')
4617                    : t('Der Abrechnungsvorschlag konnte nicht erstellt werden'));
4618                return;
4619            }
4620            daten = await resp.json();
4621        } catch (e) {
4622            UI.showError(t('Der Abrechnungsvorschlag konnte nicht erstellt werden'));
4623            return;
4624        }
4625        this._billProp = daten.proposal || {};
4626        const alt = document.getElementById('billModal');
4627        if (alt) alt.remove();
4628        const wrap = document.createElement('div');
4629        wrap.innerHTML = `
4630        <div class="modal fade" id="billModal" tabindex="-1">
4631          <div class="modal-dialog modal-lg modal-dialog-scrollable">
4632            <div class="modal-content">
4633              <div class="modal-header">
4634                <h5 class="modal-title"><i class="fas fa-file-invoice me-2"></i>${t('Abrechnungsvorschlag')}
4635                  <small class="text-muted">GOÄ</small></h5>
4636                <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
4637              </div>
4638              <div class="modal-body" id="billBody"></div>
4639              <div class="modal-footer">
4640                <small class="text-muted me-auto">
4641                  ${t('Vorschlag der KI - es wird nichts abgerechnet, bevor Sie freigeben.')}</small>
4642                <button type="button" class="btn btn-outline-secondary btn-sm" id="billSave">
4643                  ${t('Aenderungen pruefen')}</button>
4644                <button type="button" class="btn btn-primary btn-sm" id="billRelease">
4645                  <i class="fas fa-check me-1"></i>${t('Fachlich freigeben')}</button>
4646              </div>
4647            </div>
4648          </div>
4649        </div>`;
4650        document.body.appendChild(wrap.firstElementChild);
4651        this._billRender();
4652        new bootstrap.Modal(document.getElementById('billModal')).show();
4653    }
4654    _billRender() {
4655        const prop = this._billProp || {};
4656        const body = document.getElementById('billBody');
4657        if (!body) return;
4658        body.innerHTML = this._billKoerperHtml(prop);
4659        const frei = !(prop.hinweise || []).some(h => h.schwere === 'sperrend');
4660        const rel = document.getElementById('billRelease');
4661        const save = document.getElementById('billSave');
4662        if (rel) {
4663            rel.disabled = !frei || prop.status !== 'entwurf';
4664            rel.title = frei ? '' : t('Es liegen noch sperrende Hinweise vor.');
4665            rel.onclick = () => this._billFreigabe();
4666        }
4667        if (save) {
4668            save.disabled = prop.status !== 'entwurf';
4669            save.onclick = () => this._billSpeichern();
4670        }
4671        // Begruendungsfeld sofort einblenden, sobald der Faktor den Regelsatz uebersteigt
4672        body.querySelectorAll('.bill-pos').forEach(el => {
4673            const f = el.querySelector('.bill-faktor');
4674            if (f) f.addEventListener('input', () => this._billSpeichern());
4675        });
4676    }
4677    _billSammeln() {
4678        const body = document.getElementById('billBody');
4679        if (!body) return [];
4680        const alt = (this._billProp || {}).positionen || [];
4681        return Array.from(body.querySelectorAll('.bill-pos')).map(el => {
4682            const i = Number(el.dataset.i);
4683            const q = s => el.querySelector(s);
4684            return {
4685                ziffer: (alt[i] || {}).ziffer,
4686                anzahl: Number(q('.bill-anzahl') ? q('.bill-anzahl').value : 1) || 1,
4687                faktor: q('.bill-faktor') ? q('.bill-faktor').value : undefined,
4688                begruendung: q('.bill-begr') ? q('.bill-begr').value : '',
4689                konfidenz: (alt[i] || {}).konfidenz,
4690                nichtBelegt: (alt[i] || {}).nichtBelegt,
4691                begruendungVorschlag: (alt[i] || {}).begruendungVorschlag
4692            };
4693        });
4694    }
4695    async _billSpeichern() {
4696        const prop = this._billProp || {};
4697        if (!prop.id) return;
4698        try {
4699            const r = await this._recFetch(`/api/billing/proposals/${prop.id}`, {
4700                method: 'PUT', headers: { 'Content-Type': 'application/json' },
4701                body: JSON.stringify({ positionen: this._billSammeln() })
4702            });
4703            const j = await r.json().catch(() => ({}));
4704            if (!r.ok) { UI.showError(j.error || t('Die Aenderung konnte nicht geprueft werden')); return; }
4705            this._billProp = j.proposal || prop;
4706            this._billRender();
4707        } catch (e) {
4708            UI.showError(t('Die Aenderung konnte nicht geprueft werden'));
4709        }
4710    }
4711    async _billFreigabe() {
4712        const prop = this._billProp || {};
4713        if (!prop.id) return;
4714        if (!confirm(t('Mit der Freigabe uebernehmen Sie die fachliche Verantwortung fuer diese Abrechnungspositionen. Fortfahren?'))) return;
4715        try {
4716            const r = await this._recFetch(`/api/billing/proposals/${prop.id}/freigabe`,
4717                                           { method: 'POST' });
4718            const j = await r.json().catch(() => ({}));
4719            if (!r.ok) { UI.showError(j.error || t('Die Freigabe war nicht moeglich')); return; }
4720            this._billProp = j.proposal || prop;
4721            this._billRender();
4722            UI.showSuccess(t('Freigegeben. Die Positionen koennen jetzt in eine Rechnung uebernommen werden.'));
4723        } catch (e) {
4724            UI.showError(t('Die Freigabe war nicht moeglich'));
4725        }
4726    }
4727    // --- rrev-ui-v1 (AP 6.2/6.3): Aenderungsverlauf ---------------------------
4728    _rrevZeitText(iso) {
4729        if (!iso) return '';
4730        try {
4731            return new Date(iso).toLocaleString('de-DE', { day: '2-digit', month: '2-digit',
4732                year: 'numeric', hour: '2-digit', minute: '2-digit' });
4733        } catch (e) { return ''; }
4734    }
4735    _rrevWert(v) {
4736        if (v === null || v === undefined || v === '') return `<em class="text-muted">${t('leer')}</em>`;
4737        if (typeof v === 'boolean') return v ? t('ja') : t('nein');
4738        if (typeof v === 'object') return this._recEscape(JSON.stringify(v).slice(0, 300));
4739        return this._recEscape(String(v).slice(0, 300));
4740    }
4741    _rrevEintragHtml(e, entity, entityId, darfRestore) {
4742        const farbe = e.op === 'DELETE' ? '#dc3545' : (e.op === 'INSERT' ? '#198754' : '#0d6efd');
4743        const felder = (e.felder || []).map(f => `
4744            <div class="small ms-3 mt-1">
4745                <span class="text-muted">${this._recEscape(f.label)}:</span>
4746                <span class="text-decoration-line-through text-muted">${this._rrevWert(f.von)}</span>
4747                <i class="fas fa-arrow-right mx-1 text-muted" style="font-size:.7rem;"></i>
4748                <strong>${this._rrevWert(f.auf)}</strong>
4749            </div>`).join('');
4750        const unklar = (e.urheberQuelle && e.urheberQuelle !== 'app')
4751            ? ` <i class="fas fa-circle-question text-muted" title="${t('Der Urheber liess sich nicht sicher zuordnen')}"></i>` : '';
4752        const restore = (darfRestore && e.op === 'UPDATE')
4753            ? `<button class="btn btn-sm btn-link p-0 small rrev-restore" data-rev="${e.id}"
4754                       data-entity="${this._recEscape(entity)}" data-eid="${this._recEscape(String(entityId))}"
4755                       title="${t('Den Stand vor dieser Aenderung wiederherstellen')}">
4756                 <i class="fas fa-rotate-left me-1"></i>${t('Diesen Stand wiederherstellen')}</button>` : '';
4757        return `
4758        <div class="border rounded p-2 mb-2" style="border-left:4px solid ${farbe} !important;">
4759            <div class="d-flex justify-content-between align-items-start">
4760                <div class="small">
4761                    <strong>${this._recEscape(e.urheber || '')}</strong>${unklar}
4762                    <span class="text-muted"> ${this._recEscape(e.opLabel || e.op)}</span>
4763                </div>
4764                <small class="text-muted text-nowrap ms-2">${this._rrevZeitText(e.zeit)}</small>
4765            </div>
4766            ${e.grund ? `<div class="small fst-italic text-muted mt-1">${t('Grund')}: ${this._recEscape(e.grund)}</div>` : ''}
4767            ${felder}
4768            ${restore ? `<div class="mt-2">${restore}</div>` : ''}
4769        </div>`;
4770    }
4771    async _rrevZeigen(entity, entityId) {
4772        let daten;
4773        try {
4774            const resp = await this._recFetch(`/api/history/${entity}/${encodeURIComponent(entityId)}`);
4775            if (!resp.ok) {
4776                UI.showError(resp.status === 403
4777                    ? t('Fuer den Aenderungsverlauf fehlt Ihnen die Berechtigung')
4778                    : t('Der Aenderungsverlauf konnte nicht geladen werden'));
4779                return;
4780            }
4781            daten = await resp.json();
4782        } catch (e) {
4783            UI.showError(t('Der Aenderungsverlauf konnte nicht geladen werden'));
4784            return;
4785        }
4786        const eintraege = daten.eintraege || [];
4787        const darfRestore = !!daten.wiederherstellbar;
4788        const koerper = eintraege.length
4789            ? eintraege.map(e => this._rrevEintragHtml(e, entity, entityId, darfRestore)).join('')
4790            : `<div class="text-muted small text-center py-4">
4791                 ${t('Fuer diesen Eintrag ist noch keine Aenderung protokolliert.')}<br>
4792                 <span style="font-size:.8rem;">${t('Der Verlauf wird ab der Einfuehrung der Versionshistorie gefuehrt.')}</span>
4793               </div>`;
4794        const hinweis = daten.gekappt
4795            ? `<div class="alert alert-warning py-1 px-2 small mb-2">
4796                 ${t('Es werden nur die letzten')} ${daten.gekapptBei} ${t('Aenderungen angezeigt.')}</div>` : '';
4797        const alt = document.getElementById('rrevModal');
4798        if (alt) alt.remove();
4799        const wrap = document.createElement('div');
4800        wrap.innerHTML = `
4801        <div class="modal fade" id="rrevModal" tabindex="-1">
4802          <div class="modal-dialog modal-lg modal-dialog-scrollable">
4803            <div class="modal-content">
4804              <div class="modal-header">
4805                <h5 class="modal-title"><i class="fas fa-clock-rotate-left me-2"></i>${t('Aenderungsverlauf')}
4806                  <small class="text-muted">${this._recEscape(daten.entityLabel || '')}</small></h5>
4807                <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
4808              </div>
4809              <div class="modal-body">${hinweis}${koerper}</div>
4810              <div class="modal-footer">
4811                <small class="text-muted me-auto">${t('Aeltere Staende bleiben dauerhaft erhalten und werden nie ueberschrieben.')}</small>
4812                <button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">${t('Schliessen')}</button>
4813              </div>
4814            </div>
4815          </div>
4816        </div>`;
4817        document.body.appendChild(wrap.firstElementChild);
4818        const modalEl = document.getElementById('rrevModal');
4819        modalEl.querySelectorAll('.rrev-restore').forEach(btn => {
4820            btn.addEventListener('click', async () => {
4821                if (!confirm(t('Den Stand vor dieser Aenderung wiederherstellen? Der aktuelle Stand bleibt im Verlauf erhalten.'))) return;
4822                const grund = prompt(t('Grund der Wiederherstellung (optional):')) || '';
4823                btn.disabled = true;
4824                try {
4825                    const r = await this._recFetch(
4826                        `/api/history/${btn.dataset.entity}/${encodeURIComponent(btn.dataset.eid)}/restore/${btn.dataset.rev}`,
4827                        { method: 'POST', headers: { 'Content-Type': 'application/json' },
4828                          body: JSON.stringify({ grund }) });
4829                    const j = await r.json().catch(() => ({}));
4830                    if (!r.ok) { UI.showError(j.error || t('Wiederherstellen fehlgeschlagen')); btn.disabled = false; return; }
4831                    UI.showSuccess(t('Stand wiederhergestellt') + (j.wiederhergestellt ? ': ' + j.wiederhergestellt.join(', ') : ''));
4832                    bootstrap.Modal.getInstance(modalEl)?.hide();
4833                    this.loadPatientRecords();
4834                } catch (e) { UI.showError(t('Wiederherstellen fehlgeschlagen')); btn.disabled = false; }
4835            });
4836        });
4837        new bootstrap.Modal(modalEl).show();
4838    }
4839    _tlWire(list) {
4840        if (!list.dataset.tlWired) {
4841            list.dataset.tlWired = '1';
4842            list.addEventListener('click', async (ev) => {
4843                const mbtn = ev.target.closest('.tl-menu-btn');
4844                if (mbtn) {
4845                    ev.stopPropagation();
4846                    const menu = list.querySelector(`.tl-menu[data-menu="${mbtn.dataset.recid}"]`);
4847                    const open = menu && menu.style.display !== 'none';
4848                    list.querySelectorAll('.tl-menu').forEach(m => m.style.display = 'none');
4849                    if (menu && !open) menu.style.display = 'block';
4850                    return;
4851                }
4852                const act = ev.target.closest('[data-tlact]');
4853                if (act) {
4854                    ev.stopPropagation();
4855                    list.querySelectorAll('.tl-menu').forEach(m => m.style.display = 'none');
4856                    await this._tlAction(act.dataset.tlact, act.dataset.recid);
4857                }
4858            });
4859            document.addEventListener('click', () => list.querySelectorAll('.tl-menu').forEach(m => m.style.display = 'none'));
4860        }
4861        this._tlLoadThumbs(list);
4862    }
4863    async _tlAction(act, recId) {
4864        if (act === 'hist') { this._rrevZeigen('medical_records', recId); return; }   // rrev-ui-v1
4865        if (act === 'bill') { this._billZeigen(recId); return; }
4865   // billing-ui-v1
4866        if (act === 'del') { this.deletePatientRecord(recId); return; }
4867        if (act === 'print') { this._s18Pdf(this.contactId, `behandlungsverlauf?record=${recId}`); return; }
4868        if (act === 'dup') {
4869            try {
4870                const r = await this._recFetch(`/api/medical-records/${recId}/duplicate`, { method: 'POST' });
4871                if (!r.ok) throw new Error(`HTTP ${r.status}`);
4872                UI.showSuccess(t('Eintrag dupliziert'));
4873                this.loadPatientRecords();
4874            } catch (e) { UI.showError(t('Duplizieren fehlgeschlagen')); }
4875            return;
4876        }
4877        if (act === 'tpl') {
4878            const name = prompt(t('Name der Vorlage:'));
4879            if (!name || !name.trim()) return;
4880            try {
4881                const r = await this._recFetch('/api/physio/record-templates', {
4882                    method: 'POST', body: JSON.stringify({ recordId: recId, name: name.trim() }) });
4883                if (!r.ok) throw new Error(`HTTP ${r.status}`);
4884                UI.showSuccess(t('Vorlage gespeichert - ab jetzt im Formular waehlbar'));
4885                this._tplLoad();
4886            } catch (e) { UI.showError(t('Vorlage speichern fehlgeschlagen')); }
4887            return;
4888        }
4889        if (act === 'date') {
4890            const inp = prompt(t('Neues Datum (TT.MM.JJJJ oder TT.MM.JJJJ HH:MM):'));
4891            if (!inp) return;
4892            const m = inp.trim().match(/^(\d{1,2})\.(\d{1,2})\.(\d{4})(?:\s+(\d{1,2}):(\d{2}))?$/);
4893            if (!m) { UI.showError(t('Format nicht erkannt (TT.MM.JJJJ)')); return; }
4894            const iso = `${m[3]}-${m[2].padStart(2, '0')}-${m[1].padStart(2, '0')}T${(m[4] || '12').padStart(2, '0')}:${m[5] || '00'}:00`;
4895            try {
4896                const r = await this._recFetch(`/api/medical-records/${recId}`, {
4897                    method: 'PUT', body: JSON.stringify({ createdAt: iso }) });
4898                if (!r.ok) throw new Error(`HTTP ${r.status}`);
4899                UI.showSuccess(t('Datum geaendert'));
4900                this.loadPatientRecords();
4901            } catch (e) { UI.showError(t('Datum aendern fehlgeschlagen')); }
4902        }
4903    }
4904    async _tlLoadThumbs(list) {
4905        const imgs = Array.from(list.querySelectorAll('img.tl-thumb')).slice(0, 12);
4906        for (const img of imgs) {
4907            try {
4908                const resp = await this._recFetch(`/api/medical-records/${img.dataset.recid}/file`);
4909                if (!resp.ok) continue;
4910                const blob = await resp.blob();
4911                img.src = URL.createObjectURL(blob);
4912                img.style.display = 'inline-block';
4913                img.addEventListener('click', () => this.openPatientRecordFile(img.dataset.recid));
4914            } catch (e) { /* Thumbnail ist optional */ }
4915        }
4916    }
4917    async _tplLoad() {
4918        const sel = document.getElementById('recTplSelect');
4919        if (!sel) return;
4920        try {
4921            const r = await this._recFetch('/api/physio/record-templates');
4922            if (!r.ok) return;
4923            const d = await r.json();
4924            this._tplCache = d.templates || [];
4925            sel.innerHTML = `<option value="">${t('Vorlage anwenden…')}</option>` +
4926                this._tplCache.map(tp => `<option value="${tp.id}">${this._recEscape(tp.name)}</option>`).join('');
4927            if (!sel.dataset.tplWired) {
4928                sel.dataset.tplWired = '1';
4929                sel.addEventListener('change', () => this._tplApply(sel.value));
4930            }
4931        } catch (e) { /* Vorlagen sind optional */ }
4932    }
4933    _tplApply(tplId) {
4934        const tp = (this._tplCache || []).find(x => x.id === tplId);
4935        if (!tp) return;
4936        const typeSel = document.getElementById('recType');
4937        if (typeSel && tp.recordType) { typeSel.value = tp.recordType; typeSel.dispatchEvent(new Event('change')); }
4938        const set = (id, v) => { const f = document.getElementById(id); if (f && v !== undefined && v !== null && v !== '') f.value = v; };
4939        set('recTitle', tp.title);
4940        set('recContent', tp.content);
4941        const sd = tp.structuredData || {};
4942        set('befSchmerz', sd.schmerz_nrs);
4943        set('befLokalisation', sd.lokalisation);
4944        set('befBewegung', sd.bewegungseinschraenkung);
4945        set('befZiel', sd.therapieziel);
4946    }
4947
4948    // ===== Bodychart (S19) =====
4949    _bcTypes() {
4950        return [
4951            { key: 'schmerz', label: t('Schmerz'), color: '#e5383b', sym: '!' },
4952            { key: 'ausstrahlung', label: t('Ausstrahlung'), color: '#d6336c', sym: '~' },
4953            { key: 'verspannung', label: t('Verspannung'), color: '#f08c00', sym: 'V' },
4954            { key: 'trigger', label: t('Triggerpunkt'), color: '#7048e8', sym: 'T' },
4955            { key: 'schwellung', label: t('Schwellung'), color: '#1c7ed6', sym: 'S' },
4956            { key: 'notiz', label: t('Hinweis'), color: '#0ca678', sym: 'i' }
4957        ];
4958    }
4959    _bcViewDefs() {
4960        return [
4961            { key: 'front', label: t('Vorne') },
4962            { key: 'back', label: t('Hinten') },
4963            { key: 'left', label: t('Links') },
4964            { key: 'right', label: t('Rechts') }
4965        ];
4966    }
4967
4968    // Anatomische Silhouetten, ViewBox 0 0 100 220 (7,5-Kopf-Proportion).
4969    // Die Vorderansicht ist ein einziger gespiegelt-geschlossener Pfad, damit
4970    // in der Koerpermitte keine Naht sichtbar wird.
4971    _bcPaths() {
4972        return {
4973            torso: 'M50,27 L45.2,27.6 C45,33.4,44.4,38.6,41.4,40.8 C38.2,42.2,34.6,43.2,32.4,45.8 C30.2,49.4,31.6,57,34.4,63.8 C35.8,71.6,37,80,37.6,88 C37.6,94.4,36.6,99.6,35.4,105 C34,110.6,32.8,116,33,121.6 C33.4,132,34,143,34.6,153.4 C35,164,35.4,176,36,187 C36.2,192.4,36,196,36.4,199.4 C34.6,202.4,33,205.4,33.6,208.8 C34,211.4,36.2,212.2,39,211.6 C42.6,211,44.8,209.8,45.6,206.8 C46,202.6,45.
49736,198.6,45.4,194.6 C45.2,184,44.8,171.6,44.6,159.6 C44.4,148.6,44.8,136.4,45.6,125.4 C46.2,121,47.2,118.6,48.2,117.4 L50,117.4 L51.8,117.4 C52.8,118.6,53.8,121,54.4,125.4 C55.2,136.4,55.6,148.6,55.4,159.6 C55.2,171.6,54.8,184,54.6,194.6 C54.4,198.6,54,202.6,54.4,206.8 C55.2,209.8,57.4,211,61,211.6 C63.8,212.2,66,211.4,66.4,208.8 C67,205.4,65.4,202.4,63.6,199.4 C64,196,63.8,192.4,64,187 C64.6,176,65,164,65.4,153.4 C66,143,66.6,132,67,121.6 C67.2,116,66,110.6,64.6,105 C63.4,99.6,62.4,94.4,62.4,88 C63,80,64.2,71.6,65.6,63.8 C68.4,57,69.8,49.4,67.6,45.8 C65.4,43.2,61.8,42.2,58.6,40.8 C55.6,38.6,55,33.4,54.8,27.6 L50,27 Z',
4974            head: 'M50,4.4 C56.2,4.4 60,9.6 60,17.4 C60,23.6 58.2,28.2 55,30.4 C54.8,34.6 55,38.6 55.4,43 L44.6,43 C45,38.6 45.2,34.6 45,30.4 C41.8,28.2 40,23.6 40,17.4 C40,9.6 43.8,4.4 50,4.4 Z',
4975            arm: 'M35,41.8 C29.8,44 25.6,48.4 23.8,55.4 C22.4,63.6 22,71.6 22.2,79.6 C22.6,89.6 23.2,99.6 23.8,108.2 C24.2,114 24,119 24.8,124.2 C25.4,129.4 27.2,132.8 29.2,131.8 C30.8,130.8 31,127.2 30.8,122.8 C30.6,116.8 30.4,110.6 30.2,104.6 C30,96.6 29.6,88.4 29.8,80.4 C30,72.6 30.6,65.6 32.2,60.6 C33.2,55.6 34.6,48.2 35.2,42.4 Z',
4976            side: 'M50,4.4 C56.4,4.4 60.6,9.8 60.6,17.6 C60.6,23.2 58.8,27.6 56.2,30.4 C55.8,32.6 56,34.8 56.8,36.4 C59.8,39.8 62.6,43.8 63.4,49.6 C64.4,57.6 64.8,66.6 64.4,75.6 C64,84 63,89.6 62.4,96.4 C61.8,102.4 62.6,107.4 63.2,112.4 C64,118.6 62.8,124.4 60.8,128.8 C59,134.4 58,141.6 57.6,149.4 C58.4,157 59,164.6 57.8,174.4 C56.8,183.6 56.2,190.4 56.2,197.4 C56.2,201.6 56.4,205.6 55.8,208.6 C54,211.2 50.4,212.2 45.4,211.6 C40.6,211.2 36.6,210.4 35,208.4 C33.8,206.4 34.8,203.4 37,202.4 C40.4,201.4 43,200.4 44.6,198.8 C46.4,196.6 46.8,192.6 46.8,187.6 C46.8,177.6 46.6,166 46.8,156 C47.2,148 47.6,140 47.8,132 C48.2,124 47.8,118 46.8,112 C45.8,106 44.8,100.4 44.6,94.4 C44.4,86.4 44.8,78.4 45.2,70.4 C45.6,62.4 46.4,54.4 47.4,48.4 C48,43.6 48.8,38.8 49.2,34.6 C49.4,32.8 49,31.6 48.2,30.8 C46.8,30.2 45.6,29 45.2,27.2 C44.8,25.4 45.4,23.8 44.8,22.6 C44.2,21.4 42.6,21 42.6,19.6 C42.6,18.2 44.2,17.4 44.6,16 C45,14.2 44.8,12.2 45.4,10.6 C46.2,7 47.6,4.4 50,4.4 Z',
4977            sideArm: 'M59.4,43.8 C62,47.8 62.4,54.6 61.8,62.6 C61,72 59.8,81.4 58.8,90.4 C58,98.4 57.2,106 56.6,113.2 C56.2,118.4 56.8,124 55.8,129.2 C55,133.6 51,134.4 49.8,130.4 C49,126 49.6,120.4 50.2,115.2 C51,108 51.8,100.4 52.6,92.8 C53.4,84.4 54,76 53.8,67.8 C53.6,61.4 53.2,54.6 53.4,48.6 C53.6,44.8 55.8,42.6 59.4,43.8 Z',
4978            backDetail: 'M50,46 L50,112 M41,56 C38.6,62 38.4,68.4 40,74 M59,56 C61.4,62 61.6,68.4 60,74',
4979            frontDetail: 'M41.6,49.4 C45,52.2 55,52.2 58.4,49.4'
4980        };
4981    }
4982
4983    // Gradienten/Filter einmal pro SVG; uid trennt die Defs mehrerer SVGs auf der Seite.
4984    _bcDefs(uid) {
4985        return `<defs>
4986            <linearGradient id="bcSkin${uid}" x1="0" y1="0" x2="1" y2="1">
4987                <stop offset="0%" stop-color="#f2f6fa"/><stop offset="55%" stop-color="#e2eaf3"/>
4988                <stop offset="100%" stop-color="#cfdae7"/>
4989            </linearGradient>
4990            <linearGradient id="bcLimb${uid}" x1="0" y1="0" x2="1" y2="0">
4991                <stop offset="0%" stop-color="#e8eef6"/><stop offset="100%" stop-color="#d3dde9"/>
4992            </linearGradient>
4993            <filter id="bcDrop${uid}" x="-40%" y="-40%" width="180%" height="180%">
4994                <feDropShadow dx="0" dy="0.7" stdDeviation="0.7" flood-color="#1b2b3d" flood-opacity="0.35"/>
4995            </filter>
4996        </defs>`;
4997    }
4998
4999    _bcBodyShape(view, uid) {
5000        const p = this._bcPaths();
5001        const u = uid || '';
5002        const skin = `fill="url(#bcSkin${u})"`;
5003        const limb = `fill="url(#bcLimb${u})"`;
5004        const st = 'stroke="#8fa3ba" stroke-width="0.8" stroke-linejoin="round"';
5005        const hair = '<path d="' + p.backDetail + '" fill="none" stroke="#8fa3ba" stroke-width="0.6" opacity="0.55" stroke-linecap="round"/>';
5006        if (view === 'left' || view === 'right') {
5007            const g = `<path d="${p.side}" ${skin} ${st}/><path d="${p.sideArm}" ${limb} ${st}/>`;
5008            return view === 'right' ? `<g transform="translate(100,0) scale(-1,1)">${g}</g>` : g;
5009        }
5010        const arms = `<path d="${p.arm}" ${limb} ${st}/>` +
5011            `<g transform="translate(100,0) scale(-1,1)"><path d="${p.arm}" ${limb} ${st}/></g>`;
5012        const detail = view === 'back' ? hair
5013            : '<path d="' + p.frontDetail + '" fill="none" stroke="#8fa3ba" stroke-width="0.6" opacity="0.45" stroke-linecap="round"/>';
5014        return `<path d="${p.head}" ${skin} ${st}/>${arms}<path d="${p.torso}" ${skin} ${st}/>${detail}`;
5015    }
5016
5017    _bcItemsSvg(items, view, interactive, uid) {
5018        const types = Object.fromEntries(this._bcTypes().map(tp => [tp.key, tp]));
5019        const u = uid || '';
5020        let out = '';
5021        (items || []).forEach((it, i) => {
5022            if (!it || it.view !== view) return;
5023            const tp = types[it.type] || { color: '#6c757d', sym: '?', label: '' };
5024            const act = interactive ? `class="bc-item" data-bci="${i}"` : '';
5025            if (it.kind === 'path') {
5026                const d = String(it.d || '');
5027                if (!/^[ML0-9.,\s-]+$/.test(d)) return;
5028                out += `<g ${act}><path d="${d}" fill="none" stroke="${tp.color}" stroke-width="4.6" stroke-linecap="round" stroke-linejoin="round" opacity="0.22"/>` +
5029                    `<path d="${d}" fill="none" stroke="${tp.color}" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/>` +
5030                    `<title>${this.esc(tp.label)}</title></g>`;
5031            } else {
5032                const x = Number(it.x), y = Number(it.y);
5033                if (!isFinite(x) || !isFinite(y)) return;
5034                out += `<g ${act}>` +
5035                    `<circle cx="${x}" cy="${y}" r="8.4" fill="${tp.color}" opacity="0.16"/>` +
5036                    `<circle cx="${x}" cy="${y}" r="5.4" fill="${tp.color}" stroke="#fff" stroke-width="1.5" filter="url(#bcDrop${u})"/>` +
5037                    `<text x="${x}" y="${y + 2.3}" text-anchor="middle" font-size="6.6" font-weight="700" fill="#fff" style="pointer-events:none;">${tp.sym}</text>` +
5038                    `<title>${this.esc(tp.label)}</title></g>`;
5039            }
5040        });
5041        return out;
5042    }
5043
5044    // Styles einmalig injizieren (kein globales CSS-File noetig)
5045    _bcStyles() {
5046        if (document.getElementById('bcStyles')) return;
5047        const s = document.createElement('style');
5048        s.id = 'bcStyles';
5049        s.textContent = `
5050        .bc-panel{border:1px solid #dde3ea;border-radius:12px;padding:12px 14px;
5051            background:linear-gradient(180deg,#fbfcfe 0%,#f4f7fa 100%);}
5052        .bc-head{display:flex;flex-wrap:wrap;align-items:baseline;gap:8px;margin-bottom:10px;}
5053        .bc-title{font-weight:600;font-size:.9rem;color:#2c3e50;}
5054        .bc-hint{font-size:.75rem;color:#8a97a6;}
5055        .bc-toolbar{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:12px;}
5056        .bc-tool{display:inline-flex;align-items:center;gap:6px;border:1px solid #dbe1e8;
5057            background:#fff;border-radius:999px;padding:4px 12px;font-size:.78rem;color:#48566a;
5058            cursor:pointer;transition:all .12s ease;}
5059        .bc-tool:hover{border-color:#b8c3d0;transform:translateY(-1px);}
5060        .bc-tool .bc-dot{width:9px;height:9px;border-radius:50%;flex:0 0 auto;}
5061        .bc-tool.on{color:#fff;border-color:transparent;box-shadow:0 2px 6px rgba(20,40,70,.18);}
5062        .bc-tool.on .bc-dot{background:#fff !important;}
5063        .bc-views{display:flex;flex-wrap:wrap;gap:10px;justify-content:center;margin-bottom:10px;}
5064        .bc-card{background:#fff;border:1px solid #e4e9ef;border-radius:10px;padding:6px 4px 4px;
5065            transition:border-color .15s ease,box-shadow .15s ease;}
5066        .bc-card:hover{border-color:#c3d0de;box-shadow:0 3px 10px rgba(20,40,70,.07);}
5067        .bc-card.bc-has{border-color:#b9c8da;}
5068        .bc-view{display:block;touch-action:none;cursor:crosshair;}
5069        .bc-label{text-align:center;font-size:.72rem;color:#7a8794;margin-top:2px;letter-spacing:.02em;}
5070        .bc-card.bc-has .bc-label{color:#3b5068;font-weight:600;}
5071        .bc-item{cursor:pointer;transition:opacity .12s ease;}
5072        .bc-item:hover{opacity:.45;}
5073        .bc-foot{display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap;}
5074        .bc-count{font-size:.75rem;color:#8a97a6;}
5075        .bc-legend{display:flex;flex-wrap:wrap;gap:10px;margin-top:6px;}
5076        .bc-legend span{display:inline-flex;align-items:center;gap:5px;font-size:.72rem;color:#5b6b7d;}
5077        .bc-legend i{width:8px;height:8px;border-radius:50%;display:inline-block;}
5078        @media (max-width:576px){.bc-views{gap:6px;}}
5079        `;
5080        document.head.appendChild(s);
5081    }
5082    _bcInit() {
5083        const row = document.getElementById('recBodychartRow');
5084        const views = document.getElementById('bcViews');
5085        if (!row || !views) return;
5086        if (!this._bc) this._bc = { tool: 'schmerz', draw: false, items: [], cur: null };
5087        this._bcStyles();
5088        if (!views.childElementCount) {
5089            views.innerHTML = this._bcViewDefs().map(v => `
5090                <div class="bc-card" data-card="${v.key}">
5091                    <svg class="bc-view" data-view="${v.key}" viewBox="0 0 100 220"
5092                         style="height:238px;width:108px;">
5093                        ${this._bcDefs(v.key)}${this._bcBodyShape(v.key, v.key)}<g class="bc-overlay"></g>
5094                    </svg>
5095                    <div class="bc-label">${v.label}</div>
5096                </div>`).join('');
5097        }
5098        const tb = document.getElementById('bcToolbar');
5099        if (tb && !tb.childElementCount) {
5100            tb.innerHTML = this._bcTypes().map(tp =>
5101                `<button type="button" class="bc-tool" data-tool="${tp.key}">
5102                    <span class="bc-dot" style="background:${tp.color};"></span>${tp.label}</button>`).join('') +
5103                `<button type="button" class="bc-tool bc-draw" title="${t('Freihand zeichnen statt Marker setzen')}">
5104                    <i class="fas fa-pen"></i>${t('Zeichnen')}</button>`;
5105        }
5106        if (row.dataset.bcWired) { this._bcTb(); this._bcPaint(); return; }
5107        row.dataset.bcWired = '1';
5108        row.addEventListener('click', (ev) => {
5109            // Zeichnen-Knopf zuerst pruefen: er traegt ebenfalls .bc-tool (Pill-Optik),
5110            // hat aber kein data-tool und wuerde sonst als Werkzeug durchgehen.
5111            if (ev.target.closest('.bc-draw')) { this._bc.draw = !this._bc.draw; this._bcTb(); return; }
5112            const tool = ev.target.closest('.bc-tool[data-tool]');
5113            if (tool) { this._bc.tool = tool.dataset.tool; this._bcTb(); }
5114        });
5115        const undo = document.getElementById('bcUndoBtn');
5116        const clear = document.getElementById('bcClearBtn');
5117        if (undo) undo.addEventListener('click', () => { this._bc.items.pop(); this._bcPaint(); });
5118        if (clear) clear.addEventListener('click', () => { this._bc.items = []; this._bcPaint(); });
5119        views.querySelectorAll('svg.bc-view').forEach(svg => {
5120            const view = svg.dataset.view;
5121            svg.addEventListener('pointerdown', (ev) => {
5122                ev.preventDefault();
5123                const hit = ev.target.closest('[data-bci]');
5124                if (hit) { this._bc.items.splice(Number(hit.dataset.bci), 1); this._bcPaint(); return; }
5125                const p = this._bcPt(svg, ev);
5126                if (this._bc.draw) {
5127                    this._bc.cur = { kind: 'path', view: view, type: this._bc.tool, d: `M${p.x},${p.y}` };
5128                    try { svg.setPointerCapture(ev.pointerId); } catch (e) { /* optional */ }
5129                } else {
5130                    this._bc.items.push({ kind: 'marker', view: view, type: this._bc.tool, x: p.x, y: p.y });
5131                    this._bcPaint();
5132                }
5133            });
5134            svg.addEventListener('pointermove', (ev) => {
5135                if (!this._bc.cur || this._bc.cur.view !== view) return;
5136                const p = this._bcPt(svg, ev);
5137                this._bc.cur.d += ` L${p.x},${p.y}`;
5138                svg.querySelector('.bc-overlay').innerHTML =
5139                    this._bcItemsSvg(this._bc.items, view, false, view) + this._bcItemsSvg([this._bc.cur], view, false, view);
5140            });
5141            const fin = (ev) => {
5142                if (!this._bc.cur || this._bc.cur.view !== view) return;
5143                if (this._bc.cur.d.includes('L')) this._bc.items.push(this._bc.cur);
5144                this._bc.cur = null;
5145                if (ev) { try { svg.releasePointerCapture(ev.pointerId); } catch (e) { /* optional */ } }
5146                this._bcPaint();
5147            };
5148            svg.addEventListener('pointerup', fin);
5149            svg.addEventListener('pointercancel', fin);
5150        });
5151        this._bcTb();
5152        this._bcPaint();
5153    }
5154    _bcTb() {
5155        document.querySelectorAll('#bcToolbar .bc-tool[data-tool]').forEach(b => {
5156            const tp = this._bcTypes().find(x => x.key === b.dataset.tool);
5157            if (!tp) return;
5158            const on = this._bc.tool === b.dataset.tool;
5159            b.classList.toggle('on', on);
5160            b.style.background = on ? tp.color : '';
5161            b.style.color = on ? '#fff' : '';
5162        });
5163        const d = document.querySelector('#bcToolbar .bc-draw');
5164        if (d) {
5165            d.classList.toggle('on', !!this._bc.draw);
5166            d.style.background = this._bc.draw ? '#48566a' : '';
5167            d.style.color = this._bc.draw ? '#fff' : '';
5168        }
5169        const views = document.getElementById('bcViews');
5170        if (views) views.querySelectorAll('svg.bc-view').forEach(s => {
5171            s.style.cursor = this._bc.draw ? 'crosshair' : 'copy';
5172        });
5173    }
5174    _bcPt(svg, ev) {
5175        const r = svg.getBoundingClientRect();
5176        const cl = (v, max) => Math.max(0, Math.min(max, v));
5177        return { x: cl(Math.round((ev.clientX - r.left) / r.width * 1000) / 10, 100),
5178                 y: cl(Math.round((ev.clientY - r.top) / r.height * 2200) / 10, 220) };
5179    }
5180    _bcPaint() {
5181        const counts = {};
5182        (this._bc ? this._bc.items : []).forEach(i => { counts[i.view] = (counts[i.view] || 0) + 1; });
5183        document.querySelectorAll('#bcViews svg.bc-view').forEach(svg => {
5184            const v = svg.dataset.view;
5185            svg.querySelector('.bc-overlay').innerHTML = this._bcItemsSvg(this._bc.items, v, true, v);
5186            const card = svg.closest('.bc-card');
5187            if (card) card.classList.toggle('bc-has', !!counts[v]);
5188        });
5189        const c = document.getElementById('bcCount');
5190        if (c) {
5191            const n = this._bc ? this._bc.items.length : 0;
5192            c.textContent = n ? `${n} ${n === 1 ? t('Markierung') : t('Markierungen')}` : t('Noch keine Markierung gesetzt');
5193        }
5194    }
5195    _bcExport() {
5196        if (!this._bc || !this._bc.items.length) return null;
5197        return {
5198            markers: this._bc.items.filter(i => i.kind === 'marker').map(i => ({ view: i.view, type: i.type, x: i.x, y: i.y })),
5199            paths: this._bc.items.filter(i => i.kind === 'path').map(i => ({ view: i.view, type: i.type, d: i.d }))
5200        };
5201    }
5202    _bcSummary() {
5203        if (!this._bc || !this._bc.items.length) return '';
5204        const vl = Object.fromEntries(this._bcViewDefs().map(v => [v.key, v.label]));
5205        const tl = Object.fromEntries(this._bcTypes().map(tp => [tp.key, tp.label]));
5206        const cnt = {};
5207        this._bc.items.forEach(i => {
5208            const k = `${tl[i.type] || i.type} (${vl[i.view] || i.view})`;
5209            cnt[k] = (cnt[k] || 0) + 1;
5210        });
5211        return 'Bodychart: ' + Object.entries(cnt).map(([k, n]) => `${n}× ${k}`).join(', ');
5212    }
5213    _bcReset() {
5214        if (this._bc) { this._bc.items = []; this._bc.cur = null; }
5215        this._bcPaint();
5216    }
5217    _renderBodychartView(sd) {
5218        const bc = sd && sd.bodychart;
5219        if (!bc) return '';
5220        const items = []
5221            .concat((Array.isArray(bc.markers) ? bc.markers : []).map(m => ({ kind: 'marker', ...m })))
5222            .concat((Array.isArray(bc.paths) ? bc.paths : []).map(p => ({ kind: 'path', ...p })));
5223        if (!items.length) return '';
5224        this._bcStyles();
5225        const used = new Set(items.map(i => i.type));
5226        const legend = this._bcTypes().filter(tp => used.has(tp.key)).map(tp =>
5227            `<span><i style="background:${tp.color};"></i>${tp.label}</span>`).join('');
5228        const views = this._bcViewDefs().filter(v => items.some(i => i.view === v.key));
5229        // eigener uid-Praefix je Aufruf, damit die Defs nicht mit dem Editor-SVG
5230        // oder mit weiteren Bodycharts derselben Akte kollidieren
5231        this._bcRoUid = (this._bcRoUid || 0) + 1;
5232        const seq = this._bcRoUid;
5233        const uid = v => 'r' + seq + v;
5234        return `<div class="mt-1"><div class="bc-views" style="justify-content:flex-start;">` + views.map(v => `
5235            <div class="bc-card bc-has">
5236                <svg viewBox="0 0 100 220" style="height:164px;width:76px;display:block;">
5237                    ${this._bcDefs(uid(v.key))}${this._bcBodyShape(v.key, uid(v.key))}${this._bcItemsSvg(items, v.key, false, uid(v.key))}
5238                </svg>
5239                <div class="bc-label">${v.label}</div>
5240            </div>`).join('') + `</div><div class="bc-legend">${legend}</div></div>`;
5241    }
5242
5243    _renderSchmerzChart(records) {
5244        // Akte v2 W4: Schmerzverlauf (NRS 0-10) ueber Zeit als Mini-Chart (inline SVG, keine Lib)
5245        try {
5246            const pts = (records || [])
5247                .filter(r => r.recordType === 'befund' && r.structuredData
5248                    && r.structuredData.schmerz_nrs !== undefined && r.structuredData.schmerz_nrs !== '')
5249                .map(r => ({ v: Math.max(0, Math.min(10, parseInt(r.structuredData.schmerz_nrs, 10))),
5250                             d: r.createdAt ? new Date(r.createdAt) : null }))
5251                .filter(p => !isNaN(p.v) && p.d && !isNaN(p.d.getTime()))
5252                .sort((a, b) => a.d - b.d);
5253            if (pts.length < 2) return '';
5254            const W = 320, H = 64, PAD = 8;
5255            const x = i => PAD + i * ((W - 2 * PAD) / (pts.length - 1));
5256            const y = v => (H - PAD) - (v / 10) * (H - 2 * PAD);
5257            const poly = pts.map((p, i) => `${x(i).toFixed(1)},${y(p.v).toFixed(1)}`).join(' ');
5258            const dots = pts.map((p, i) => {
5259                const col = p.v >= 7 ? '#dc3545' : (p.v >= 4 ? '#ffc107' : '#198754');
5260                return `<circle cx="${x(i).toFixed(1)}" cy="${y(p.v).toFixed(1)}" r="3" fill="${col}">`
5261                    + `<title>${p.d.toLocaleDateString('de-DE')}: ${p.v}/10</title></circle>`;
5262            }).join('');
5263            const first = pts[0], last = pts[pts.length - 1];
5264            const trend = last.v < first.v
5265                ? `<span class="text-success"><i class="fas fa-arrow-down me-1"></i>${first.v} &rarr; ${last.v}</span>`
5266                : (last.v > first.v
5267                    ? `<span class="text-danger"><i class="fas fa-arrow-up me-1"></i>${first.v} &rarr; ${last.v}</span>`
5268                    : `<span class="text-muted">${first.v} &rarr; ${last.v}</span>`);
5269            return `
5270                <div class="border rounded p-2 mb-2 bg-light">
5271                    <div class="d-flex justify-content-between align-items-center">
5272                        <strong class="small"><i class="fas fa-chart-line me-1"></i>${t('Schmerzverlauf (NRS)')}</strong>
5273                        <small>${trend}</small>
5274                    </div>
5275                    <svg viewBox="0 0 ${W} ${H}" style="width:100%;height:${H}px;
5275display:block;" preserveAspectRatio="none">
5276                        <line x1="${PAD}" y1="${y(0)}" x2="${W - PAD}" y2="${y(0)}" stroke="#dee2e6" stroke-width="1"/>
5277                        <line x1="${PAD}" y1="${y(5)}" x2="${W - PAD}" y2="${y(5)}" stroke="#f1f3f5" stroke-width="1"/>
5278                        <line x1="${PAD}" y1="${y(10)}" x2="${W - PAD}" y2="${y(10)}" stroke="#f1f3f5" stroke-width="1"/>
5279                        <polyline points="${poly}" fill="none" stroke="#0d6efd" stroke-width="2"/>
5280                        ${dots}
5281                    </svg>
5282                    <div class="d-flex justify-content-between">
5283                        <small class="text-muted">${first.d.toLocaleDateString('de-DE')}</small>
5284                        <small class="text-muted">${last.d.toLocaleDateString('de-DE')}</small>
5285                    </div>
5286                </div>`;
5287        } catch (e) { return ''; }
5288    }
5289
5290    _renderStructuredBefund(sd) {
5291        // Strukturierte Befundfelder (schmerz_nrs, lokalisation, ...) als Chips
5292        if (!sd || typeof sd !== 'object') return '';
5293        const chips = [];
5294        if (sd.schmerz_nrs !== undefined && sd.schmerz_nrs !== '') {
5295            const n = parseInt(sd.schmerz_nrs, 10);
5296            const cls = n >= 7 ? 'bg-danger' : (n >= 4 ? 'bg-warning text-dark' : 'bg-success');
5297            chips.push(`<span class="badge ${cls}">${t('Schmerz')}: ${this._recEscape(String(sd.schmerz_nrs))}/10</span>`);
5298        }
5299        if (sd.lokalisation) chips.push(`<span class="badge bg-light text-dark border">${this._recEscape(sd.lokalisation)}</span>`);
5300        if (sd.bewegungseinschraenkung) chips.push(`<span class="badge bg-light text-dark border"><i class="fas fa-arrows-alt me-1"></i>${this._recEscape(sd.bewegungseinschraenkung)}</span>`);
5301        if (sd.therapieziel) chips.push(`<span class="badge bg-light text-dark border"><i class="fas fa-bullseye me-1"></i>${this._recEscape(sd.therapieziel)}</span>`);
5302        return chips.length ? `<div class="mt-1 d-flex flex-wrap gap-1">${chips.join('')}</div>` : '';
5303    }
5304
5305    // ==================== KI-FIRST: DIKTAT -> BEFUND-ENTWURF ====================
5306    _setKiStatus(msg, cls) {
5307        const el = document.getElementById('recKiStatus');
5308        if (el) { el.textContent = msg || ''; el.className = 'small ' + (cls || 'text-muted'); }
5309    }
5310
5311    async _toggleDiktat() {
5312        const btn = document.getElementById('recDiktatBtn');
5313        if (this._diktatRec && this._diktatRec.state === 'recording') {
5314            this._diktatRec.stop();
5315            return;
5316        }
5317        try {
5318            const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
5319            const mime = MediaRecorder.isTypeSupported('audio/webm') ? 'audio/webm' : '';
5320            const rec = new MediaRecorder(stream, mime ? { mimeType: mime } : undefined);
5321            const chunks = [];
5322            rec.ondataavailable = (e) => { if (e.data && e.data.size) chunks.push(e.data); };
5323            rec.onstop = async () => {
5324                stream.getTracks().forEach(tr => tr.stop());
5325                if (btn) btn.innerHTML = `<i class="fas fa-microphone me-1"></i>${t('Diktat')}`;
5326                const blob = new Blob(chunks, { type: rec.mimeType || 'audio/webm' });
5327                if (blob.size < 1000) { this._setKiStatus(t('Aufnahme zu kurz'), 'text-danger'); return; }
5328                await this._extractBefund({ audio: blob });
5329            };
5330            rec.start();
5331            this._diktatRec = rec;
5332            if (btn) btn.innerHTML = `<i class="fas fa-stop me-1"></i>${t('Stopp')}`;
5333            this._setKiStatus(t('Aufnahme laeuft... nochmal klicken zum Beenden'), 'text-danger');
5334        } catch (e) {
5335            console.error('Diktat mic failed:', e);
5336            this._setKiStatus(t('Mikrofon nicht verfuegbar'), 'text-danger');
5337        }
5338    }
5339
5340    async _kiStructureText() {
5341        const text = (document.getElementById('recContent')?.value || '').trim();
5342        if (text.length < 10) { this._setKiStatus(t('Bitte erst Text eingeben'), 'text-danger'); return; }
5343        await this._extractBefund({ text });
5344    }
5345
5346    // ============ soap-docu-ui-v1: SOAP + Kontext (AP 5.2 / 5.3) ============
5347    _soapSammeln() {
5348        const out = {};
5349        let leer = true;
5350        ['subjektiv', 'objektiv', 'beurteilung', 'plan'].forEach(f => {
5351            const el = document.querySelector('[data-soap="' + f + '"]');
5352            const v = (el && el.value || '').trim();
5353            out[f] = v;
5354            if (v) leer = false;
5355        });
5356        return leer ? null : out;
5357    }
5358
5359    _soapCount() {
5360        const s = this._soapSammeln();
5361        const n = s ? ['subjektiv', 'objektiv', 'beurteilung', 'plan']
5362            .filter(f => s[f]).length : 0;
5363        const el = document.getElementById('recSoapCount');
5364        if (el) el.textContent = n ? n + '/4 ' + t('Felder gefuellt') : '';
5365        return n;
5366    }
5367
5368    _soapSetzen(soap) {
5369        if (!soap) return;
5370        // p5fix-v1: Ein Feld, zu dem das NEUE Diktat nichts hergibt, muss geleert
5371        // werden. Vorher blieb der Text des vorigen Diktats stehen - diktiert man
5372        // nacheinander zwei Patienten oder zwei Koerperregionen, haette der
5373        // Therapeut eine FREMDE Einschaetzung freigegeben, ohne es zu merken.
5374        ['subjektiv', 'objektiv', 'beurteilung', 'plan'].forEach(f => {
5375            const el = document.querySelector('[data-soap="' + f + '"]');
5376            if (el) el.value = soap[f] || '';
5377        });
5378        const row = document.getElementById('recSoapRow');
5379        if (row) row.style.display = 'block';
5380        // Freigabe-Schritt: nur wenn der Inhalt von der KI stammt
5381        const vonKi = soap.quelle && String(soap.quelle).startsWith('ki_');
5382        const badge = document.getElementById('recSoapKiBadge');
5383        if (badge) badge.style.display = vonKi ? 'inline-block' : 'none';
5384        const wrap = document.getElementById('recSoapFreigabeWrap');
5385        if (wrap) wrap.style.display = vonKi ? 'block' : 'none';
5386        const cb = document.getElementById('recSoapFreigabe');
5387        if (cb && vonKi) cb.checked = false;
5388        this._soapKiQuelle = vonKi ? soap.quelle : null;
5389        this._soapCount();
5390    }
5391
5392    _soapReset() {
5393        ['subjektiv', 'objektiv', 'beurteilung', 'plan'].forEach(f => {
5394            const el = document.querySelector('[data-soap="' + f + '"]');
5395            if (el) el.value = '';
5396        });
5397        const badge = document.getElementById('recSoapKiBadge');
5398        if (badge) badge.style.display = 'none';
5399        const wrap = document.getElementById('recSoapFreigabeWrap');
5400        if (wrap) wrap.style.display = 'none';
5401        const cb = document.getElementById('recSoapFreigabe');
5402        if (cb) cb.checked = false;
5403        this._soapKiQuelle = null;
5404        const kr = document.getElementById('recKontextRow');
5405        if (kr) { kr.innerHTML = ''; kr.style.display = 'none'; }
5406        this._soapCount();
5407    }
5408
5409    _kontextZeigen(kontext) {
5410        const host = document.getElementById('recKontextRow');
5411        if (!host) return;
5412        const vs = (kontext && kontext.vorschlaege) || [];
5413        if (!vs.length) { host.innerHTML = ''; host.style.display = 'none'; return; }
5414        // Hausmethode der Akte statt einer dritten Auslegung derselben Maskierung
5415        const esc = (v) => this._recEscape(v == null ? '' : v);
5416        const ikon = { folgerezept: 'fa-file-prescription', termin: 'fa-calendar-day',
5417                       hausaufgabe: 'fa-dumbbell', doku_luecke: 'fa-circle-exclamation' };
5418        host.innerHTML = '<div class="border rounded p-2 bg-light">'
5419            + '<small class="text-muted d-block mb-1"><i class="fas fa-lightbulb me-1"></i>'
5420            + t('Hinweise aus dem Verlauf') + '</small>'
5421            + vs.map(v => '<div class="d-flex align-items-start mb-1">'
5422                + '<i class="fas ' + (ikon[v.art] || 'fa-circle-info')
5423                + ' me-2 mt-1 ' + (v.dringend ? 'text-danger' : 'text-muted') + '"></i>'
5424                + '<div class="small"><b>' + esc(v.titel) + '</b> — ' + esc(v.text)
5425                + '<span class="text-muted"> (' + esc(v.begruendung) + ')</span></div>'
5426                + '</div>').join('')
5427            + '</div>';
5428        host.style.display = 'block';
5429    }
5430
5431    async _kontextLaden() {
5432        try {
5433            const r = await this._recFetch('/api/physio/soap/vorschlaege', {
5434                method: 'POST',
5435                body: JSON.stringify({ patient_id: this.contactId, soap: this._soapSammeln() })
5436            });
5437            if (!r.ok) return;
5438            this._kontextZeigen(await r.json());
5439        } catch (e) { console.warn('Kontext-Vorschlaege:', e); }
5440    }
5441
5442    async _extractBefund(input) {
5443        this._setKiStatus(t('KI strukturiert...'), 'text-primary');
5444        try {
5445            const idToken = await auth.currentUser.getIdToken();
5446            let resp;
5447            if (input.audio) {
5448                const fd = new FormData();
5449                fd.append('audio', input.audio, 'diktat.webm');
5450                // soap-docu-ui-v1: Patient mitgeben -> Kontext-Vorschlaege (AP 5.3)
5451                if (this.contactId) fd.append('patient_id', this.contactId);
5452                resp = await fetch('/api/physio/befund/extract', {
5453                    method: 'POST', headers: { 'Authorization': `Bearer ${idToken}` }, body: fd
5454                });
5455            } else {
5456                resp = await this._recFetch('/api/physio/befund/extract', {
5457                    method: 'POST',
5458                    body: JSON.stringify({ text: input.text, patient_id: this.contactId })
5459                });
5460            }
5461            if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
5462            const data = await resp.json();
5463            this._applyBefundDraft(data.draft || {});
5464        } catch (e) {
5465            console.error('Befund-Extraktion failed:', e);
5466            this._setKiStatus(t('KI-Strukturierung fehlgeschlagen'), 'text-danger');
5467        }
5468    }
5469
5470    _applyBefundDraft(d) {
5471        const typeSel = document.getElementById('recType');
5472        if (typeSel && d.record_type) {
5473            typeSel.value = d.record_type;
5474            typeSel.dispatchEvent(new Event('change'));
5475        }
5476        const set = (id, val) => { const e = document.getElementById(id); if (e && val !== undefined && val !== null && val !== '') e.value = val; };
5477        set('recTitle', d.title);
5478        set('recContent', d.content);
5479        const sd = d.structured_data || {};
5480        // soap-docu-ui-v1: SOAP-Entwurf uebernehmen + Kontext-Hinweise zeigen
5481        if (sd.soap) this._soapSetzen(sd.soap);
5482        if (d.kontext) this._kontextZeigen(d.kontext);
5483        set('befSchmerz', sd.schmerz_nrs !== null && sd.schmerz_nrs !== undefined ? String(sd.schmerz_nrs) : '');
5484        set('befLokalisation', sd.lokalisation);
5485        set('befBewegung', sd.bewegungseinschraenkung);
5486        set('befZiel', sd.therapieziel);
5487        // S19T2b: KI-erkannte Koerperregionen als Bodychart-Marker-Entwurf uebernehmen
5488        const bcm = Array.isArray(sd.bodychart_markers) ? sd.bodychart_markers : [];
5489        let bcNote = '';
5490        if (bcm.length) {
5491            this._bcInit();
5492            this._bc.items = bcm.map(m => ({ kind: 'marker', view: m.view, type: m.type, x: m.x, y: m.y }));
5493            const bcRow = document.getElementById('recBodychartRow');
5494            if (bcRow) bcRow.style.display = 'block';
5495            this._bcPaint();
5496            bcNote = ` — ${bcm.length} ${t('Bodychart-Marker vorgeschlagen (Klick auf Marker entfernt ihn)')}`;
5497        }
5498        const warn = d.warnung ? ` — ${d.warnung}` : '';
5499        this._setKiStatus(t('KI-Entwurf uebernommen — bitte pruefen und speichern') + bcNote + warn, 'text-success');
5500    }
5501
5502    async savePatientRecord() {
5503        let content = (document.getElementById('recContent')?.value || '').trim();
5504        if (document.getElementById('recType')?.value === 'bodychart' && !content) content = this._bcSummary();
5505        if (!content) { UI.showError(t('Bitte Inhalt eingeben')); return; }
5506        try {
5507            const recordType = document.getElementById('recType').value;
5508            const verordnungId = recordType === 'behandlung'
5509                ? (document.getElementById('recVerordnung')?.value || '') : '';
5510            // Strukturierter Befund (Akte v2 W2): nur befuellte Felder senden
5511            let structuredData = null;
5512            if (recordType === 'befund') {
5513                const sd = {
5514                    schmerz_nrs: document.getElementById('befSchmerz')?.value || '',
5515                    lokalisation: (document.getElementById('befLokalisation')?.value || '').trim(),
5516                    bewegungseinschraenkung: (document.getElementById('befBewegung')?.value || '').trim(),
5517                    therapieziel: (document.getElementById('befZiel')?.value || '').trim(),
5518                };
5519                Object.keys(sd).forEach(k => { if (sd[k] === '') delete sd[k]; });
5520                if (Object.keys(sd).length) structuredData = sd;
5521            }
5522            if (recordType === 'bodychart') {
5523                const bc = this._bcExport();
5524                if (!bc) { UI.showError(t('Bitte mindestens eine Markierung oder Zeichnung setzen')); return; }
5525                structuredData = { bodychart: bc };
5526            } else {
5527                // S19T2b: sichtbares Bodychart (z.B. KI-Diktat-Marker) an jeden Eintragstyp anhaengen
5528                const bcRow = document.getElementById('recBodychartRow');
5529                if (bcRow && bcRow.style.display !== 'none') {
5530                    const bc = this._bcExport();
5531                    if (bc) structuredData = { ...(structuredData || {}), bodychart: bc };
5532                }
5533            }
5534            // soap-docu-ui-v1: SOAP + FREIGABE-SCHRITT (AP 5.2).
5535            // Ein KI-Entwurf darf nicht ohne ausdrueckliche fachliche Uebernahme in
5536            // die Akte - die Akte ist ein Rechtsdokument, und wer sie unterschreibt,
5537            // haftet dafuer. Handgetippte Felder brauchen den Haken nicht.
5538            const soapWerte = this._soapSammeln();
5539            if (soapWerte && this._soapKiQuelle) {
5540                const cb = document.getElementById('recSoapFreigabe');
5541                if (!cb || !cb.checked) {
5542                    UI.showError(t('Bitte den KI-Entwurf pruefen und die Freigabe bestaetigen'));
5543                    const wrap = document.getElementById('recSoapFreigabeWrap');
5544                    if (wrap) wrap.scrollIntoView({ behavior: 'smooth', block: 'center' });
5545                    return;
5546                }
5547                soapWerte.quelle = this._soapKiQuelle;
5548                soapWerte.freigegeben_am = new Date().toISOString();
5549            } else if (soapWerte) {
5550                soapWerte.quelle = 'manuell';
5551            }
5552            if (soapWerte) structuredData = { ...(structuredData || {}), soap: soapWerte };
5553            const resp = await this._recFetch(`/api/patients/${this.contactId}/records`, {
5554                method: 'POST',
5555                body: JSON.stringify({
5556                    recordType: recordType,
5557                    title: document.getElementById('recTitle').value,
5558                    content: content,
5559                    isPrivate: !!document.getElementById('recPrivat')?.checked,
5560                    ...(verordnungId ? { verordnungId } : {}),
5561                    ...(structuredData ? { structuredData } : {})
5562                })
5563            });
5564            if (resp.status === 409) {
5565                UI.showError(t('Fuer diesen Termin existiert bereits eine Behandlungsdoku'));
5566                return;
5567            }
5568            if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
5569            const saved = await resp.json().catch(() => ({}));
5570            if (saved.verordnungUsage) {
5571                const u = saved.verordnungUsage;
5572                UI.showSuccess(t('Behandlung dokumentiert') + ` — ${t('Einheit')} ${u.used}/${u.total || '?'}`);
5573            }
5574            document.getElementById('recContent').value = '';
5575            document.getElementById('recTitle').value = '';
5576            ['befSchmerz', 'befLokalisation', 'befBewegung', 'befZiel'].forEach(id => {
5577                const f = document.getElementById(id); if (f) f.value = '';
5578            });
5579            this._soapReset();  // soap-docu-ui-v1
5580            this._bcReset();
5581            document.getElementById('recAddForm').style.display = 'none';
5582            await Promise.all([this.loadPatientRecords(), this.loadVerordnungOptions()]);
5583        } catch (e) {
5584            console.error('savePatientRecord failed:', e);
5585            UI.showError(t('Eintrag konnte nicht gespeichert werden'));
5586        }
5587    }
5588
5589    async deletePatientRecord(recordId) {
5590        if (!recordId || !confirm(t('Eintrag wirklich loeschen?'))) return;
5591        try {
5592            const resp = await this._recFetch(`/api/medical-records/${recordId}`, { method: 'DELETE' });
5593            if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
5594            await this.loadPatientRecords();
5595        } catch (e) {
5596            console.error('deletePatientRecord failed:', e);
5597            UI.showError(t('Eintrag konnte nicht geloescht werden'));
5598        }
5599    }
5600
5601    static async loadContact(contactId) {
5602        try {
5603            try {
5604                const _c = await contactsApi.get(contactId);
5605                if (_c && _c.id) return _c;
5606                return null;
5607            } catch (e) {
5608                console.error('Fehler beim Laden des Kontakts:', e);
5609                return null;
5610            }
5611        } catch (error) {
5612            console.error('Fehler beim Laden des Kontakts:', error);
5613            return null;
5614        }
5615    }
5616}
5617
5618// ContactDetail global verfügbar machen für onclick-Handler
5619window.ContactDetail = ContactDetail;

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.