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, '&').replace(/</g, '<').replace(/>/g, '>') 231 .replace(/"/g, '"').replace(/'/g, '''); 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,'<');}).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, '&').replace(/</g, '<') 3003 .replace(/>/g, '>').replace(/"/g, '"'); 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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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 ? '✍' : ''} ${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(' · ')).replace(/&middot;/g,'·')}</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…</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">🔏 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,'"')}"> 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} → ${last.v}</span>` 5266 : (last.v > first.v 5267 ? `<span class="text-danger"><i class="fas fa-arrow-up me-1"></i>${first.v} → ${last.v}</span>` 5268 : `<span class="text-muted">${first.v} → ${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.