2026-04-16 15:23:14 +02:00
<!DOCTYPE html>
< html lang = "de" >
< head >
< meta charset = "utf-8" >
< meta name = "viewport" content = "width=device-width, initial-scale=1" >
2026-04-19 20:41:37 +02:00
< link rel = "icon" href = "data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect width='32' height='32' rx='6' fill='%235B8DB3'/><text x='16' y='22' font-family='Arial' font-size='14' font-weight='bold' fill='white' text-anchor='middle'>AZA</text></svg>" >
< title > AZA – Empfang< / title >
2026-04-16 15:23:14 +02:00
< style >
*{box-sizing:border-box;margin:0;padding:0}
2026-04-19 20:41:37 +02:00
html,body{height:100%;overflow:hidden}
2026-04-22 22:33:46 +02:00
/* rem bezieht sich auf html — muss mit Basisschrift (9pt) übereinstimmen, bis applyFontSize() läuft */
html{font-size:9pt}
2026-04-19 20:41:37 +02:00
body{font-family:'Segoe UI',system-ui,sans-serif;background:#f0f4f8;color:#1a2a3a;font-size:9pt;display:flex;flex-direction:column}
/* === Header === */
header{background:linear-gradient(135deg,#5B8DB3,#3a6d93);color:#fff;padding:10px 20px;display:flex;align-items:center;justify-content:space-between;box-shadow:0 2px 8px rgba(0,0,0,.12);flex-shrink:0;gap:8px;z-index:50}
header h1{font-size:1.2rem;font-weight:600;letter-spacing:.3px;white-space:nowrap}
.header-right{display:flex;align-items:center;gap:6px;flex-wrap:wrap}
.hbtn{background:none;border:1px solid rgba(255,255,255,.3);color:#fff;border-radius:5px;padding:3px 9px;font-size:.78rem;cursor:pointer;transition:background .15s;white-space:nowrap}
.hbtn:hover{background:rgba(255,255,255,.15)}
2026-04-22 22:33:46 +02:00
.hbtn.on{background:rgba(255,255,255,.25);border-color:rgba(255,255,255,.55)}
2026-04-19 20:41:37 +02:00
.hbtn.muted{opacity:.5}
.hbtn.active-sort{background:rgba(255,255,255,.25)}
.badge{background:rgba(255,255,255,.2);border-radius:16px;padding:3px 12px;font-size:.8rem}
.font-ctrl{display:flex;align-items:center;gap:2px}
.font-ctrl span{font-size:.72rem;opacity:.8}
.font-ctrl button{background:none;border:1px solid rgba(255,255,255,.25);color:#fff;border-radius:3px;width:20px;height:20px;cursor:pointer;font-size:.65rem;line-height:1;display:flex;align-items:center;justify-content:center}
.font-ctrl button:hover{background:rgba(255,255,255,.15)}
.vol-ctrl{display:flex;align-items:center;gap:3px}
.vol-ctrl input[type=range]{width:50px;accent-color:#fff;cursor:pointer}
.vol-ctrl span{font-size:.7rem;opacity:.8;min-width:26px;text-align:right}
/* === Settings Panel === */
.settings-panel{display:none;background:#fff;border-bottom:1px solid #e0e4e8;padding:12px 20px;flex-shrink:0;z-index:40}
.settings-panel.open{display:block}
.settings-inner h3{font-size:.9rem;color:#1a4d6d;margin-bottom:8px}
.setting-row{display:flex;align-items:center;gap:8px;margin-bottom:6px;flex-wrap:wrap}
.setting-row label{font-size:.82rem;color:#3a5a7a}
.setting-row select,.setting-row input[type=number]{border:1px solid #ccc;border-radius:4px;padding:3px 6px;font-size:.82rem}
.setting-row input[type=number]{width:60px}
.btn-test-small{background:#e8f0f8;color:#2a5a8a;border:1px solid #c8d8e8;border-radius:4px;padding:2px 8px;font-size:.78rem;cursor:pointer}
.btn-test-small:hover{background:#d4e4f0}
.btn-cleanup{background:#f8e8e8;color:#8a2a2a;border:1px solid #e0c8c8;border-radius:4px;padding:2px 8px;font-size:.78rem;cursor:pointer}
.btn-cleanup:hover{background:#f0d4d4}
/* === 3-Panel Layout === */
#app-layout{display:flex;flex:1;min-height:0;overflow:hidden}
/* === Sidebar (left) === */
#sidebar{width:210px;min-width:170px;background:#fff;border-right:1px solid #e0e4e8;display:flex;flex-direction:column;flex-shrink:0;overflow:hidden}
#sidebar-scroll{flex:1;overflow-y:auto;overflow-x:hidden}
.sb-me{padding:12px 14px;background:#f4f8fc;border-bottom:1px solid #eef2f6;display:flex;align-items:center;gap:8px}
.sb-me-dot{width:8px;height:8px;border-radius:50%;background:#6bc47f;flex-shrink:0}
.sb-me-name{font-weight:600;font-size:.88rem;color:#1a3a5a;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.sb-me-change{background:none;border:none;color:#8a9aaa;cursor:pointer;font-size:.72rem;padding:2px 4px}
.sb-me-change:hover{color:#5B8DB3}
.sb-section{font-size:.68rem;font-weight:700;color:#8a9aaa;text-transform:uppercase;letter-spacing:.8px;padding:14px 14px 5px}
.sb-item{padding:7px 14px;font-size:.85rem;color:#1a3a5a;cursor:pointer;border-left:3px solid transparent;transition:all .12s;display:flex;align-items:center;gap:8px}
.sb-item:hover{background:#f4f8fc}
.sb-item.active{background:#e8f0f8;border-left-color:#5B8DB3;font-weight:600;color:#1a4d6d}
.sb-icon{font-size:.9rem;width:18px;text-align:center;flex-shrink:0}
.sb-badge{margin-left:auto;background:#5B8DB3;color:#fff;border-radius:10px;padding:1px 7px;font-size:.68rem;font-weight:600;min-width:18px;text-align:center}
.sb-badge:empty{display:none}
.sb-user{padding:5px 14px 5px 18px;font-size:.82rem;color:#3a5a7a;cursor:pointer;display:flex;align-items:center;gap:7px;transition:background .12s;border-left:3px solid transparent}
.sb-user:hover{background:#f4f8fc}
.sb-user.me{font-weight:600;color:#1a3a5a;background:#f0f7fe;border-left-color:#5B8DB3}
.sb-dot{width:7px;height:7px;border-radius:50%;flex-shrink:0}
.sb-dot.online{background:#6bc47f}
.sb-dot.away{background:#e8c547}
.sb-dot.offline{background:#ccc}
.sb-user-name{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.sb-group-tag{font-size:.65rem;color:#8a9aaa;background:#f0f4f8;padding:1px 5px;border-radius:6px}
.sb-add{padding:6px 14px 6px 18px;font-size:.78rem;color:#5B8DB3;cursor:pointer;display:flex;align-items:center;gap:6px;transition:background .12s}
.sb-add:hover{background:#f4f8fc}
.sb-add-input{display:none;padding:4px 14px 8px 18px}
.sb-add-input.open{display:block}
.sb-add-input input{width:100%;border:1px solid #ccc;border-radius:4px;padding:4px 8px;font-size:.82rem;font-family:inherit}
/* === Chat Area (middle) === */
#chat-area{flex:1;display:flex;flex-direction:column;min-width:0;background:#f0f4f8}
#chat-top-bar{padding:8px 16px;background:#fff;border-bottom:1px solid #e0e4e8;display:flex;align-items:center;gap:8px;flex-shrink:0;min-height:40px}
.ctb-back{background:none;border:none;color:#5B8DB3;cursor:pointer;font-size:.9rem;padding:4px 6px;border-radius:4px;display:none}
.ctb-back:hover{background:#e8f0f8}
.ctb-back.visible{display:inline-flex}
.ctb-title{font-weight:600;font-size:.95rem;color:#1a3a5a;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.ctb-actions{display:flex;gap:4px}
.ctb-btn{background:#e8f0f8;color:#2a5a8a;border:1px solid #d0dce8;border-radius:5px;padding:3px 10px;font-size:.76rem;cursor:pointer;font-family:inherit;transition:all .12s;white-space:nowrap}
.ctb-btn:hover{background:#d4e4f0}
.ctb-btn.done{background:#d4edda;color:#155724;border-color:#c3e6cb}
.ctb-btn.done:hover{background:#c3e6cb}
.ctb-btn.del{color:#8a2a2a;background:#f8e8e8;border-color:#e8c8c8}
.ctb-btn.del:hover{background:#f0d4d4}
#chat-content{flex:1;overflow-y:auto;padding:12px 16px}
/* Thread list items */
.tl-item{background:#fff;border-radius:8px;padding:10px 14px;margin-bottom:8px;cursor:pointer;border-left:4px solid #5B8DB3;box-shadow:0 1px 3px rgba(0,0,0,.05);transition:all .12s}
.tl-item:hover{box-shadow:0 2px 8px rgba(0,0,0,.1);transform:translateY(-1px)}
.tl-item.done{border-left-color:#8bc49a;opacity:.7}
.tl-item.done:hover{opacity:.85}
.tl-row1{display:flex;align-items:baseline;gap:6px;margin-bottom:3px}
.tl-patient{font-weight:600;font-size:.92rem;color:#1a3a5a;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.tl-time{font-size:.72rem;color:#8a9aaa;white-space:nowrap}
.tl-row2{display:flex;align-items:center;gap:6px}
.tl-sender{font-size:.78rem;color:#6a8a9a;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.tl-replies{font-size:.7rem;color:#5B8DB3;white-space:nowrap}
.tl-status{font-size:.68rem;padding:2px 8px;border-radius:10px;font-weight:600;white-space:nowrap}
.tl-status.offen{background:#fff3cd;color:#856404}
.tl-status.erledigt{background:#d4edda;color:#155724}
/* Conversation view */
.conv-empty{text-align:center;padding:60px 20px;color:#8a9aaa;font-size:.95rem}
.conv-doc{background:#fff;border-radius:10px;padding:14px 18px;margin-bottom:14px;box-shadow:0 1px 4px rgba(0,0,0,.06);border-left:4px solid #5B8DB3}
.conv-doc-head{display:flex;align-items:center;gap:8px;margin-bottom:8px}
.conv-doc-sender{font-weight:600;font-size:.88rem;color:#1a3a5a}
.conv-doc-time{font-size:.72rem;color:#8a9aaa}
.conv-field{margin-bottom:6px}
.conv-field-label{font-size:.72rem;font-weight:700;color:#5B8DB3;text-transform:uppercase;letter-spacing:.4px;margin-bottom:2px;cursor:pointer;user-select:none}
.conv-field-label .tri{font-size:.6rem;margin-right:3px;display:inline-block;transition:transform .15s}
.conv-field-label .tri.closed{transform:rotate(-90deg)}
.conv-field-value{white-space:pre-wrap;line-height:1.5;font-size:.88rem}
.conv-field-value.collapsed{display:none}
.conv-attach{margin-top:6px}
.conv-attach img{max-width:100%;max-height:260px;border-radius:6px;border:1px solid #e0e4e8}
.conv-msg{padding:10px 14px;margin-bottom:6px;border-radius:10px;max-width:78%;font-size:.88rem;position:relative}
.conv-msg.from-other{background:#fff;border:1px solid #e8ecf0;margin-right:auto;border-bottom-left-radius:3px}
.conv-msg.from-me{background:#e8f0fa;border:1px solid #d0dce8;margin-left:auto;border-bottom-right-radius:3px}
.conv-msg-meta{font-size:.7rem;color:#8a9aaa;margin-bottom:3px;display:flex;gap:6px}
.conv-msg-sender{font-weight:600;color:#5a7a9a}
.conv-msg-text{white-space:pre-wrap;line-height:1.45}
/* Input bar */
#chat-input-bar{padding:10px 16px;background:#fff;border-top:1px solid #e0e4e8;flex-shrink:0;display:none;gap:8px;align-items:flex-end}
#chat-input-bar.visible{display:flex}
#chat-input-bar textarea{flex:1;border:1px solid #d0dce8;border-radius:8px;padding:8px 12px;font-family:inherit;font-size:.9rem;resize:none;min-height:38px;max-height:120px;line-height:1.4;outline:none;transition:border-color .15s}
#chat-input-bar textarea:focus{border-color:#5B8DB3}
.input-btns{display:flex;gap:4px;flex-shrink:0}
.btn-send{background:#5B8DB3;color:#fff;border:none;border-radius:8px;padding:8px 16px;cursor:pointer;font-weight:600;font-size:.85rem;font-family:inherit;transition:background .15s;white-space:nowrap}
.btn-send:hover{background:#4A7A9E}
.btn-dictate{background:#f0e8ff;color:#6b3fa0;border:none;border-radius:8px;padding:8px 12px;cursor:pointer;font-size:.85rem;font-family:inherit;white-space:nowrap}
.btn-dictate:hover{background:#e0d0f8}
.btn-dictate.recording{background:#f8d7da;color:#721c24;animation:pulse 1.5s infinite}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.6}}
/* === Tasks Panel (right) === */
#tasks-panel{width:240px;min-width:170px;background:#fff;border-left:1px solid #e0e4e8;display:flex;flex-direction:column;flex-shrink:0;overflow:hidden}
.tp-header{display:flex;align-items:center;justify-content:space-between;padding:10px 12px;background:#fafcfe;border-bottom:1px solid #eef2f6;font-weight:600;color:#1a4d6d;font-size:.88rem}
.tp-add-btn{background:#5B8DB3;color:#fff;border:none;border-radius:50%;width:22px;height:22px;font-size:14px;cursor:pointer;display:flex;align-items:center;justify-content:center;line-height:1}
.tp-add-btn:hover{background:#4A7A9E}
.tp-input-row{display:none;padding:6px 8px;border-bottom:1px solid #eef2f6}
.tp-input-row.open{display:block}
.tp-input-row input{width:100%;border:1px solid #ccc;border-radius:4px;padding:4px 8px;font-size:.82rem;font-family:inherit}
#todo-list{flex:1;overflow-y:auto;padding:6px}
.todo-item{display:flex;align-items:flex-start;gap:4px;padding:4px 6px;border-radius:5px;margin-bottom:2px;background:#f8fafe;font-size:.8rem;transition:background .1s}
.todo-item:hover{background:#eef4fa}
.todo-item.todo-done{background:#f0f8f0}
.todo-item input[type=checkbox]{margin-top:3px;accent-color:#5B8DB3;cursor:pointer}
.todo-item .todo-text{flex:1;word-break:break-word;outline:none;min-height:1.2em}
.todo-item .todo-text.done{text-decoration:line-through;opacity:.5}
.todo-item .todo-assignee{font-size:.68rem;color:#8a9aaa;margin-top:1px}
.todo-item .todo-del{background:none;border:none;color:#ccc;cursor:pointer;font-size:.78rem;padding:2px;line-height:1}
.todo-item .todo-del:hover{color:#e05050}
.todo-item .todo-assign-btn{background:none;border:none;color:#aac;cursor:pointer;font-size:.7rem;padding:1px 3px}
.todo-item .todo-assign-btn:hover{color:#5B8DB3}
/* === Status Bar === */
.status-bar{padding:5px 16px;font-size:.72rem;color:#8a9aaa;background:#fff;border-top:1px solid #eef2f6;display:flex;justify-content:center;gap:12px;flex-shrink:0}
/* === Utility === */
.copied-flash{animation:copiedFl .6s ease}
@keyframes copiedFl{0%{background:#d4edda}100%{background:transparent}}
2026-04-20 14:38:16 +02:00
/* === Login Overlay === */
#login-overlay{position:fixed;top:0;left:0;right:0;bottom:0;background:#f0f4f8;z-index:200;display:flex;align-items:center;justify-content:center}
#login-overlay.hidden{display:none}
.login-box{background:#fff;border-radius:12px;box-shadow:0 4px 24px rgba(0,0,0,.12);padding:36px;width:380px;max-width:90vw}
.login-box h2{font-size:1.3rem;color:#1a4d6d;margin-bottom:6px}
.login-box p{font-size:.85rem;color:#6a8a9a;margin-bottom:20px}
.login-field{margin-bottom:14px}
.login-field label{display:block;font-size:.82rem;color:#3a5a7a;margin-bottom:4px;font-weight:600}
.login-field input,.login-field select{width:100%;border:1px solid #d0dce8;border-radius:6px;padding:8px 12px;font-size:.9rem;font-family:inherit;outline:none;transition:border-color .15s}
.login-field input:focus,.login-field select:focus{border-color:#5B8DB3}
.login-btn{width:100%;background:#5B8DB3;color:#fff;border:none;border-radius:8px;padding:10px;font-size:.95rem;font-weight:600;cursor:pointer;font-family:inherit;transition:background .15s}
.login-btn:hover{background:#4A7A9E}
.login-error{color:#dc3545;font-size:.82rem;margin-top:8px;min-height:1.2em}
.login-switch{text-align:center;margin-top:14px;font-size:.82rem;color:#6a8a9a}
2026-04-22 22:33:46 +02:00
/* Neuer Chat (Popup) */
.new-chat-overlay{position:fixed;inset:0;background:rgba(26,42,58,.45);z-index:200;display:flex;align-items:center;justify-content:center;padding:16px}
.new-chat-overlay.hidden{display:none!important}
.new-chat-box{background:#fff;border-radius:12px;padding:22px 24px;max-width:400px;width:100%;box-shadow:0 8px 32px rgba(0,0,0,.15)}
.new-chat-box h3{font-size:1.1rem;color:#1a4d6d;margin-bottom:8px}
.new-chat-box p{font-size:.85rem;color:#6a8a9a;margin-bottom:14px;line-height:1.45}
.nc-opt{display:block;margin:8px 0;font-size:.88rem;cursor:pointer}
.nc-opt input{margin-right:8px}
#nc-peer-select{width:100%;margin-top:10px;padding:8px;border:1px solid #d0dce8;border-radius:6px;font-size:.88rem;font-family:inherit}
.nc-btns{display:flex;gap:10px;justify-content:flex-end;margin-top:18px}
.nc-btns button{padding:8px 16px;border-radius:8px;font-size:.85rem;cursor:pointer;font-family:inherit}
.nc-btns .nc-cancel{background:#eef2f6;color:#3a5a7a;border:1px solid #d0dce8}
.nc-btns .nc-go{background:#5B8DB3;color:#fff;border:none;font-weight:600}
#reply-att-hint{font-size:.72rem;color:#5B8DB3;min-height:1em;margin:4px 0 0 2px}
2026-04-20 14:38:16 +02:00
.login-switch a{color:#5B8DB3;cursor:pointer;text-decoration:underline}
2026-04-21 10:00:36 +02:00
/* Registrierung: Überschrift, Fließtext, Labels, Felder, Button, Link – alles 9pt */
.login-box.login-register,
.login-box.login-register h2,
.login-box.login-register p,
.login-box.login-register label,
.login-box.login-register input,
.login-box.login-register select,
.login-box.login-register button,
.login-box.login-register .login-error,
.login-box.login-register .login-switch,
.login-box.login-register .login-switch a{font-size:9pt!important}
.login-box.login-register h2{font-weight:600}
2026-04-20 14:38:16 +02:00
/* === Admin Panel === */
.admin-overlay{display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.3);z-index:150;align-items:flex-start;justify-content:center;padding-top:40px;overflow-y:auto}
.admin-overlay.open{display:flex}
.admin-panel{background:#fff;border-radius:12px;box-shadow:0 8px 32px rgba(0,0,0,.15);width:720px;max-width:94vw;max-height:calc(100vh - 80px);overflow-y:auto;padding:0}
.admin-header{display:flex;align-items:center;justify-content:space-between;padding:16px 24px;border-bottom:1px solid #e0e4e8;position:sticky;top:0;background:#fff;border-radius:12px 12px 0 0;z-index:5}
.admin-header h2{font-size:1.15rem;color:#1a4d6d;font-weight:600}
.admin-close{background:none;border:none;font-size:1.3rem;color:#8a9aaa;cursor:pointer;padding:4px 8px;border-radius:4px}
.admin-close:hover{background:#f0f4f8;color:#1a4d6d}
.admin-tabs{display:flex;gap:0;border-bottom:1px solid #e0e4e8;padding:0 24px;background:#fafcfe}
.admin-tab{padding:10px 18px;font-size:.85rem;color:#6a8a9a;cursor:pointer;border-bottom:2px solid transparent;font-weight:500;transition:all .15s;font-family:inherit;background:none;border-top:none;border-left:none;border-right:none}
.admin-tab:hover{color:#1a4d6d;background:#f0f4f8}
.admin-tab.active{color:#5B8DB3;border-bottom-color:#5B8DB3;font-weight:600}
.admin-body{padding:20px 24px}
.admin-table{width:100%;border-collapse:collapse;font-size:.82rem}
.admin-table th{text-align:left;padding:8px 10px;color:#5B8DB3;font-weight:600;font-size:.75rem;text-transform:uppercase;letter-spacing:.4px;border-bottom:2px solid #e0e4e8}
.admin-table td{padding:8px 10px;border-bottom:1px solid #f0f4f8;vertical-align:middle}
.admin-table tr:hover td{background:#fafcfe}
.admin-badge{display:inline-block;padding:2px 8px;border-radius:10px;font-size:.72rem;font-weight:600}
.admin-badge.admin{background:#e8d5f5;color:#6b3fa0}
.admin-badge.arzt{background:#d4edda;color:#155724}
.admin-badge.mpa{background:#d6eaf8;color:#1a5276}
.admin-badge.empfang{background:#fff3cd;color:#856404}
.admin-badge.active{background:#d4edda;color:#155724}
.admin-badge.deactivated{background:#f8d7da;color:#721c24}
.admin-badge.trusted{background:#d4edda;color:#155724}
.admin-badge.blocked{background:#f8d7da;color:#721c24}
.admin-badge.pending_conn{background:#fff3cd;color:#856404}
.admin-badge.active_conn{background:#d4edda;color:#155724}
.admin-badge.revoked{background:#e2e3e5;color:#6c757d}
.admin-btn{background:#e8f0f8;color:#2a5a8a;border:1px solid #d0dce8;border-radius:4px;padding:3px 10px;font-size:.75rem;cursor:pointer;font-family:inherit;transition:all .12s;white-space:nowrap}
.admin-btn:hover{background:#d4e4f0}
.admin-btn.danger{color:#8a2a2a;background:#f8e8e8;border-color:#e8c8c8}
.admin-btn.danger:hover{background:#f0d4d4}
.admin-btn.success{color:#155724;background:#d4edda;border-color:#c3e6cb}
.admin-btn.success:hover{background:#c3e6cb}
.admin-section-title{font-size:.88rem;font-weight:600;color:#1a4d6d;margin:16px 0 8px}
.admin-info-row{display:flex;align-items:center;gap:8px;margin-bottom:6px;font-size:.82rem;flex-wrap:wrap}
.admin-info-label{color:#6a8a9a;min-width:120px}
.admin-info-value{color:#1a3a5a;font-weight:500}
.admin-mono{font-family:monospace;background:#f0f4f8;padding:2px 8px;border-radius:4px;user-select:all}
.admin-empty{text-align:center;padding:24px;color:#8a9aaa;font-size:.85rem}
.admin-add-row{display:flex;gap:6px;margin-top:12px;flex-wrap:wrap;align-items:center}
.admin-add-row input,.admin-add-row select{border:1px solid #d0dce8;border-radius:4px;padding:4px 8px;font-size:.82rem;font-family:inherit}
.admin-add-row input{flex:1;min-width:120px}
2026-04-19 20:41:37 +02:00
@media print{
header,.settings-panel,#sidebar,#tasks-panel,.status-bar,#chat-input-bar,#chat-top-bar{display:none!important}
#app-layout{display:block!important}
#chat-area{display:block!important}
.tl-item{break-inside:avoid;box-shadow:none}
.conv-doc,.conv-msg{break-inside:avoid;box-shadow:none}
}
2026-04-16 15:23:14 +02:00
< / style >
< / head >
< body >
2026-04-20 14:38:16 +02:00
< div id = "login-overlay" >
< div class = "login-box" id = "login-box" > < / div >
< / div >
2026-04-16 15:23:14 +02:00
< header >
2026-04-19 20:41:37 +02:00
< h1 > AZA – Empfang< / h1 >
2026-04-16 15:23:14 +02:00
< div class = "header-right" >
2026-04-22 22:33:46 +02:00
< div class = "font-ctrl" id = "font-ctrl-wrap" title = "Schriftgrösse der ganzen Seite (100% = Standard). Mit Pfeilen vergrössern oder verkleinern." >
2026-04-19 20:41:37 +02:00
< span > Aa< / span >
2026-04-22 22:33:46 +02:00
< span id = "font-size-display" > 100%< / span >
< button type = "button" onclick = "changeFontSize(1)" aria-label = "Schrift grösser" > ▲ < / button >
< button type = "button" onclick = "changeFontSize(-1)" aria-label = "Schrift kleiner" > ▼ < / button >
2026-04-19 20:41:37 +02:00
< / div >
2026-04-22 22:33:46 +02:00
< button class = "hbtn" onclick = "openNewChatDialog()" title = "Neuen Chat in eigenem Fenster" > + Neuer Chat< / button >
2026-04-19 20:41:37 +02:00
< button class = "hbtn" id = "sound-btn" onclick = "toggleSound()" title = "Ton an/aus" > 🔔 Ton< / button >
< div class = "vol-ctrl" title = "Lautstärke" >
< input type = "range" id = "vol-slider" min = "0" max = "300" step = "5" oninput = "setVolume(this.value)" >
< span id = "vol-display" > 100%< / span >
< / div >
2026-04-22 22:33:46 +02:00
< button type = "button" class = "hbtn" id = "shell-pin-btn" onclick = "toggleShellPin()" style = "display:none" title = "Immer im Vordergrund" aria-pressed = "false" > 📌 < / button >
2026-04-19 20:41:37 +02:00
< button class = "hbtn" id = "sort-btn" onclick = "toggleSort()" title = "Sortierung" > ⇅ < / button >
< span class = "badge" id = "count-badge" > – < / span >
< button class = "hbtn" onclick = "toggleSettings()" title = "Einstellungen" > ⚙ < / button >
2026-04-16 15:23:14 +02:00
< / div >
< / header >
2026-04-19 20:41:37 +02:00
< div class = "settings-panel" id = "settings-panel" >
< div class = "settings-inner" >
< h3 > Ton-Einstellungen< / h3 >
< div class = "setting-row" >
< label for = "tone-select" > Benachrichtigungston:< / label >
< select id = "tone-select" onchange = "saveSoundSettings()" > < / select >
< button class = "btn-test-small" onclick = "testCurrentSound()" > Testen< / button >
< / div >
< div class = "setting-row" >
< label for = "repeat-select" > Ton-Wiederholung:< / label >
< select id = "repeat-select" onchange = "onRepeatChange()" >
< option value = "0" > Aus< / option >
< option value = "30000" > Standard (30 Sek.)< / option >
< option value = "5000" > Alle 5 Sekunden< / option >
< option value = "2000" > Alle 2 Sekunden< / option >
< option value = "custom" > Eigene Eingabe< / option >
< / select >
< span id = "custom-sec-wrap" style = "display:none" >
< input type = "number" id = "custom-sec" min = "1" max = "600" value = "10" style = "width:55px" onchange = "onRepeatChange()" > Sek.
< / span >
< / div >
2026-04-20 14:38:16 +02:00
< h3 style = "margin-top:12px" > Praxis-Information< / h3 >
< div id = "practice-info-section" style = "margin-bottom:6px" > < / div >
2026-04-19 20:41:37 +02:00
< h3 style = "margin-top:12px" > Aufbewahrung< / h3 >
< div class = "setting-row" >
< label for = "cleanup-days" > Nachrichten ä lter als< / label >
< input type = "number" id = "cleanup-days" min = "1" max = "365" value = "30" style = "width:55px" > Tage
< button class = "btn-cleanup" onclick = "doCleanup()" > Jetzt bereinigen< / button >
< span id = "cleanup-result" style = "font-size:.78rem;color:#6a8a9a" > < / span >
< / div >
2026-04-20 14:38:16 +02:00
< div id = "admin-panel-link" style = "display:none;margin-top:12px;padding-top:12px;border-top:1px solid #eee" >
< button class = "btn-test-small" onclick = "openAdmin()" style = "font-size:.85rem;padding:6px 16px" > Praxis-Verwaltung< / button >
< / div >
< div style = "margin-top:16px;padding-top:12px;border-top:1px solid #eee" >
< button class = "btn-cleanup" onclick = "doLogout()" style = "background:#e8e8f0;color:#3a3a5a;border-color:#c8c8d8" > Abmelden< / button >
< / div >
2026-04-19 20:41:37 +02:00
< / div >
2026-04-16 15:23:14 +02:00
< / div >
2026-04-19 20:41:37 +02:00
< div id = "app-layout" >
<!-- === SIDEBAR (left) === -->
< aside id = "sidebar" >
< div class = "sb-me" id = "sb-me" >
< div class = "sb-me-dot" > < / div >
2026-04-20 14:38:16 +02:00
< div style = "flex:1;overflow:hidden" >
< div class = "sb-me-name" id = "sb-me-name" > Nicht angemeldet< / div >
< div id = "sb-me-role" style = "font-size:.68rem;color:#8a9aaa" > < / div >
< / div >
< button class = "sb-me-change" onclick = "doLogout()" title = "Abmelden" > → < / button >
2026-04-19 20:41:37 +02:00
< / div >
< div id = "sidebar-scroll" >
< div class = "sb-section" > Kanä le< / div >
< div class = "sb-item active" data-view = "allgemein" onclick = "setView('allgemein')" >
< span class = "sb-icon" > 💬 < / span > Allgemein
< span class = "sb-badge" id = "sb-badge-open" > < / span >
< / div >
< div class = "sb-item" data-view = "archiv" onclick = "setView('archiv')" >
< span class = "sb-icon" > 📁 < / span > Archiv
< / div >
2026-04-22 22:33:46 +02:00
< div class = "sb-add" onclick = "openNewChatDialog()" title = "Chat in neuem Fenster oder Tab" >
< span > +< / span > Neuer Chat
< / div >
2026-04-19 20:41:37 +02:00
< div class = "sb-section" > Benutzer< / div >
< div id = "sb-users" > < / div >
< div class = "sb-add" onclick = "toggleSbAddUser()" >
< span > +< / span > Neuer Benutzer
< / div >
< div class = "sb-add-input" id = "sb-add-input" >
< input type = "text" id = "sb-new-user" placeholder = "Name eingeben..." onkeydown = "if(event.key==='Enter'){addUserFromSidebar();event.preventDefault()}" >
< / div >
< / div >
< / aside >
<!-- === CHAT AREA (middle) === -->
< main id = "chat-area" >
< div id = "chat-top-bar" >
< button class = "ctb-back" id = "ctb-back" onclick = "backToList()" > ◀ Zurü ck< / button >
< div class = "ctb-title" id = "ctb-title" > Allgemein< / div >
< div class = "ctb-actions" id = "ctb-actions" > < / div >
< / div >
< div id = "chat-content" >
< div class = "conv-empty" > Nachrichten werden geladen… < / div >
< / div >
< div id = "chat-input-bar" >
2026-04-22 22:33:46 +02:00
< textarea id = "reply-text" placeholder = "Nachricht schreiben… (Bild: einfügen oder hier ablegen)" rows = "1" onkeydown = "if(event.key==='Enter'&&!event.shiftKey){sendReply();event.preventDefault()}" oninput = "autoGrowTextarea(this)" > < / textarea >
< div id = "reply-att-hint" > < / div >
2026-04-19 20:41:37 +02:00
< div class = "input-btns" >
< button class = "btn-dictate" id = "dictate-btn" onclick = "toggleDictation()" > 🎤 < / button >
< button class = "btn-send" onclick = "sendReply()" > Senden< / button >
< / div >
< / div >
< / main >
<!-- === TASKS PANEL (right) === -->
< aside id = "tasks-panel" >
< div class = "tp-header" >
< span > Aufgaben< / span >
< button class = "tp-add-btn" onclick = "toggleTodoInput()" title = "Neue Aufgabe" > +< / button >
< / div >
< div class = "tp-input-row" id = "todo-input-row" >
< input type = "text" id = "todo-inline-input" placeholder = "Aufgabe eingeben..." onkeydown = "if(event.key==='Enter'){addTodoInline();event.preventDefault()}" >
< / div >
< div id = "todo-list" > < / div >
< / aside >
< / div >
2026-04-22 22:33:46 +02:00
< div id = "new-chat-overlay" class = "new-chat-overlay hidden" onclick = "if(event.target===this)closeNewChatDialog()" >
< div class = "new-chat-box" onclick = "event.stopPropagation()" >
< h3 > Neuer Chat< / h3 >
< p > Wä hlen Sie, wo der Chat laufen soll. Es ö ffnet sich ein < strong > zusä tzliches Fenster< / strong > (Browser oder Desktop-Hü lle) — Sie kö nnen dort in Ruhe schreiben, < strong > Bilder per Drag & Drop oder Strg+V< / strong > einfü gen.< / p >
< label class = "nc-opt" > < input type = "radio" name = "nc-mode" value = "general" checked onchange = "syncNcPeerSelect()" > Allgemein (wie Kanal „ Allgemein“ )< / label >
< label class = "nc-opt" > < input type = "radio" name = "nc-mode" value = "dm" onchange = "syncNcPeerSelect()" > Direkt mit Benutzer< / label >
< select id = "nc-peer-select" disabled aria-label = "Benutzer wählen" >
< option value = "" > — Benutzer wä hlen — < / option >
< / select >
< div class = "nc-btns" >
< button type = "button" class = "nc-cancel" onclick = "closeNewChatDialog()" > Abbrechen< / button >
< button type = "button" class = "nc-go" onclick = "launchNewChatWindow()" > Fenster ö ffnen< / button >
< / div >
< / div >
< / div >
2026-04-20 14:38:16 +02:00
< div class = "admin-overlay" id = "admin-overlay" >
< div class = "admin-panel" >
< div class = "admin-header" >
< h2 > Praxis-Verwaltung< / h2 >
< button class = "admin-close" onclick = "closeAdmin()" > × < / button >
< / div >
< div class = "admin-tabs" id = "admin-tabs" >
< button class = "admin-tab active" onclick = "showAdminTab('praxis')" > Praxis< / button >
< button class = "admin-tab" onclick = "showAdminTab('users')" > Benutzer< / button >
< button class = "admin-tab" onclick = "showAdminTab('devices')" > Gerä te< / button >
< button class = "admin-tab" onclick = "showAdminTab('channels')" > Kanä le< / button >
< button class = "admin-tab" onclick = "showAdminTab('federation')" > Verbindungen< / button >
< / div >
< div class = "admin-body" id = "admin-body" > < / div >
< / div >
< / div >
2026-04-19 20:41:37 +02:00
< div class = "status-bar" >
< span > Aktualisiert alle 10 Sek.< / span >
2026-04-22 22:33:46 +02:00
< span style = "opacity:.5" id = "ui-version" > v2026.04.22< / span >
2026-04-19 20:41:37 +02:00
< / div >
2026-04-16 15:23:14 +02:00
< script >
2026-04-19 20:41:37 +02:00
/* ===================================================================
STATE
=================================================================== */
var API_BASE = window.location.origin + '/empfang';
2026-04-21 10:00:36 +02:00
function getPracticeIdOrEmpty() {
try { return localStorage.getItem('aza_practice_id') || ''; } catch (e) { return ''; }
}
2026-04-20 14:38:16 +02:00
var currentSession = null;
var practiceUsers = [];
var serverTasks = [];
2026-04-19 20:41:37 +02:00
var allMessages = [];
var allThreads = [];
var activeView = 'allgemein';
var activeThreadId = null;
var lastDataHash = '';
var previousThreadIds = new Set();
var justSentReply = false;
2026-04-20 14:38:16 +02:00
var pollTimer = null;
var userPollTimer = null;
2026-04-22 22:33:46 +02:00
var pendingReplyAttachments = [];
var REPLY_ATTACH_MAX_BYTES = 2 * 1024 * 1024;
2026-04-16 15:23:14 +02:00
2026-04-19 20:41:37 +02:00
var soundEnabled = localStorage.getItem('empfang_sound') !== 'off';
var audioCtx = null;
2026-04-22 22:33:46 +02:00
var EMPFANG_FONT_BASE_PT = 9;
var currentFontSize = parseInt(localStorage.getItem('empfang_font_size'), 10) || EMPFANG_FONT_BASE_PT;
2026-04-19 20:41:37 +02:00
var volume = parseFloat(localStorage.getItem('empfang_volume') || '1.0');
var repeatMs = parseInt(localStorage.getItem('empfang_repeat_ms') || '30000', 10);
var repeatInterval = null;
var recognition = null;
var isRecording = false;
var _sortRaw = localStorage.getItem('empfang_sort');
if (_sortRaw === 'desc' || _sortRaw === null || _sortRaw === '') localStorage.setItem('empfang_sort', 'newest');
var sortNewestFirst = localStorage.getItem('empfang_sort') !== 'asc';
var TONE_PRESETS = [
{name:'Sanftes Glockenspiel', notes:[{f:523,d:.15},{f:659,d:.15},{f:784,d:.3}], wave:'sine', vol:.12},
{name:'Zwei-Ton Harmonisch', notes:[{f:392,d:.2},{f:523,d:.3}], wave:'sine', vol:.12},
{name:'Drei-Ton Melodie', notes:[{f:523,d:.12},{f:587,d:.12},{f:659,d:.28}], wave:'sine', vol:.11},
{name:'Kristallklar', notes:[{f:1319,d:.5}], wave:'sine', vol:.07},
{name:'Warmer Akkord', notes:[{f:262,d:.4}], wave:'triangle', vol:.14},
{name:'Aufstieg', notes:[{f:262,d:.09},{f:330,d:.09},{f:392,d:.09},{f:523,d:.22}], wave:'sine', vol:.10},
{name:'Sanfte Welle', notes:[{f:440,d:.55}], wave:'sine', vol:.10},
{name:'Tropfen', notes:[{f:659,d:.12},{f:587,d:.12},{f:523,d:.28}], wave:'sine', vol:.10},
{name:'Morgengruss', notes:[{f:523,d:.14},{f:392,d:.14},{f:523,d:.28}], wave:'sine', vol:.12},
{name:'Zephyr', notes:[{f:880,d:.45}], wave:'sine', vol:.06},
{name:'Bambus', notes:[{f:330,d:.18},{f:440,d:.28}], wave:'triangle', vol:.12},
{name:'Silberglocke', notes:[{f:988,d:.45}], wave:'sine', vol:.08},
{name:'Meditation', notes:[{f:262,d:.65}], wave:'sine', vol:.12},
{name:'Horizont', notes:[{f:587,d:.18},{f:880,d:.32}], wave:'sine', vol:.10},
{name:'Stille Post', notes:[{f:784,d:.4}], wave:'sine', vol:.08},
];
var currentToneIdx = parseInt(localStorage.getItem('empfang_tone_idx') || '0', 10);
2026-04-20 14:38:16 +02:00
/* ===================================================================
API WRAPPER (global 401 handling)
=================================================================== */
async function apiFetch(url, opts) {
2026-04-21 10:00:36 +02:00
opts = opts || {};
if (!opts.credentials) opts.credentials = 'include';
var r = await fetch(url, opts);
2026-04-20 14:38:16 +02:00
if (r.status === 401) {
currentSession = null;
stopPolling();
showLoginOverlay();
throw new Error('auth');
}
return r;
}
/* ===================================================================
AUTH
=================================================================== */
async function checkAuth() {
try {
2026-04-21 10:00:36 +02:00
var r = await fetch(API_BASE + '/auth/me', {credentials: 'include'});
2026-04-20 14:38:16 +02:00
if (r.status === 401) return null;
var d = await r.json();
if (d.authenticated) return d;
return null;
} catch(e) { return null; }
}
async function showLoginOverlay() {
stopPolling();
var overlay = document.getElementById('login-overlay');
overlay.classList.remove('hidden');
try {
2026-04-21 10:00:36 +02:00
var r = await fetch(API_BASE + '/auth/needs_setup', {credentials: 'include'});
2026-04-20 14:38:16 +02:00
var d = await r.json();
if (d.needs_setup) { renderSetupForm(); return; }
} catch(e) {}
renderLoginForm();
}
function hideLoginOverlay() {
document.getElementById('login-overlay').classList.add('hidden');
}
function renderSetupForm() {
var box = document.getElementById('login-box');
2026-04-21 10:00:36 +02:00
box.className = 'login-box';
2026-04-20 14:38:16 +02:00
box.innerHTML =
'< h2 > AZA Praxis-Chat einrichten< / h2 > ' +
'< p > Willkommen! Richten Sie Ihre Praxis und den ersten Administrator ein.< / p > ' +
'< div class = "login-field" > < label > Praxisname< / label > < input type = "text" id = "setup-practice" placeholder = "z.B. Hausarztpraxis Winterthur" > < / div > ' +
'< div class = "login-field" > < label > Ihr Name< / label > < input type = "text" id = "setup-name" autocomplete = "username" placeholder = "z.B. Dr. M\u00fcller" > < / div > ' +
'< div class = "login-field" > < label > E-Mail (Lizenz-/Admin-E-Mail)< / label > < input type = "email" id = "setup-email" autocomplete = "email" placeholder = "praxis@beispiel.ch" > < / div > ' +
'< div class = "login-field" > < label > Passwort (min. 4 Zeichen)< / label > < input type = "password" id = "setup-pass" autocomplete = "new-password" > < / div > ' +
'< button class = "login-btn" onclick = "doSetup()" > Praxis einrichten< / button > ' +
'< div class = "login-error" id = "login-error" > < / div > ';
document.getElementById('setup-practice').focus();
}
function renderLoginForm() {
var box = document.getElementById('login-box');
2026-04-21 10:00:36 +02:00
box.className = 'login-box';
2026-04-20 14:38:16 +02:00
var lastUser = localStorage.getItem('aza_last_login_user') || '';
box.innerHTML =
'< h2 > Anmelden< / h2 > ' +
'< p > AZA Praxis-Chat – Bitte anmelden< / p > ' +
'< form onsubmit = "event.preventDefault();doLogin()" > ' +
'< div class = "login-field" > < label > Benutzername oder E-Mail< / label > < input type = "text" id = "login-name" autocomplete = "username" value = "' + esc(lastUser) + '" > < / div > ' +
'< div class = "login-field" > < label > Passwort< / label > < input type = "password" id = "login-pass" autocomplete = "current-password" > < / div > ' +
'< button type = "submit" class = "login-btn" > Anmelden< / button > ' +
'< / form > ' +
'< div class = "login-error" id = "login-error" > < / div > ' +
'< div class = "login-switch" > < a onclick = "renderForgotPasswordForm()" > Passwort vergessen?< / a > < / div > ' +
'< div class = "login-switch" style = "margin-top:16px;padding-top:12px;border-top:1px solid #eee" > ' +
'< p style = "font-size:.82rem;color:#6a8a9a;margin-bottom:6px" > Noch kein Konto? Registrieren Sie sich mit dem Einladungscode Ihrer Praxis.< / p > ' +
'< a onclick = "renderRegisterForm()" style = "font-weight:600" > Registrieren → < / a > ' +
'< / div > ';
var nameInp = document.getElementById('login-name');
var passInp = document.getElementById('login-pass');
if (lastUser) { passInp.focus(); } else { nameInp.focus(); }
}
function renderForgotPasswordForm() {
var box = document.getElementById('login-box');
2026-04-21 10:00:36 +02:00
box.className = 'login-box';
var pid = getPracticeIdOrEmpty();
2026-04-20 14:38:16 +02:00
box.innerHTML =
'< h2 > Passwort vergessen< / h2 > ' +
2026-04-21 10:00:36 +02:00
'< p > Geben Sie Ihren < strong > Benutzernamen< / strong > (wie in der Praxis angezeigt) oder Ihre < strong > E-Mail-Adresse< / strong > ein.< / p > ' +
'< p style = "font-size:.82rem;color:#6a8a9a" > Nach einer erfolgreichen Anmeldung speichert der Browser die Praxiszuordnung — dann ist der Ablauf einfacher.< / p > ' +
(pid ? '' : '< p style = "font-size:.78rem;color:#a67c00" > Hinweis: Auf diesem Ger\u00e4t ist noch keine Praxis gespeichert. Benutzername oder E-Mail trotzdem m\u00f6glich; bei mehreren Konten ggf. zus\u00e4tzliche Auswahl.< / p > ') +
'< div class = "login-field" > < label > Benutzername oder E-Mail< / label > < input type = "text" id = "forgot-login" autocomplete = "username" placeholder = "z. B. Suro oder name@praxis.ch" > < / div > ' +
2026-04-20 14:38:16 +02:00
'< button class = "login-btn" onclick = "doForgotPassword()" > Reset-Link senden< / button > ' +
'< div class = "login-error" id = "login-error" > < / div > ' +
'< div class = "login-switch" > < a onclick = "renderLoginForm()" > ← Zur\u00fcck zur Anmeldung< / a > < / div > ';
2026-04-21 10:00:36 +02:00
document.getElementById('forgot-login').focus();
}
function renderForgotPickUser(loginEmail, candidates) {
var box = document.getElementById('login-box');
box.className = 'login-box';
var buttons = (candidates || []).map(function(c, i) {
var lab = esc(c.display_name || '') + (c.practice_name ? ' \u2013 ' + esc(c.practice_name) : '');
return '< button type = "button" class = "login-btn" style = "margin-bottom:8px;width:100%;text-align:left" onclick = "doForgotPickCandidate(' + i + ')" > ' + lab + '< / button > ';
}).join('');
box.innerHTML =
'< h2 > Passwort vergessen< / h2 > ' +
'< p > Diese E-Mail-Adresse ist < strong > mehreren Benutzerkonten< / strong > zugeordnet. Bitte w\u00e4hlen Sie Ihr Konto oder geben Sie Ihren Benutzernamen ein.< / p > ' +
'< div class = "login-field" style = "margin-top:10px" > ' + buttons + '< / div > ' +
'< div class = "login-field" > < label > Benutzername (exakt)< / label > < input type = "text" id = "forgot-pick-manual" autocomplete = "username" placeholder = "Wie in der Praxis angezeigt" > < / div > ' +
'< button class = "login-btn" onclick = "doForgotPickManual()" > Auswahl best\u00e4tigen< / button > ' +
'< div class = "login-error" id = "login-error" > < / div > ' +
'< div class = "login-switch" > < a onclick = "renderForgotPasswordForm()" > ← Zur\u00fcck< / a > < / div > ';
window._forgotCandidates = candidates;
window._forgotLoginEmail = loginEmail;
document.getElementById('forgot-pick-manual').focus();
}
async function doForgotPickCandidate(i) {
var c = (window._forgotCandidates || [])[i];
if (!c) return;
await _postForgotSecondStep(window._forgotLoginEmail, c.display_name, c.practice_id);
}
async function doForgotPickManual() {
var v = (document.getElementById('forgot-pick-manual').value || '').trim();
var errEl = document.getElementById('login-error');
var cand = window._forgotCandidates || [];
var hit = null;
for (var j = 0; j < cand.length ; j + + ) {
if ((cand[j].display_name || '') === v) { hit = cand[j]; break; }
}
if (!hit) {
errEl.style.color = '#842029';
errEl.textContent = 'Kein passender Benutzername. Bitte exakt wie in der Praxis w\u00e4hlen oder eingeben.';
return;
}
await _postForgotSecondStep(window._forgotLoginEmail, hit.display_name, hit.practice_id);
}
async function _postForgotSecondStep(loginEmail, displayName, practiceId) {
var errEl = document.getElementById('login-error');
errEl.style.color = '';
errEl.textContent = '';
try {
var r = await fetch(API_BASE + '/auth/forgot_password', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
login: loginEmail,
display_name: displayName,
practice_id: practiceId || getPracticeIdOrEmpty()
})
});
var d = await r.json().catch(function() { return {}; });
if (d.step === 'no_email' || (d.success === false & & d.step === 'no_email')) {
errEl.style.color = '#842029';
errEl.textContent = d.message || 'Keine E-Mail hinterlegt.';
return;
}
if (d.success === false & & d.message) {
errEl.style.color = '#842029';
errEl.textContent = d.message;
return;
}
errEl.style.color = '#155724';
errEl.textContent = d.message || 'Link wurde gesendet.';
} catch (e) {
errEl.style.color = '#842029';
errEl.textContent = 'Fehler beim Senden.';
}
2026-04-20 14:38:16 +02:00
}
async function doForgotPassword() {
2026-04-21 10:00:36 +02:00
var raw = (document.getElementById('forgot-login').value || '').trim();
2026-04-20 14:38:16 +02:00
var errEl = document.getElementById('login-error');
2026-04-21 10:00:36 +02:00
errEl.style.color = '';
if (!raw) { errEl.style.color = '#842029'; errEl.textContent = 'Bitte Benutzername oder E-Mail eingeben.'; return; }
2026-04-20 14:38:16 +02:00
try {
var r = await fetch(API_BASE + '/auth/forgot_password', {
2026-04-21 10:00:36 +02:00
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({login: raw, practice_id: getPracticeIdOrEmpty()})
2026-04-20 14:38:16 +02:00
});
2026-04-21 10:00:36 +02:00
var d = await r.json().catch(function() { return {}; });
if (d.step === 'pick_user' & & d.candidates & & d.candidates.length) {
renderForgotPickUser(d.login || raw, d.candidates);
return;
}
if (d.step === 'no_email' || (d.success === false & & d.step === 'no_email')) {
errEl.style.color = '#842029';
errEl.textContent = d.message || 'Keine E-Mail hinterlegt.';
return;
}
if (d.success === false & & d.message) {
errEl.style.color = '#842029';
errEl.textContent = d.message;
return;
}
2026-04-20 14:38:16 +02:00
errEl.style.color = '#155724';
2026-04-21 10:00:36 +02:00
errEl.textContent = d.message || 'Wenn ein passendes Konto existiert, wurde ein Link gesendet.';
} catch(e) {
errEl.style.color = '#842029';
errEl.textContent = 'Fehler beim Senden.';
}
}
function stripResetTokenFromUrl() {
try {
var p = window.location.pathname || '';
var sp = new URLSearchParams(window.location.search);
sp.delete('reset_token');
var q = sp.toString();
history.replaceState(null, '', p + (q ? '?' + q : ''));
} catch (e) {}
}
function renderResetPasswordInvalid(msg) {
stripResetTokenFromUrl();
var box = document.getElementById('login-box');
box.className = 'login-box';
box.innerHTML =
'< h2 > Link ung\u00fcltig< / h2 > ' +
'< p class = "login-error" id = "login-error" style = "display:block;color:#842029" > ' + esc(msg || 'Ung\u00fcltiger oder abgelaufener Link.') + '< / p > ' +
'< div class = "login-switch" > < a onclick = "renderLoginForm()" > Zur Anmeldung< / a > < / div > ';
2026-04-20 14:38:16 +02:00
}
function renderResetPasswordForm(resetToken) {
var box = document.getElementById('login-box');
2026-04-21 10:00:36 +02:00
box.className = 'login-box';
2026-04-20 14:38:16 +02:00
box.innerHTML =
2026-04-21 10:00:36 +02:00
'< h2 > Passwort zur\u00fccksetzen< / h2 > ' +
'< p > Sie haben eine Passwort-Zur\u00fccksetzung angefordert.< / p > ' +
2026-04-20 14:38:16 +02:00
'< p > Bitte w\u00e4hlen Sie ein neues Passwort (min. 4 Zeichen).< / p > ' +
'< div class = "login-field" > < label > Neues Passwort< / label > < input type = "password" id = "reset-pass" autocomplete = "new-password" > < / div > ' +
2026-04-21 10:00:36 +02:00
'< div class = "login-field" > < label > Neues Passwort best\u00e4tigen< / label > < input type = "password" id = "reset-pass2" autocomplete = "new-password" > < / div > ' +
2026-04-22 22:33:46 +02:00
'< button class = "login-btn" onclick = "doResetPassword(\'' + resetToken + '\')" > Neues Passwort festlegen< / button > ' +
2026-04-20 14:38:16 +02:00
'< div class = "login-error" id = "login-error" > < / div > ';
document.getElementById('reset-pass').focus();
}
async function doResetPassword(resetToken) {
var pass1 = document.getElementById('reset-pass').value;
var pass2 = document.getElementById('reset-pass2').value;
var errEl = document.getElementById('login-error');
if (!pass1 || pass1.length < 4 ) { errEl . textContent = 'Passwort muss mindestens 4 Zeichen haben.' ; return ; }
if (pass1 !== pass2) { errEl.textContent = 'Passw\u00f6rter stimmen nicht \u00fcberein.'; return; }
try {
var r = await fetch(API_BASE + '/auth/reset_password', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({reset_token: resetToken, password: pass1})
});
2026-04-21 10:00:36 +02:00
var d = await r.json().catch(function() { return {}; });
2026-04-20 14:38:16 +02:00
if (r.ok & & d.success) {
2026-04-21 10:00:36 +02:00
stripResetTokenFromUrl();
var pref = (d.display_name || d.email || '').trim();
if (pref) { try { localStorage.setItem('aza_last_login_user', pref); } catch (e2) {} }
2026-04-20 14:38:16 +02:00
errEl.style.color = '#155724';
2026-04-21 10:00:36 +02:00
errEl.textContent = 'Passwort wurde erfolgreich ge\u00e4ndert. Sie k\u00f6nnen sich jetzt anmelden.';
2026-04-20 14:38:16 +02:00
setTimeout(renderLoginForm, 2000);
} else {
2026-04-21 10:00:36 +02:00
var fail = d.detail;
if (typeof fail === 'string') {
errEl.textContent = fail;
} else if (Array.isArray(fail) & & fail[0] & & fail[0].msg) {
errEl.textContent = fail.map(function(x) { return x.msg; }).join(' ');
} else {
errEl.textContent = d.message || 'Passwort konnte nicht zur\u00fcckgesetzt werden.';
}
2026-04-20 14:38:16 +02:00
}
2026-04-21 10:00:36 +02:00
} catch(e) { errEl.textContent = 'Passwort konnte nicht zur\u00fcckgesetzt werden.'; }
2026-04-20 14:38:16 +02:00
}
function renderRegisterForm() {
var box = document.getElementById('login-box');
2026-04-21 10:00:36 +02:00
box.className = 'login-box login-register';
2026-04-20 14:38:16 +02:00
box.innerHTML =
2026-04-21 10:00:36 +02:00
'< h2 > registrieren Sie sich f\u00fcr den Chat< / h2 > ' +
2026-04-20 14:38:16 +02:00
'< p > Ihr Administrator hat Ihnen einen Einladungscode gegeben.< / p > ' +
2026-04-21 10:00:36 +02:00
'< div class = "login-field" > < label > Einladungscode< / label > < input type = "text" id = "reg-code" placeholder = "z.B. CHAT-AB12-CD34" > < / div > ' +
2026-04-20 14:38:16 +02:00
'< div class = "login-field" > < label > Ihr Name< / label > < input type = "text" id = "reg-name" autocomplete = "username" placeholder = "z.B. Sandra M\u00fcller" > < / div > ' +
'< div class = "login-field" > < label > Passwort (min. 4 Zeichen)< / label > < input type = "password" id = "reg-pass" autocomplete = "new-password" > < / div > ' +
'< div class = "login-field" > < label > Ihre Rolle< / label > ' +
'< select id = "reg-role" > ' +
'< option value = "empfang" > Empfang< / option > ' +
'< option value = "mpa" > MPA< / option > ' +
'< option value = "arzt" > Arzt / \u00c4rztin< / option > ' +
'< / select > ' +
'< / div > ' +
'< button class = "login-btn" onclick = "doRegister()" > Registrieren< / button > ' +
'< div class = "login-error" id = "login-error" > < / div > ' +
'< div class = "login-switch" > < a onclick = "renderLoginForm()" > Bereits registriert? → Anmelden< / a > < / div > ';
document.getElementById('reg-code').focus();
}
async function onAuthSuccess() {
var me = await checkAuth();
if (!me) return false;
currentSession = me;
hideLoginOverlay();
updateSbMe();
loadUsersFromServer();
loadTasksFromServer();
loadMessages();
loadPracticeInfo();
startPolling();
return true;
}
async function doSetup() {
var practiceName = (document.getElementById('setup-practice').value || '').trim();
var name = (document.getElementById('setup-name').value || '').trim();
var email = (document.getElementById('setup-email').value || '').trim();
var pass = document.getElementById('setup-pass').value || '';
var errEl = document.getElementById('login-error');
if (!practiceName) { errEl.textContent = 'Bitte Praxisnamen eingeben.'; return; }
if (!name) { errEl.textContent = 'Bitte Ihren Namen eingeben.'; return; }
if (!pass || pass.length < 4 ) { errEl . textContent = 'Passwort (min. 4 Zeichen) erforderlich.' ; return ; }
try {
var r = await fetch(API_BASE + '/auth/setup', {
2026-04-21 10:00:36 +02:00
method: 'POST', credentials: 'include',
headers: {'Content-Type': 'application/json'},
2026-04-20 14:38:16 +02:00
body: JSON.stringify({name: name, password: pass, practice_name: practiceName, email: email})
});
if (r.ok) {
2026-04-21 10:00:36 +02:00
try {
var sd = await r.json();
if (sd.practice_id) localStorage.setItem('aza_practice_id', sd.practice_id);
} catch(e) {}
2026-04-20 14:38:16 +02:00
if (!await onAuthSuccess()) errEl.textContent = 'Einrichtung fehlgeschlagen.';
} else {
var d = await r.json().catch(function(){ return {}; });
errEl.textContent = d.detail || d.error || 'Einrichtung fehlgeschlagen.';
}
} catch(e) { errEl.textContent = 'Verbindungsfehler.'; }
}
async function doLogin() {
var name = (document.getElementById('login-name').value || '').trim();
var pass = document.getElementById('login-pass').value || '';
var errEl = document.getElementById('login-error');
2026-04-21 10:00:36 +02:00
errEl.style.color = '';
if (!name || !pass) { errEl.textContent = 'Benutzername/E-Mail und Passwort erforderlich.'; return; }
2026-04-20 14:38:16 +02:00
try {
var r = await fetch(API_BASE + '/auth/login', {
2026-04-21 10:00:36 +02:00
method: 'POST', credentials: 'include',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
name: name,
password: pass,
practice_id: getPracticeIdOrEmpty()
})
2026-04-20 14:38:16 +02:00
});
if (r.ok) {
2026-04-21 10:00:36 +02:00
try {
var ld = await r.json();
if (ld.practice_id) localStorage.setItem('aza_practice_id', ld.practice_id);
var saveUser = (ld.display_name || name).trim();
localStorage.setItem('aza_last_login_user', saveUser);
} catch(e) {}
2026-04-20 14:38:16 +02:00
if (!await onAuthSuccess()) errEl.textContent = 'Anmeldung fehlgeschlagen.';
} else {
var d = await r.json().catch(function(){ return {}; });
2026-04-21 10:00:36 +02:00
errEl.style.color = '#842029';
var det = d.detail;
if (typeof det === 'object' & & det !== null & & det.message) {
errEl.textContent = det.message;
} else if (typeof det === 'string') {
errEl.textContent = det;
} else {
errEl.textContent = d.error || 'Anmeldung fehlgeschlagen.';
}
2026-04-20 14:38:16 +02:00
}
2026-04-21 10:00:36 +02:00
} catch(e) { errEl.style.color = '#842029'; errEl.textContent = 'Verbindungsfehler.'; }
2026-04-20 14:38:16 +02:00
}
async function doRegister() {
var code = (document.getElementById('reg-code').value || '').trim();
var name = (document.getElementById('reg-name').value || '').trim();
var pass = document.getElementById('reg-pass').value || '';
var role = document.getElementById('reg-role').value || 'empfang';
var errEl = document.getElementById('login-error');
if (!code || !name || !pass) { errEl.textContent = 'Alle Felder erforderlich.'; return; }
try {
var r = await fetch(API_BASE + '/auth/register', {
2026-04-21 10:00:36 +02:00
method: 'POST', credentials: 'include',
headers: {'Content-Type': 'application/json'},
2026-04-20 14:38:16 +02:00
body: JSON.stringify({invite_code: code, name: name, password: pass, role: role})
});
if (r.ok) {
2026-04-21 10:00:36 +02:00
try {
var rd = await r.json();
if (rd.practice_id) localStorage.setItem('aza_practice_id', rd.practice_id);
} catch(e) {}
2026-04-20 14:38:16 +02:00
if (!await onAuthSuccess()) errEl.textContent = 'Registrierung fehlgeschlagen.';
} else {
var d = await r.json().catch(function(){ return {}; });
errEl.textContent = d.detail || d.error || 'Registrierung fehlgeschlagen.';
}
} catch(e) { errEl.textContent = 'Verbindungsfehler.'; }
}
async function doLogout() {
2026-04-21 10:00:36 +02:00
try { await fetch(API_BASE + '/auth/logout', {method: 'POST', credentials: 'include'}); } catch(e) {}
2026-04-20 14:38:16 +02:00
currentSession = null;
practiceUsers = [];
serverTasks = [];
allMessages = [];
allThreads = [];
lastDataHash = '';
previousThreadIds = new Set();
stopPolling();
stopRepeatTone();
updateSbMe();
document.getElementById('sb-users').innerHTML = '';
document.getElementById('todo-list').innerHTML = '';
document.getElementById('chat-content').innerHTML = '< div class = "conv-empty" > Nachrichten werden geladen… < / div > ';
document.getElementById('count-badge').textContent = '\u2013';
document.getElementById('sb-badge-open').textContent = '';
showLoginOverlay();
}
2026-04-19 20:41:37 +02:00
/* ===================================================================
INIT
=================================================================== */
2026-04-20 14:38:16 +02:00
(async function init() {
2026-04-19 20:41:37 +02:00
var sel = document.getElementById('tone-select');
TONE_PRESETS.forEach(function(t, i) {
var o = document.createElement('option');
o.value = i; o.textContent = t.name;
if (i === currentToneIdx) o.selected = true;
sel.appendChild(o);
});
document.getElementById('vol-slider').value = Math.round(volume * 100);
document.getElementById('vol-display').textContent = Math.round(volume * 100) + '%';
initRepeatUI();
updateSortBtn();
updateSoundBtn();
applyFontSize();
2026-04-22 22:33:46 +02:00
setupReplyBarDropPaste();
initShellPinUi();
2026-04-19 20:41:37 +02:00
2026-04-20 14:38:16 +02:00
var urlParams = new URLSearchParams(window.location.search);
var resetToken = urlParams.get('reset_token');
if (resetToken) {
stopPolling();
document.getElementById('login-overlay').classList.remove('hidden');
2026-04-21 10:00:36 +02:00
try {
var vr = await fetch(
API_BASE + '/auth/reset_verify?reset_token=' + encodeURIComponent(resetToken)
);
var vd = await vr.json().catch(function() { return {valid: false}; });
if (!vd.valid) {
renderResetPasswordInvalid(vd.detail || 'Ung\u00fcltiger oder abgelaufener Link.');
return;
}
renderResetPasswordForm(resetToken);
} catch (e) {
renderResetPasswordInvalid('Verbindungsfehler. Bitte sp\u00e4ter erneut versuchen.');
}
2026-04-20 14:38:16 +02:00
return;
}
var me = await checkAuth();
2026-04-21 10:00:36 +02:00
if (me & & me.practice_id) {
try { localStorage.setItem('aza_practice_id', me.practice_id); } catch(e) {}
}
2026-04-20 14:38:16 +02:00
if (!me) {
var inviteParam = urlParams.get('invite');
stopPolling();
document.getElementById('login-overlay').classList.remove('hidden');
if (inviteParam) {
renderRegisterForm();
setTimeout(function() {
var codeInput = document.getElementById('reg-code');
if (codeInput) codeInput.value = inviteParam;
}, 50);
} else {
showLoginOverlay();
2026-04-19 22:22:11 +02:00
}
2026-04-20 14:38:16 +02:00
return;
}
currentSession = me;
hideLoginOverlay();
updateSbMe();
loadUsersFromServer();
loadTasksFromServer();
loadMessages();
loadPracticeInfo();
startPolling();
})();
2026-04-19 22:22:11 +02:00
2026-04-19 20:41:37 +02:00
/* ===================================================================
SIDEBAR
=================================================================== */
function setView(view) {
activeView = view;
activeThreadId = null;
document.querySelectorAll('.sb-item').forEach(function(el) {
el.classList.toggle('active', el.getAttribute('data-view') === view);
});
renderView();
}
function renderSidebarUsers() {
var el = document.getElementById('sb-users');
2026-04-20 14:38:16 +02:00
if (!practiceUsers.length) {
2026-04-19 20:41:37 +02:00
el.innerHTML = '< div style = "padding:4px 18px;font-size:.78rem;color:#bbb" > Keine Benutzer< / div > ';
return;
2026-04-16 15:23:14 +02:00
}
2026-04-20 14:38:16 +02:00
el.innerHTML = practiceUsers.map(function(u) {
var isMe = currentSession & & u.display_name === currentSession.display_name;
return '< div class = "sb-user' + (isMe ? ' me' : '') + '" > ' +
2026-04-19 20:41:37 +02:00
'< span class = "sb-dot ' + (isMe ? 'online' : 'offline') + '" > < / span > ' +
2026-04-20 14:38:16 +02:00
'< span class = "sb-user-name" > ' + esc(u.display_name) + '< / span > ' +
(u.role ? '< span class = "sb-group-tag" > ' + esc(u.role) + '< / span > ' : '') +
2026-04-19 20:41:37 +02:00
'< / div > ';
}).join('');
2026-04-16 15:23:14 +02:00
}
2026-04-19 20:41:37 +02:00
function updateSbMe() {
2026-04-20 14:38:16 +02:00
var nameEl = document.getElementById('sb-me-name');
var roleEl = document.getElementById('sb-me-role');
if (currentSession) {
nameEl.textContent = currentSession.display_name;
roleEl.textContent = currentSession.role || '';
} else {
nameEl.textContent = 'Nicht angemeldet';
roleEl.textContent = '';
}
2026-04-19 20:41:37 +02:00
}
function toggleSbAddUser() {
var el = document.getElementById('sb-add-input');
var isOpen = el.classList.toggle('open');
if (isOpen) {
var inp = document.getElementById('sb-new-user');
inp.value = '';
inp.focus();
}
}
2026-04-20 14:38:16 +02:00
async function addUserFromSidebar() {
2026-04-19 20:41:37 +02:00
var inp = document.getElementById('sb-new-user');
var name = (inp.value || '').trim();
if (!name) return;
2026-04-20 14:38:16 +02:00
try {
await apiFetch(API_BASE + '/users', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name: name})
});
} catch(e) {}
2026-04-19 20:41:37 +02:00
inp.value = '';
document.getElementById('sb-add-input').classList.remove('open');
2026-04-20 14:38:16 +02:00
loadUsersFromServer();
}
/* ===================================================================
USERS (server only)
=================================================================== */
async function loadUsersFromServer() {
try {
var r = await apiFetch(API_BASE + '/users');
var d = await r.json();
practiceUsers = d.users_full || [];
renderSidebarUsers();
} catch(e) {}
2026-04-16 15:23:14 +02:00
}
2026-04-19 20:41:37 +02:00
/* ===================================================================
MESSAGES
=================================================================== */
2026-04-16 15:23:14 +02:00
async function loadMessages() {
try {
2026-04-20 14:38:16 +02:00
var r = await apiFetch(API_BASE + '/messages');
2026-04-19 20:41:37 +02:00
var d = await r.json();
var raw = JSON.stringify(d.messages || []);
if (raw === lastDataHash) return;
lastDataHash = raw;
allMessages = d.messages || [];
allMessages.forEach(function(m) { if (!m.thread_id) m.thread_id = m.id; });
2026-04-20 14:38:16 +02:00
if (currentSession) {
2026-04-22 22:33:46 +02:00
var myName = currentSession.display_name;
2026-04-19 20:41:37 +02:00
allMessages = allMessages.filter(function(m) {
var rcpt = m.extras & & m.extras.recipient;
2026-04-22 22:33:46 +02:00
var abs = m.absender || '';
var senderCore = abs.split('(')[0].trim();
var iAmSender = senderCore === myName;
return !rcpt || rcpt === 'Alle' || rcpt === myName || iAmSender;
2026-04-19 20:41:37 +02:00
});
}
buildThreads();
var currentIds = new Set(allThreads.map(function(t){ return t.tid; }));
var hasNewDesktop = false;
currentIds.forEach(function(tid) {
if (!previousThreadIds.has(tid)) {
var t = allThreads.find(function(th){ return th.tid === tid; });
if (t & & !(t.root.absender || '').includes('Empfang')) hasNewDesktop = true;
}
});
if (previousThreadIds.size > 0 & & hasNewDesktop & & !justSentReply) {
playTone(currentToneIdx);
startRepeatTone();
2026-04-16 15:23:14 +02:00
}
2026-04-19 20:41:37 +02:00
justSentReply = false;
previousThreadIds = currentIds;
var openCount = allThreads.filter(function(t){ return t.root.status === 'offen'; }).length;
document.getElementById('count-badge').textContent = openCount + ' offen';
document.getElementById('sb-badge-open').textContent = openCount || '';
renderView();
2026-04-16 15:23:14 +02:00
} catch (e) {
2026-04-20 14:38:16 +02:00
if (e.message === 'auth') return;
2026-04-19 20:41:37 +02:00
document.getElementById('chat-content').innerHTML =
'< div class = "conv-empty" > Backend nicht erreichbar.< br > Bitte Verbindung prü fen.< / div > ';
2026-04-16 15:23:14 +02:00
}
}
2026-04-19 20:41:37 +02:00
function buildThreads() {
var threadMap = {};
allMessages.forEach(function(m) {
if (!threadMap[m.thread_id]) threadMap[m.thread_id] = [];
threadMap[m.thread_id].push(m);
});
allThreads = [];
Object.keys(threadMap).forEach(function(tid) {
var arr = threadMap[tid].slice().sort(function(a,b){
return (a.empfangen||'').localeCompare(b.empfangen||'');
});
var root = arr.find(function(m){ return m.id === tid; }) || arr[0];
var replies = arr.filter(function(m){ return m.id !== root.id; });
var lastTime = arr[arr.length-1].empfangen || arr[arr.length-1].zeitstempel || '';
allThreads.push({ tid:tid, root:root, replies:replies, lastTime:lastTime, all:arr });
});
allThreads.sort(function(a,b) {
var ao = a.root.status !== 'erledigt' ? 1 : 0;
var bo = b.root.status !== 'erledigt' ? 1 : 0;
if (ao !== bo) return bo - ao;
return sortNewestFirst
? b.lastTime.localeCompare(a.lastTime)
: a.lastTime.localeCompare(b.lastTime);
});
}
/* ===================================================================
VIEW RENDERING
=================================================================== */
function renderView() {
if (activeThreadId) {
renderConversation(activeThreadId);
} else {
renderThreadList();
}
}
function renderThreadList() {
var c = document.getElementById('chat-content');
var backBtn = document.getElementById('ctb-back');
var titleEl = document.getElementById('ctb-title');
var actionsEl = document.getElementById('ctb-actions');
backBtn.classList.remove('visible');
actionsEl.innerHTML = '';
document.getElementById('chat-input-bar').classList.remove('visible');
var filtered;
if (activeView === 'archiv') {
filtered = allThreads.filter(function(t){ return t.root.status === 'erledigt'; });
titleEl.textContent = 'Archiv';
} else {
filtered = allThreads.filter(function(t){ return t.root.status === 'offen'; });
titleEl.textContent = 'Allgemein (' + filtered.length + ' offen)';
}
if (!filtered.length) {
var hint = activeView === 'archiv' ? 'Keine erledigten Nachrichten.' : 'Keine offenen Nachrichten.';
c.innerHTML = '< div class = "conv-empty" > ' + hint + '< / div > ';
2026-04-16 15:23:14 +02:00
return;
}
2026-04-19 20:41:37 +02:00
c.innerHTML = filtered.map(function(t) {
var m = t.root;
var isDone = m.status === 'erledigt';
var isTask = m.extras & & m.extras.is_task;
var replyCount = t.replies.length;
var time = (m.zeitstempel || m.empfangen || '').slice(11, 16) || '';
var date = (m.zeitstempel || m.empfangen || '').slice(0, 10) || '';
var today = new Date().toISOString().slice(0, 10);
var timeDisplay = date === today ? time : date.slice(5) + ' ' + time;
return '< div class = "tl-item' + (isDone ? ' done' : '') + '" onclick = "selectThread(\'' + t.tid + '\')" > ' +
'< div class = "tl-row1" > ' +
'< div class = "tl-patient" > ' + (isTask ? '\u2611 ' : '') + esc(m.patient || 'Ohne Patientenangabe') + '< / div > ' +
'< div class = "tl-time" > ' + esc(timeDisplay) + '< / div > ' +
'< / div > ' +
'< div class = "tl-row2" > ' +
'< div class = "tl-sender" > ' + esc(m.absender || '\u2013') + '< / div > ' +
(replyCount ? '< span class = "tl-replies" > ' + replyCount + ' Antw.< / span > ' : '') +
'< span class = "tl-status ' + (isDone ? 'erledigt' : 'offen') + '" > ' + (isDone ? 'Erledigt' : 'Offen') + '< / span > ' +
'< / div > ' +
'< / div > ';
2026-04-16 15:23:14 +02:00
}).join('');
}
2026-04-19 20:41:37 +02:00
function selectThread(tid) {
activeThreadId = tid;
stopRepeatTone();
renderConversation(tid);
2026-04-16 15:23:14 +02:00
}
2026-04-19 20:41:37 +02:00
function backToList() {
activeThreadId = null;
renderView();
}
function renderConversation(tid) {
var t = allThreads.find(function(th){ return th.tid === tid; });
if (!t) { backToList(); return; }
var m = t.root;
var isDone = m.status === 'erledigt';
var c = document.getElementById('chat-content');
var backBtn = document.getElementById('ctb-back');
var titleEl = document.getElementById('ctb-title');
var actionsEl = document.getElementById('ctb-actions');
var inputBar = document.getElementById('chat-input-bar');
backBtn.classList.add('visible');
titleEl.textContent = m.patient || 'Unterhaltung';
inputBar.classList.add('visible');
actionsEl.innerHTML =
(!isDone ? '< button class = "ctb-btn done" onclick = "markDone(\'' + m.id + '\')" > ✓ Erledigt< / button > ' : '') +
'< button class = "ctb-btn" onclick = "copyThread(\'' + tid + '\')" > Kopieren< / button > ' +
'< button class = "ctb-btn" onclick = "saveThread(\'' + tid + '\')" > Speichern< / button > ' +
'< button class = "ctb-btn" onclick = "printThread(\'' + tid + '\')" > Drucken< / button > ' +
'< button class = "ctb-btn del" onclick = "deleteMsg(\'' + m.id + '\')" > Lö schen< / button > ';
var html = '';
html += '< div class = "conv-doc" > ';
html += '< div class = "conv-doc-head" > ';
html += '< span class = "conv-doc-sender" > ' + esc(m.absender || '\u2013') + '< / span > ';
html += '< span class = "conv-doc-time" > ' + esc(m.zeitstempel || m.empfangen || '') + '< / span > ';
html += '< / div > ';
var fields = [];
if (m.therapieplan) fields.push({l:'Therapieplan', v:m.therapieplan});
if (m.procedere) fields.push({l:'Procedere', v:m.procedere});
if (m.kommentar) fields.push({l:'Kommentar', v:m.kommentar});
if (m.medikamente) fields.push({l:'Medikamente', v:m.medikamente});
if (fields.length) {
fields.forEach(function(f) {
html += '< div class = "conv-field" > ' +
'< div class = "conv-field-label" onclick = "toggleConvField(this)" > ' +
'< span class = "tri" > ▼ < / span > ' + esc(f.l) +
'< / div > ' +
'< div class = "conv-field-value" > ' + esc(f.v) + '< / div > ' +
'< / div > ';
});
} else {
html += '< div style = "color:#999;font-size:.85rem" > Keine Inhalte< / div > ';
}
if (m.extras & & m.extras.attachments & & m.extras.attachments.length) {
html += '< div class = "conv-attach" > ';
m.extras.attachments.forEach(function(a) {
var isImg = /\.(jpg|jpeg|png|gif|bmp|webp)$/i.test(a.name);
if (isImg & & a.data) {
var ext = a.name.split('.').pop().toLowerCase();
var mime = ext === 'png' ? 'image/png' : (ext === 'gif' ? 'image/gif' : 'image/jpeg');
html += '< div style = "margin:4px 0" > < img src = "data:' + mime + ' ; base64 , ' + a . data +
'" alt="' + esc(a.name) + '">< div style = "font-size:.7rem;color:#8a9aaa" > ' + esc(a.name) + '< / div > < / div > ';
} else {
html += '< div style = "margin:2px 0;font-size:.85rem" > \uD83D\uDCCE ' + esc(a.name) + '< / div > ';
}
});
html += '< / div > ';
}
html += '< / div > ';
if (t.replies.length) {
var sortedReplies = sortNewestFirst ? t.replies.slice().reverse() : t.replies;
sortedReplies.forEach(function(r) {
var isFromEmpfang = (r.absender || '').includes('Empfang');
2026-04-22 22:33:46 +02:00
var attHtml = '';
var ratt = r.extras & & r.extras.attachments;
if (ratt & & ratt.length) {
ratt.forEach(function(a) {
if (a.data & & a.name & & /\.(jpg|jpeg|png|gif|bmp|webp)$/i.test(a.name)) {
var ext = a.name.split('.').pop().toLowerCase();
var mime = ext === 'png' ? 'image/png' : (ext === 'gif' ? 'image/gif' : 'image/jpeg');
attHtml += '< div style = "margin-top:6px" > < img src = "data:' + mime + ' ; base64 , ' + a . data +
'" alt="' + esc(a.name) + '" style="max-width:100%;max-height:220px;border-radius:6px">< / div > ';
}
});
}
2026-04-19 20:41:37 +02:00
html += '< div class = "conv-msg ' + (isFromEmpfang ? 'from-me' : 'from-other') + '" > ' +
'< div class = "conv-msg-meta" > ' +
'< span class = "conv-msg-sender" > ' + esc(r.absender || '') + '< / span > ' +
'< span > ' + esc(r.zeitstempel || r.empfangen || '') + '< / span > ' +
'< / div > ' +
2026-04-22 22:33:46 +02:00
'< div class = "conv-msg-text" > ' + esc(r.kommentar || '') + '< / div > ' + attHtml +
2026-04-19 20:41:37 +02:00
'< / div > ';
});
}
c.innerHTML = html;
c.scrollTop = sortNewestFirst ? 0 : c.scrollHeight;
}
function toggleConvField(labelEl) {
var tri = labelEl.querySelector('.tri');
var val = labelEl.nextElementSibling;
if (!val) return;
var hide = !val.classList.contains('collapsed');
val.classList.toggle('collapsed', hide);
if (tri) tri.classList.toggle('closed', hide);
}
/* ===================================================================
INLINE REPLY
=================================================================== */
2026-04-22 22:33:46 +02:00
function updateReplyAttHint() {
var el = document.getElementById('reply-att-hint');
if (!el) return;
el.textContent = pendingReplyAttachments.length
? pendingReplyAttachments.length + ' Bild(er) angeh\u00e4ngt'
: '';
}
function queueReplyImageFiles(files) {
Array.from(files || []).forEach(function(file) {
if (!file.type || file.type.indexOf('image/') !== 0) return;
if (file.size > REPLY_ATTACH_MAX_BYTES) return;
var fr = new FileReader();
fr.onload = function() {
var s = fr.result || '';
var i = s.indexOf(',');
pendingReplyAttachments.push({
name: file.name || 'bild.png',
data: i >= 0 ? s.slice(i + 1) : s
});
updateReplyAttHint();
};
fr.readAsDataURL(file);
});
}
function setupReplyBarDropPaste() {
var ta = document.getElementById('reply-text');
var bar = document.getElementById('chat-input-bar');
if (!ta || !bar) return;
ta.addEventListener('paste', function(e) {
var items = e.clipboardData & & e.clipboardData.items;
if (!items) return;
for (var i = 0; i < items.length ; i + + ) {
if (items[i].type & & items[i].type.indexOf('image/') === 0) {
e.preventDefault();
var f = items[i].getAsFile();
if (f) queueReplyImageFiles([f]);
}
}
});
bar.addEventListener('dragover', function(e) { e.preventDefault(); });
bar.addEventListener('drop', function(e) {
e.preventDefault();
if (e.dataTransfer & & e.dataTransfer.files) queueReplyImageFiles(e.dataTransfer.files);
});
}
function openNewChatDialog() {
if (!currentSession) {
showLoginOverlay();
return;
}
var sel = document.getElementById('nc-peer-select');
sel.innerHTML = '< option value = "" > \u2014 Benutzer w\u00e4hlen \u2014< / option > ';
(practiceUsers || []).forEach(function(u) {
if (!u || u.display_name === currentSession.display_name) return;
var o = document.createElement('option');
o.value = u.display_name;
o.textContent = u.display_name + (u.role ? ' (' + u.role + ')' : '');
sel.appendChild(o);
});
var gr = document.querySelector('input[name="nc-mode"][value="general"]');
if (gr) gr.checked = true;
syncNcPeerSelect();
document.getElementById('new-chat-overlay').classList.remove('hidden');
}
function closeNewChatDialog() {
document.getElementById('new-chat-overlay').classList.add('hidden');
}
function syncNcPeerSelect() {
var mode = (document.querySelector('input[name="nc-mode"]:checked') || {}).value;
document.getElementById('nc-peer-select').disabled = mode !== 'dm';
}
function launchNewChatWindow() {
var mode = (document.querySelector('input[name="nc-mode"]:checked') || {}).value || 'general';
var peer = (document.getElementById('nc-peer-select').value || '').trim();
if (mode === 'dm' & & !peer) {
alert('Bitte einen Benutzer ausw\u00e4hlen.');
return;
}
closeNewChatDialog();
var q = mode === 'general' ? 'mode=general' : ('mode=dm& peer=' + encodeURIComponent(peer));
var url = window.location.origin + '/empfang/chatwin.html?' + q;
var wname = 'aza_empfang_chat_' + String(peer || 'allgemein').replace(/\W/g, '_') + '_' + Date.now();
var w = window.open(url, wname, 'width=560,height=720,menubar=no,toolbar=no,scrollbars=yes,resizable=yes');
if (!w) {
alert('Das Chat-Fenster wurde blockiert. Bitte Popups f\u00fcr diese Seite erlauben, oder die Adresse manuell \u00f6ffnen:\n\n' + url);
}
}
2026-04-19 20:41:37 +02:00
async function sendReply() {
2026-04-20 14:38:16 +02:00
if (!activeThreadId || !currentSession) return;
2026-04-19 20:41:37 +02:00
var ta = document.getElementById('reply-text');
var text = ta.value.trim();
2026-04-22 22:33:46 +02:00
if (!text & & !pendingReplyAttachments.length) return;
2026-04-19 20:41:37 +02:00
stopDictation();
var t = allThreads.find(function(th){ return th.tid === activeThreadId; });
2026-04-20 14:38:16 +02:00
var absenderName = currentSession.display_name + ' (Empfang)';
2026-04-19 20:41:37 +02:00
var replyToAbsender = t ? (t.root.absender || '') : '';
try {
2026-04-22 22:33:46 +02:00
var extras = { reply_to: activeThreadId, reply_to_absender: replyToAbsender };
if (pendingReplyAttachments.length) {
extras.attachments = pendingReplyAttachments.slice();
pendingReplyAttachments = [];
updateReplyAttHint();
}
2026-04-19 20:41:37 +02:00
var payload = {
medikamente: '', therapieplan: '', procedere: '',
2026-04-22 22:33:46 +02:00
kommentar: text || (extras.attachments & & extras.attachments.length ? '\u200b' : ''),
patient: '',
2026-04-19 20:41:37 +02:00
absender: absenderName,
zeitstempel: new Date().toISOString().slice(0,19).replace('T',' '),
2026-04-22 22:33:46 +02:00
extras: extras
2026-04-19 20:41:37 +02:00
};
2026-04-20 14:38:16 +02:00
var r = await apiFetch(API_BASE + '/send', {
2026-04-19 20:41:37 +02:00
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (r.ok) {
ta.value = '';
ta.style.height = '38px';
justSentReply = true;
lastDataHash = '';
loadMessages();
notifyOtherTabs();
}
} catch(e) {}
}
function autoGrowTextarea(el) {
el.style.height = '38px';
el.style.height = Math.min(el.scrollHeight, 120) + 'px';
}
/* ===================================================================
ACTIONS (markDone, delete, copy, save, print)
=================================================================== */
async function markDone(id) {
2026-04-20 14:38:16 +02:00
try { await apiFetch(API_BASE + '/messages/' + id + '/done', {method:'POST'}); } catch(e) { return; }
2026-04-19 20:41:37 +02:00
stopRepeatTone();
lastDataHash = '';
loadMessages();
notifyOtherTabs();
}
async function deleteMsg(id) {
2026-04-20 14:38:16 +02:00
try { await apiFetch(API_BASE + '/messages/' + id, {method:'DELETE'}); } catch(e) { return; }
2026-04-19 20:41:37 +02:00
activeThreadId = null;
lastDataHash = '';
loadMessages();
notifyOtherTabs();
}
function copyThread(tid) {
var t = allThreads.find(function(th){ return th.tid === tid; });
if (!t) return;
var m = t.root;
var parts = [];
2026-04-16 15:23:14 +02:00
if (m.patient) parts.push('Patient: ' + m.patient);
if (m.therapieplan) parts.push('Therapieplan:\n' + m.therapieplan);
if (m.procedere) parts.push('Procedere:\n' + m.procedere);
if (m.kommentar) parts.push('Kommentar:\n' + m.kommentar);
2026-04-19 20:41:37 +02:00
parts.push('Absender: ' + (m.absender||'\u2013') + ' \u00b7 ' + (m.zeitstempel||''));
if (t.replies.length) {
parts.push('\n--- Chat-Verlauf ---');
t.replies.forEach(function(r) {
parts.push((r.absender||'') + ' (' + (r.zeitstempel||'') + '):\n' + (r.kommentar||''));
});
}
navigator.clipboard.writeText(parts.join('\n\n')).catch(function(){});
stopRepeatTone();
2026-04-16 15:23:14 +02:00
}
2026-04-19 20:41:37 +02:00
function saveThread(tid) {
var t = allThreads.find(function(th){ return th.tid === tid; });
if (!t) return;
var m = t.root;
var lines = ['=== AZA Empfang Chat-Verlauf ===',
'Patient/Nr.: ' + (m.patient || '-'),
'Absender: ' + (m.absender || '-'),
'Zeitpunkt: ' + (m.zeitstempel || m.empfangen || ''), ''];
if (m.therapieplan) lines.push('THERAPIEPLAN:\n' + m.therapieplan + '\n');
if (m.procedere) lines.push('PROCEDERE:\n' + m.procedere + '\n');
if (m.kommentar) lines.push('KOMMENTAR:\n' + m.kommentar + '\n');
if (t.replies.length) {
lines.push('--- Chat-Verlauf ---');
t.replies.forEach(function(r) {
lines.push('[' + (r.absender||'') + '] ' + (r.zeitstempel||r.empfangen||''));
lines.push(r.kommentar || ''); lines.push('');
});
}
var blob = new Blob([lines.join('\n')], {type:'text/plain;charset=utf-8'});
var a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'chat_' + (m.patient || tid).replace(/[^a-zA-Z0-9]/g, '_') + '.txt';
a.click(); URL.revokeObjectURL(a.href);
2026-04-16 15:23:14 +02:00
}
2026-04-19 20:41:37 +02:00
function printThread(tid) {
var t = allThreads.find(function(th){ return th.tid === tid; });
if (!t) return;
var m = t.root;
var w = window.open('', '_blank', 'width=600,height=600');
var h = '< html > < head > < title > Empfang< / title > ' +
'< style > b o d y { f o n t - f a m i l y : " S e g o e U I " , s a n s - s e r i f ; p a d d i n g : 3 0 p x ; c o l o r : # 1 a 2 a 3 a } ' +
'h2{color:#5B8DB3;margin-bottom:16px}' +
'.f{margin-bottom:12px}.fl{font-size:.8rem;font-weight:bold;color:#5B8DB3;text-transform:uppercase}' +
'.fv{margin-top:2px;white-space:pre-wrap}' +
'.meta{font-size:.8rem;color:#888;margin-top:20px;border-top:1px solid #ddd;padding-top:10px}' +
'.reply{margin-top:10px;padding:8px 12px;background:#f8f8f8;border-left:3px solid #999;border-radius:4px}' +
'.rs{font-weight:bold;font-size:.85rem}< / style > < / head > < body > ' +
'< h2 > ' + esc(m.patient||'Empfangsnachricht') + '< / h2 > ' +
(m.therapieplan ? '< div class = "f" > < div class = "fl" > Therapieplan< / div > < div class = "fv" > ' + esc(m.therapieplan) + '< / div > < / div > ' : '') +
(m.procedere ? '< div class = "f" > < div class = "fl" > Procedere< / div > < div class = "fv" > ' + esc(m.procedere) + '< / div > < / div > ' : '') +
(m.kommentar ? '< div class = "f" > < div class = "fl" > Kommentar< / div > < div class = "fv" > ' + esc(m.kommentar) + '< / div > < / div > ' : '') +
'< div class = "meta" > Absender: ' + esc(m.absender||'\u2013') + ' \u00b7 ' + esc(m.zeitstempel||m.empfangen||'') + '< / div > ' +
(t.replies.length ? '< h3 style = "margin-top:20px" > Chat-Verlauf< / h3 > ' +
t.replies.map(function(r){
return '< div class = "reply" > < div class = "rs" > ' + esc(r.absender||'') + ' · ' +
esc(r.zeitstempel||r.empfangen||'') + '< / div > < div class = "fv" > ' + esc(r.kommentar||'') + '< / div > < / div > ';
}).join('') : '') +
'< / body > < / html > ';
w.document.write(h); w.document.close(); w.print();
}
async function doCleanup() {
var days = parseInt(document.getElementById('cleanup-days').value, 10) || 30;
var el = document.getElementById('cleanup-result');
el.textContent = 'Bereinige...';
try {
2026-04-20 14:38:16 +02:00
var r = await apiFetch(API_BASE + '/cleanup', {
2026-04-19 20:41:37 +02:00
method:'POST', headers:{'Content-Type':'application/json'},
body:JSON.stringify({max_age_days:days})
});
var d = await r.json();
el.textContent = d.removed + ' entfernt, ' + d.remaining + ' verbleibend';
if (d.removed > 0) { lastDataHash = ''; loadMessages(); }
2026-04-20 14:38:16 +02:00
} catch(e) {
if (e.message !== 'auth') el.textContent = 'Fehler';
}
2026-04-19 20:41:37 +02:00
}
2026-04-22 22:33:46 +02:00
/* ===================================================================
DESKTOP-HÜLLE (pywebview): immer im Vordergrund
=================================================================== */
var shellPinBusy = false;
function initShellPinUi() {
var b = document.getElementById('shell-pin-btn');
if (!b) return;
var api = window.pywebview & & pywebview.api;
if (!api || typeof api.toggle_on_top !== 'function' || typeof api.get_on_top !== 'function') {
b.style.display = 'none';
return;
}
b.style.display = '';
updateShellPinBtn();
}
async function updateShellPinBtn() {
var b = document.getElementById('shell-pin-btn');
if (!b || b.style.display === 'none') return;
try {
var v = await pywebview.api.get_on_top();
if (v) {
b.classList.add('on');
b.title = 'Immer im Vordergrund (aktiv)';
b.setAttribute('aria-pressed', 'true');
} else {
b.classList.remove('on');
b.title = 'Immer im Vordergrund';
b.setAttribute('aria-pressed', 'false');
}
} catch (e) {}
}
async function toggleShellPin() {
if (shellPinBusy) return;
var b = document.getElementById('shell-pin-btn');
if (!b || b.style.display === 'none') return;
var api = window.pywebview & & pywebview.api;
if (!api || typeof api.toggle_on_top !== 'function') return;
shellPinBusy = true;
try {
await api.toggle_on_top();
await updateShellPinBtn();
} catch (e) {}
shellPinBusy = false;
}
2026-04-19 20:41:37 +02:00
/* ===================================================================
SOUND
=================================================================== */
function updateSoundBtn() {
var btn = document.getElementById('sound-btn');
if (soundEnabled) { btn.innerHTML = '🔔 Ton'; btn.classList.remove('muted'); }
else { btn.innerHTML = '🔕 Aus'; btn.classList.add('muted'); }
}
function toggleSound() {
soundEnabled = !soundEnabled;
localStorage.setItem('empfang_sound', soundEnabled ? 'on' : 'off');
updateSoundBtn();
if (soundEnabled) playTone(currentToneIdx);
}
function testCurrentSound() {
var prev = soundEnabled; soundEnabled = true;
playTone(currentToneIdx); soundEnabled = prev;
}
function playTone(idx) {
if (!soundEnabled) return;
var preset = TONE_PRESETS[idx] || TONE_PRESETS[0];
var vol = preset.vol * Math.max(0, volume);
try {
if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
if (audioCtx.state === 'suspended') audioCtx.resume();
var t = audioCtx.currentTime;
preset.notes.forEach(function(note) {
var g = audioCtx.createGain();
g.connect(audioCtx.destination);
g.gain.setValueAtTime(vol, t);
g.gain.linearRampToValueAtTime(vol * 0.6, t + note.d * 0.4);
g.gain.exponentialRampToValueAtTime(0.001, t + note.d);
var o = audioCtx.createOscillator();
o.type = preset.wave; o.frequency.setValueAtTime(note.f, t);
o.connect(g); o.start(t); o.stop(t + note.d + 0.05);
t += note.d;
});
} catch(e) {}
}
function startRepeatTone() {
stopRepeatTone();
if (repeatMs > 0 & & soundEnabled) {
repeatInterval = setInterval(function(){ playTone(currentToneIdx); }, repeatMs);
}
}
function stopRepeatTone() {
if (repeatInterval) { clearInterval(repeatInterval); repeatInterval = null; }
}
function setVolume(v) {
volume = parseInt(v, 10) / 100;
document.getElementById('vol-display').textContent = Math.round(volume * 100) + '%';
localStorage.setItem('empfang_volume', volume);
}
function saveSoundSettings() {
currentToneIdx = parseInt(document.getElementById('tone-select').value, 10);
localStorage.setItem('empfang_tone_idx', currentToneIdx);
onRepeatChange();
}
function initRepeatUI() {
var sel = document.getElementById('repeat-select');
var known = ['0','2000','5000','30000'];
if (known.indexOf(String(repeatMs)) >= 0) {
sel.value = String(repeatMs);
document.getElementById('custom-sec-wrap').style.display = 'none';
} else if (repeatMs > 0) {
sel.value = 'custom';
document.getElementById('custom-sec-wrap').style.display = 'inline';
document.getElementById('custom-sec').value = Math.round(repeatMs / 1000);
} else { sel.value = '0'; }
}
function onRepeatChange() {
var sel = document.getElementById('repeat-select');
var cw = document.getElementById('custom-sec-wrap');
if (sel.value === 'custom') {
cw.style.display = 'inline';
repeatMs = Math.max(1, parseInt(document.getElementById('custom-sec').value, 10)) * 1000;
} else { cw.style.display = 'none'; repeatMs = parseInt(sel.value, 10); }
localStorage.setItem('empfang_repeat_ms', repeatMs);
}
/* ===================================================================
FONT / UI
=================================================================== */
function applyFontSize() {
2026-04-22 22:33:46 +02:00
var fs = currentFontSize + 'pt';
/* rem bezieht sich auf < html > : nur body zu setzen skaliert die meisten Inhalte nicht */
document.documentElement.style.fontSize = fs;
document.body.style.fontSize = fs;
var pct = Math.round((currentFontSize / EMPFANG_FONT_BASE_PT) * 100);
var disp = document.getElementById('font-size-display');
if (disp) disp.textContent = pct + '%';
var wrap = document.getElementById('font-ctrl-wrap');
if (wrap) wrap.title = 'Schriftgröße: ' + pct + '% (' + currentFontSize + ' pt, 100% = ' + EMPFANG_FONT_BASE_PT + ' pt)';
localStorage.setItem('empfang_font_size', String(currentFontSize));
2026-04-19 20:41:37 +02:00
}
function changeFontSize(d) {
currentFontSize = Math.max(5, Math.min(20, currentFontSize + d));
applyFontSize();
}
function toggleSettings() {
document.getElementById('settings-panel').classList.toggle('open');
}
function toggleSort() {
sortNewestFirst = !sortNewestFirst;
localStorage.setItem('empfang_sort', sortNewestFirst ? 'newest' : 'asc');
updateSortBtn();
lastDataHash = '';
2026-04-16 15:23:14 +02:00
loadMessages();
}
2026-04-19 20:41:37 +02:00
function updateSortBtn() {
var btn = document.getElementById('sort-btn');
if (sortNewestFirst) { btn.innerHTML = '↑ Neu'; btn.classList.add('active-sort'); }
else { btn.innerHTML = '↓ Alt'; btn.classList.remove('active-sort'); }
}
2026-04-16 15:23:14 +02:00
2026-04-19 20:41:37 +02:00
/* ===================================================================
DICTATION
=================================================================== */
function toggleDictation() {
if (isRecording) { stopDictation(); return; }
var SR = window.SpeechRecognition || window.webkitSpeechRecognition;
if (!SR) { return; }
recognition = new SR();
recognition.lang = 'de-DE';
recognition.continuous = true;
recognition.interimResults = false;
recognition.onresult = function(ev) {
var ta = document.getElementById('reply-text');
for (var i = ev.resultIndex; i < ev.results.length ; i + + ) {
if (ev.results[i].isFinal) ta.value += ev.results[i][0].transcript + ' ';
}
autoGrowTextarea(ta);
};
recognition.onerror = function() { stopDictation(); };
recognition.onend = function() {
if (isRecording) { try { recognition.start(); } catch(e) { stopDictation(); } }
};
recognition.start();
isRecording = true;
updateDictateBtn();
}
function stopDictation() {
if (recognition) { try { recognition.stop(); } catch(e){} recognition = null; }
isRecording = false;
updateDictateBtn();
}
function updateDictateBtn() {
var btn = document.getElementById('dictate-btn');
if (!btn) return;
if (isRecording) { btn.innerHTML = '⏹ '; btn.classList.add('recording'); btn.title = 'Stoppen'; }
else { btn.innerHTML = '🎤 '; btn.classList.remove('recording'); btn.title = 'Diktieren'; }
}
/* ===================================================================
2026-04-20 14:38:16 +02:00
TASKS (server only)
2026-04-19 20:41:37 +02:00
=================================================================== */
2026-04-20 14:38:16 +02:00
async function loadTasksFromServer() {
try {
var r = await apiFetch(API_BASE + '/tasks');
var d = await r.json();
serverTasks = d.tasks || [];
renderTodos();
} catch(e) {}
2026-04-16 15:23:14 +02:00
}
2026-04-19 20:41:37 +02:00
function renderTodos() {
var el = document.getElementById('todo-list');
2026-04-20 14:38:16 +02:00
if (!serverTasks.length) {
el.innerHTML = '< div style = "color:#aaa;font-size:.8rem;padding:12px;text-align:center" > Keine Aufgaben< / div > ';
return;
}
el.innerHTML = serverTasks.map(function(t) {
2026-04-19 20:41:37 +02:00
var assignee = t.assignee || '';
2026-04-20 14:38:16 +02:00
var tid = String(t.task_id).replace(/'/g, "\\'");
2026-04-19 20:41:37 +02:00
return '< div class = "todo-item ' + (t.done ? 'todo-done' : '') + '" > ' +
2026-04-20 14:38:16 +02:00
'< input type = "checkbox" ' + ( t . done ? ' checked ' : ' ' ) + ' onchange = "toggleTodo(\'' + tid + '\')" > ' +
2026-04-19 20:41:37 +02:00
'< div style = "flex:1;min-width:0" > ' +
2026-04-20 14:38:16 +02:00
'< span class = "todo-text ' + (t.done ? 'done' : '') + '" contenteditable = "true" onblur = "editTodo(\'' + tid + '\',this)" > ' + esc(t.text) + '< / span > ' +
2026-04-19 20:41:37 +02:00
(assignee ? '< div class = "todo-assignee" > \u2192 ' + esc(assignee) + '< / div > ' : '') +
'< / div > ' +
2026-04-20 14:38:16 +02:00
'< button class = "todo-assign-btn" onclick = "assignTodo(\'' + tid + '\')" title = "Zuweisen" > \u270B< / button > ' +
'< button class = "todo-del" onclick = "delTodo(\'' + tid + '\')" title = "Entfernen" > × < / button > ' +
2026-04-19 20:41:37 +02:00
'< / div > ';
}).join('');
}
function toggleTodoInput() {
var row = document.getElementById('todo-input-row');
var inp = document.getElementById('todo-inline-input');
var isOpen = row.classList.toggle('open');
if (isOpen) { inp.value = ''; inp.focus(); }
}
2026-04-20 14:38:16 +02:00
async function addTodoInline() {
2026-04-19 20:41:37 +02:00
var inp = document.getElementById('todo-inline-input');
var text = (inp.value || '').trim();
if (!text) return;
2026-04-20 14:38:16 +02:00
try {
await apiFetch(API_BASE + '/tasks', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({text: text, assignee: ''})
});
inp.value = '';
inp.focus();
await loadTasksFromServer();
notifyOtherTabs();
} catch(e) {}
}
async function toggleTodo(taskId) {
var task = serverTasks.find(function(t) { return String(t.task_id) === taskId; });
if (!task) return;
try {
await apiFetch(API_BASE + '/tasks/' + taskId + '/update', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({done: !task.done})
});
await loadTasksFromServer();
notifyOtherTabs();
} catch(e) {}
}
async function editTodo(taskId, el) {
var text = (el.textContent || '').trim();
if (!text) return;
try {
await apiFetch(API_BASE + '/tasks/' + taskId + '/update', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({text: text})
});
} catch(e) {}
}
async function delTodo(taskId) {
try {
await apiFetch(API_BASE + '/tasks/' + taskId, {method: 'DELETE'});
await loadTasksFromServer();
notifyOtherTabs();
} catch(e) {}
}
async function assignTodo(taskId) {
var task = serverTasks.find(function(t) { return String(t.task_id) === taskId; });
if (!task) return;
var names = practiceUsers.map(function(u) { return u.display_name; }).join(', ');
var name = prompt('Zuweisen an (' + (names || 'keine bekannt') + '):', task.assignee || '');
if (name === null) return;
try {
await apiFetch(API_BASE + '/tasks/' + taskId + '/update', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({assignee: name.trim()})
});
await loadTasksFromServer();
notifyOtherTabs();
} catch(e) {}
}
/* ===================================================================
SETTINGS (Practice info)
=================================================================== */
async function loadPracticeInfo() {
try {
var r = await apiFetch(API_BASE + '/practice/info');
var d = await r.json();
var el = document.getElementById('practice-info-section');
if (!el) return;
var html = '< div class = "setting-row" > < label > Praxis:< / label > < span > ' + esc(d.practice_name || '-') + '< / span > < / div > ';
if (currentSession & & currentSession.role === 'admin' & & d.invite_code) {
html += '< div class = "setting-row" > < label > Einladungscode:< / label > < span style = "font-family:monospace;background:#f0f4f8;padding:2px 8px;border-radius:4px;user-select:all" id = "invite-code-display" > ' + esc(d.invite_code) + '< / span > ' +
' < button class = "btn-test-small" onclick = "copyInviteCode()" > Kopieren< / button > ' +
' < button class = "btn-test-small" onclick = "regenerateInviteCode()" > Neuen Code< / button > < / div > ';
html += '< div class = "setting-row" style = "font-size:.78rem;color:#8a9aaa" > Diesen Code an Mitarbeiter weitergeben, damit sie sich registrieren k\u00f6nnen.< / div > ';
}
el.innerHTML = html;
var adminLink = document.getElementById('admin-panel-link');
if (adminLink) {
adminLink.style.display = (currentSession & & currentSession.role === 'admin') ? 'block' : 'none';
}
} catch(e) {}
2026-04-19 20:41:37 +02:00
}
2026-04-20 14:38:16 +02:00
function copyInviteCode() {
var el = document.getElementById('invite-code-display');
if (el) navigator.clipboard.writeText(el.textContent.trim()).catch(function(){});
}
2026-04-19 20:41:37 +02:00
2026-04-20 14:38:16 +02:00
async function regenerateInviteCode() {
if (!confirm('Neuen Einladungscode erzeugen?\nDer alte Code wird ungueltig.')) return;
try {
var r = await apiFetch(API_BASE + '/auth/regenerate_invite', {method:'POST'});
var d = await r.json();
if (d.success) loadPracticeInfo();
} catch(e) {}
}
/* ===================================================================
ADMIN PANEL
=================================================================== */
function openAdmin() {
document.getElementById('admin-overlay').classList.add('open');
showAdminTab('praxis');
}
function closeAdmin() {
document.getElementById('admin-overlay').classList.remove('open');
}
function showAdminTab(tab) {
var tabs = document.querySelectorAll('.admin-tab');
var tabNames = ['praxis','users','devices','channels','federation'];
tabs.forEach(function(t, i) { t.classList.toggle('active', tabNames[i] === tab); });
if (tab === 'praxis') loadAdminPraxis();
else if (tab === 'users') loadAdminUsers();
else if (tab === 'devices') loadAdminDevices();
else if (tab === 'channels') loadAdminChannels();
else if (tab === 'federation') loadAdminFederation();
}
async function loadAdminPraxis() {
var body = document.getElementById('admin-body');
try {
var r = await apiFetch(API_BASE + '/practice/info');
var d = await r.json();
var html = '< div class = "admin-section-title" > Praxis-Informationen< / div > ';
html += '< div class = "admin-info-row" > < span class = "admin-info-label" > Praxisname:< / span > < span class = "admin-info-value" > ' + esc(d.practice_name || '-') + '< / span > < / div > ';
html += '< div class = "admin-info-row" > < span class = "admin-info-label" > Benutzer:< / span > < span class = "admin-info-value" > ' + d.user_count + '< / span > < / div > ';
html += '< div class = "admin-info-row" > < span class = "admin-info-label" > Nachrichten:< / span > < span class = "admin-info-value" > ' + d.message_count + ' (' + d.open_count + ' offen)< / span > < / div > ';
if (d.admin_email) {
html += '< div class = "admin-info-row" > < span class = "admin-info-label" > Admin-E-Mail:< / span > < span class = "admin-info-value" > ' + esc(d.admin_email) + '< / span > < / div > ';
}
if (d.invite_code) {
html += '< div class = "admin-section-title" style = "margin-top:20px" > Einladungslink< / div > ';
html += '< div class = "admin-info-row" > < span class = "admin-info-label" > Code:< / span > < span class = "admin-mono" > ' + esc(d.invite_code) + '< / span > ';
html += ' < button class = "admin-btn" onclick = "copyInviteCode()" > Kopieren< / button > ';
html += ' < button class = "admin-btn" onclick = "regenerateInviteCode().then(function(){loadAdminPraxis()})" > Neuen Code< / button > < / div > ';
var link = window.location.origin + '/empfang/?invite=' + encodeURIComponent(d.invite_code);
html += '< div class = "admin-info-row" > < span class = "admin-info-label" > Link:< / span > < span class = "admin-mono" style = "font-size:.75rem;word-break:break-all" > ' + esc(link) + '< / span > ';
html += ' < button class = "admin-btn" onclick = "navigator.clipboard.writeText(\'' + link.replace(/'/g, " \ \ ' " ) + ' \ ' ) " > Link kopieren< / button > < / div > ';
html += '< div style = "font-size:.78rem;color:#8a9aaa;margin-top:6px" > Diesen Link an Mitarbeiter senden, damit sie sich registrieren k\u00f6nnen.< / div > ';
}
body.innerHTML = html;
} catch(e) { body.innerHTML = '< div class = "admin-empty" > Fehler beim Laden.< / div > '; }
}
async function loadAdminUsers() {
var body = document.getElementById('admin-body');
body.innerHTML = '< div class = "admin-empty" > Laden...< / div > ';
try {
var r = await apiFetch(API_BASE + '/admin/users');
var d = await r.json();
var users = d.users || [];
var html = '< div class = "admin-section-title" > Benutzer (' + users.length + ')< / div > ';
if (!users.length) { html += '< div class = "admin-empty" > Keine Benutzer< / div > '; body.innerHTML = html; return; }
html += '< table class = "admin-table" > < thead > < tr > < th > Name< / th > < th > Rolle< / th > < th > Status< / th > < th > Letzter Login< / th > < th > Aktionen< / th > < / tr > < / thead > < tbody > ';
users.forEach(function(u) {
var roleClass = u.role || 'mpa';
var statusClass = u.status === 'deactivated' ? 'deactivated' : 'active';
html += '< tr > ';
html += '< td > < strong > ' + esc(u.display_name) + '< / strong > ' + (u.email ? '< br > < span style = "font-size:.72rem;color:#8a9aaa" > ' + esc(u.email) + '< / span > ' : '') + '< / td > ';
html += '< td > < span class = "admin-badge ' + roleClass + '" > ' + esc(u.role) + '< / span > < / td > ';
html += '< td > < span class = "admin-badge ' + statusClass + '" > ' + (u.status === 'deactivated' ? 'Deaktiviert' : 'Aktiv') + '< / span > < / td > ';
html += '< td style = "font-size:.75rem;color:#8a9aaa" > ' + esc(u.last_login || '-') + '< / td > ';
html += '< td style = "white-space:nowrap" > ';
if (u.user_id !== currentSession.user_id) {
html += '< select onchange = "changeUserRole(\'' + u.user_id + '\',this.value)" style = "font-size:.75rem;padding:2px 4px;border:1px solid #ddd;border-radius:3px;margin-right:4px" > ';
['arzt','mpa','empfang'].forEach(function(ro) {
html += '< option value = "' + ro + '" ' + ( u . role = == ro ? ' selected ' : ' ' ) + ' > ' + ro + '< / option > ';
});
html += '< / select > ';
if (u.status === 'deactivated') {
html += '< button class = "admin-btn success" onclick = "activateUser(\'' + u.user_id + '\')" > Aktivieren< / button > ';
} else {
html += '< button class = "admin-btn" onclick = "deactivateUser(\'' + u.user_id + '\')" > Deaktivieren< / button > ';
}
html += '< button class = "admin-btn" onclick = "resetPassword(\'' + u.user_id + '\',\'' + esc(u.display_name).replace(/'/g," \ \ ' " ) + ' \ ' ) " > PW Reset< / button > ';
html += '< button class = "admin-btn danger" onclick = "deleteUser(\'' + u.user_id + '\',\'' + esc(u.display_name).replace(/'/g," \ \ ' " ) + ' \ ' ) " > L\u00f6schen< / button > ';
} else {
html += '< span style = "font-size:.75rem;color:#8a9aaa" > (Sie)< / span > ';
}
html += '< / td > < / tr > ';
});
html += '< / tbody > < / table > ';
body.innerHTML = html;
} catch(e) { body.innerHTML = '< div class = "admin-empty" > Fehler beim Laden.< / div > '; }
}
async function changeUserRole(userId, newRole) {
try {
await apiFetch(API_BASE + '/admin/users/' + userId + '/role', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({role: newRole})
});
loadAdminUsers();
} catch(e) {}
}
async function deactivateUser(userId) {
if (!confirm('Benutzer wirklich deaktivieren?')) return;
try {
await apiFetch(API_BASE + '/admin/users/' + userId + '/deactivate', {method:'POST'});
loadAdminUsers();
} catch(e) {}
}
async function activateUser(userId) {
try {
await apiFetch(API_BASE + '/admin/users/' + userId + '/activate', {method:'POST'});
loadAdminUsers();
} catch(e) {}
}
async function resetPassword(userId, userName) {
2026-04-21 10:00:36 +02:00
if (!confirm('Passwort von "' + userName + '" zur\u00fccksetzen?')) return;
2026-04-20 14:38:16 +02:00
try {
var r = await apiFetch(API_BASE + '/admin/users/' + userId + '/reset_password', {method:'POST'});
var d = await r.json();
if (d.temp_password) {
alert('Neues tempor\u00e4res Passwort fuer ' + userName + ':\n\n' + d.temp_password + '\n\nBitte weitergeben. Muss beim n\u00e4chsten Login ge\u00e4ndert werden.');
}
loadAdminUsers();
} catch(e) {}
}
async function deleteUser(userId, userName) {
if (!confirm('Benutzer "' + userName + '" endg\u00fcltig l\u00f6schen?\n\nDiese Aktion kann nicht r\u00fcckg\u00e4ngig gemacht werden.')) return;
try {
await apiFetch(API_BASE + '/admin/users/' + userId, {method:'DELETE'});
loadAdminUsers();
loadUsersFromServer();
} catch(e) {}
}
async function loadAdminDevices() {
var body = document.getElementById('admin-body');
body.innerHTML = '< div class = "admin-empty" > Laden...< / div > ';
try {
var r = await apiFetch(API_BASE + '/admin/devices');
var d = await r.json();
var devices = d.devices || [];
var html = '< div class = "admin-section-title" > Ger\u00e4te (' + devices.length + ')< / div > ';
if (!devices.length) { html += '< div class = "admin-empty" > Keine Ger\u00e4te registriert< / div > '; body.innerHTML = html; return; }
html += '< table class = "admin-table" > < thead > < tr > < th > Benutzer< / th > < th > Ger\u00e4t< / th > < th > Typ< / th > < th > Letzter Zugriff< / th > < th > Status< / th > < th > Aktionen< / th > < / tr > < / thead > < tbody > ';
devices.forEach(function(dev) {
var trustClass = dev.trust_status === 'blocked' ? 'blocked' : 'trusted';
html += '< tr > ';
html += '< td > ' + esc(dev.user_name || dev.user_id) + '< / td > ';
html += '< td > ' + esc(dev.device_name) + '< / td > ';
html += '< td style = "font-size:.75rem" > ' + esc(dev.platform) + ' / ' + esc(dev.device_type) + '< / td > ';
html += '< td style = "font-size:.75rem;color:#8a9aaa" > ' + esc(dev.last_active || '-') + '< / td > ';
html += '< td > < span class = "admin-badge ' + trustClass + '" > ' + esc(dev.trust_status) + '< / span > < / td > ';
html += '< td > ';
if (dev.trust_status !== 'blocked') {
html += '< button class = "admin-btn danger" onclick = "blockDevice(\'' + dev.device_id + '\')" > Sperren< / button > ';
}
html += '< button class = "admin-btn danger" onclick = "deleteDevice(\'' + dev.device_id + '\')" > L\u00f6schen< / button > ';
html += '< / td > < / tr > ';
});
html += '< / tbody > < / table > ';
body.innerHTML = html;
} catch(e) { body.innerHTML = '< div class = "admin-empty" > Fehler beim Laden.< / div > '; }
}
async function blockDevice(deviceId) {
try {
await apiFetch(API_BASE + '/admin/devices/' + deviceId + '/block', {method:'POST'});
loadAdminDevices();
} catch(e) {}
}
async function deleteDevice(deviceId) {
if (!confirm('Ger\u00e4t entfernen?')) return;
try {
await apiFetch(API_BASE + '/admin/devices/' + deviceId, {method:'DELETE'});
loadAdminDevices();
} catch(e) {}
}
async function loadAdminChannels() {
var body = document.getElementById('admin-body');
body.innerHTML = '< div class = "admin-empty" > Laden...< / div > ';
try {
var r = await apiFetch(API_BASE + '/channels');
var d = await r.json();
var channels = d.channels || [];
var html = '< div class = "admin-section-title" > Kan\u00e4le (' + channels.length + ')< / div > ';
html += '< table class = "admin-table" > < thead > < tr > < th > Name< / th > < th > Typ< / th > < th > Rollen< / th > < th > Aktionen< / th > < / tr > < / thead > < tbody > ';
channels.forEach(function(ch) {
html += '< tr > ';
html += '< td > < strong > ' + esc(ch.name) + '< / strong > < / td > ';
html += '< td > < span class = "admin-badge ' + (ch.scope === 'external' ? 'pending_conn' : 'active') + '" > ' + esc(ch.scope) + '< / span > < / td > ';
html += '< td style = "font-size:.75rem" > ' + (ch.allowed_roles & & ch.allowed_roles.length ? esc(ch.allowed_roles.join(', ')) : 'Alle') + '< / td > ';
html += '< td > ';
if (ch.channel_type !== 'external') {
html += '< button class = "admin-btn danger" onclick = "deleteChannel(\'' + ch.channel_id + '\',\'' + esc(ch.name).replace(/'/g," \ \ ' " ) + ' \ ' ) " > L\u00f6schen< / button > ';
}
html += '< / td > < / tr > ';
});
html += '< / tbody > < / table > ';
html += '< div class = "admin-add-row" > ';
html += '< input type = "text" id = "new-channel-name" placeholder = "Neuer Kanalname..." > ';
html += '< select id = "new-channel-roles" > < option value = "" > Alle Rollen< / option > < option value = "arzt,admin" > Nur \u00c4rzte< / option > < option value = "mpa,admin" > Nur MPA< / option > < option value = "empfang,admin" > Nur Empfang< / option > < / select > ';
html += '< button class = "admin-btn success" onclick = "createChannel()" > Kanal erstellen< / button > ';
html += '< / div > ';
body.innerHTML = html;
} catch(e) { body.innerHTML = '< div class = "admin-empty" > Fehler beim Laden.< / div > '; }
}
async function createChannel() {
var name = (document.getElementById('new-channel-name').value || '').trim();
if (!name) return;
var rolesStr = document.getElementById('new-channel-roles').value;
var roles = rolesStr ? rolesStr.split(',') : [];
try {
await apiFetch(API_BASE + '/channels', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({name: name, allowed_roles: roles})
});
loadAdminChannels();
} catch(e) {}
}
async function deleteChannel(channelId, channelName) {
if (!confirm('Kanal "' + channelName + '" l\u00f6schen?')) return;
try {
await apiFetch(API_BASE + '/channels/' + channelId, {method:'DELETE'});
loadAdminChannels();
} catch(e) {}
}
async function loadAdminFederation() {
var body = document.getElementById('admin-body');
body.innerHTML = '< div class = "admin-empty" > Laden...< / div > ';
try {
var r = await apiFetch(API_BASE + '/federation/connections');
var d = await r.json();
var conns = d.connections || [];
var html = '< div class = "admin-section-title" > Praxis-Verbindungen< / div > ';
if (conns.length) {
html += '< table class = "admin-table" > < thead > < tr > < th > Verbundene Praxis< / th > < th > Status< / th > < th > Seit< / th > < th > Aktionen< / th > < / tr > < / thead > < tbody > ';
conns.forEach(function(c) {
var otherName = c.practice_a_name || c.practice_b_name || '-';
if (c.practice_a_id === currentSession.practice_id) otherName = c.practice_b_name || '(ausstehend)';
else otherName = c.practice_a_name || '-';
var statusClass = c.status === 'active' ? 'active_conn' : (c.status === 'pending' ? 'pending_conn' : 'revoked');
html += '< tr > ';
html += '< td > ' + esc(otherName) + '< / td > ';
html += '< td > < span class = "admin-badge ' + statusClass + '" > ' + esc(c.status) + '< / span > < / td > ';
html += '< td style = "font-size:.75rem;color:#8a9aaa" > ' + esc(c.created_at || '-') + '< / td > ';
html += '< td > ';
if (c.status === 'active' || c.status === 'pending') {
html += '< button class = "admin-btn danger" onclick = "revokeConnection(\'' + c.connection_id + '\')" > Trennen< / button > ';
}
html += '< / td > < / tr > ';
});
html += '< / tbody > < / table > ';
} else {
html += '< div class = "admin-empty" > Keine Verbindungen zu anderen Praxen< / div > ';
}
html += '< div class = "admin-section-title" style = "margin-top:24px" > Neue Verbindung< / div > ';
html += '< div class = "admin-add-row" > ';
html += '< button class = "admin-btn success" onclick = "createFederationInvite()" > Einladung erstellen< / button > ';
html += '< input type = "text" id = "accept-federation-token" placeholder = "Einladungscode einer anderen Praxis..." > ';
html += '< button class = "admin-btn" onclick = "acceptFederationInvite()" > Verbindung annehmen< / button > ';
html += '< / div > ';
html += '< div id = "federation-invite-result" style = "margin-top:8px" > < / div > ';
body.innerHTML = html;
} catch(e) { body.innerHTML = '< div class = "admin-empty" > Fehler beim Laden.< / div > '; }
}
async function createFederationInvite() {
try {
var r = await apiFetch(API_BASE + '/federation/invite', {method:'POST', headers:{'Content-Type':'application/json'}, body:'{}'});
var d = await r.json();
if (d.invite_token) {
var el = document.getElementById('federation-invite-result');
el.innerHTML = '< div class = "admin-info-row" > < span class = "admin-info-label" > Einladungscode:< / span > < span class = "admin-mono" > ' + esc(d.invite_token) + '< / span > < button class = "admin-btn" onclick = "navigator.clipboard.writeText(\'' + d.invite_token + '\')" > Kopieren< / button > < / div > ' +
'< div style = "font-size:.78rem;color:#8a9aaa" > Diesen Code an den Admin der anderen Praxis weitergeben.< / div > ';
}
} catch(e) {}
}
async function acceptFederationInvite() {
var token = (document.getElementById('accept-federation-token').value || '').trim();
if (!token) return;
try {
var r = await apiFetch(API_BASE + '/federation/accept', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({invite_token: token})
});
var d = await r.json();
if (d.success) {
document.getElementById('accept-federation-token').value = '';
loadAdminFederation();
} else {
alert(d.detail || 'Verbindung fehlgeschlagen.');
}
} catch(e) { alert('Fehler bei der Verbindung.'); }
}
async function revokeConnection(connId) {
if (!confirm('Verbindung wirklich trennen?')) return;
try {
await apiFetch(API_BASE + '/federation/connections/' + connId + '/revoke', {method:'POST'});
loadAdminFederation();
} catch(e) {}
2026-04-19 20:41:37 +02:00
}
/* ===================================================================
UTILITY
=================================================================== */
function esc(s) {
var d = document.createElement('div');
d.textContent = s || '';
return d.innerHTML;
}
function copyText(el) {
var t = (el.textContent || '').trim();
if (!t) return;
navigator.clipboard.writeText(t).then(function(){
el.classList.add('copied-flash');
setTimeout(function(){ el.classList.remove('copied-flash'); }, 600);
});
}
/* ===================================================================
POLLING + CROSS-TAB SYNC
=================================================================== */
2026-04-20 14:38:16 +02:00
function startPolling() {
stopPolling();
pollTimer = setInterval(function() {
loadMessages();
loadTasksFromServer();
}, 10000);
userPollTimer = setInterval(loadUsersFromServer, 30000);
}
function stopPolling() {
if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
if (userPollTimer) { clearInterval(userPollTimer); userPollTimer = null; }
}
2026-04-19 20:41:37 +02:00
document.addEventListener('visibilitychange', function() {
2026-04-20 14:38:16 +02:00
if (!document.hidden & & currentSession) {
lastDataHash = '';
loadMessages();
loadUsersFromServer();
loadTasksFromServer();
}
2026-04-19 20:41:37 +02:00
});
var bc = null;
try {
bc = new BroadcastChannel('aza_empfang_sync');
bc.onmessage = function(ev) {
2026-04-20 14:38:16 +02:00
if (ev.data === 'reload' & & currentSession) {
lastDataHash = '';
loadMessages();
loadTasksFromServer();
}
2026-04-19 20:41:37 +02:00
};
} catch(e) {}
function notifyOtherTabs() {
try { if (bc) bc.postMessage('reload'); } catch(e) {}
}
2026-04-22 22:33:46 +02:00
try {
window.addEventListener('pywebviewready', function() { initShellPinUi(); });
} catch (e) {}
2026-04-16 15:23:14 +02:00
< / script >
< / body >
< / html >