This commit is contained in:
2026-05-05 23:36:13 +02:00
parent 72ecd579de
commit 2363564013
10324 changed files with 1666654 additions and 180 deletions

View File

@@ -3,6 +3,9 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
<meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Expires" content="0">
<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 &ndash; Empfang</title>
<style>
@@ -55,6 +58,8 @@ header h1{font-size:1.2rem;font-weight:600;letter-spacing:.3px;white-space:nowra
.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-section-future{color:#b8c4cc;padding-top:18px}
.sb-placeholder{padding:2px 18px 10px;font-size:.72rem;color:#aab8c4;line-height:1.35}
.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}
@@ -64,12 +69,14 @@ header h1{font-size:1.2rem;font-weight:600;letter-spacing:.3px;white-space:nowra
.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-user.dm-selected{background:#e8f0f8;border-left-color:#5B8DB3;font-weight:600;color:#1a4d6d}
.sb-user.sb-user-self{cursor:pointer}
.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-group-tag{font-size:.62rem;color:#95a5b5;background:#f4f6f9;padding:1px 5px;border-radius:6px;font-weight:500}
.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}
@@ -305,13 +312,13 @@ header h1{font-size:1.2rem;font-weight:600;letter-spacing:.3px;white-space:nowra
<button class="btn-test-small" onclick="testCurrentSound()">Testen</button>
</div>
<div class="setting-row">
<label for="repeat-select">Ton-Wiederholung:</label>
<label for="repeat-select">Ton-Modus:</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>
<option value="30000">Wiederholend (Standard, alle 30 Sek.)</option>
<option value="5000">Kontinuierlich (alle 5 Sek.)</option>
<option value="2000">Kontinuierlich (alle 2 Sek.)</option>
<option value="0">Einmalig</option>
<option value="custom">Eigenes Intervall</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.
@@ -348,7 +355,7 @@ header h1{font-size:1.2rem;font-weight:600;letter-spacing:.3px;white-space:nowra
<button class="sb-me-change" onclick="doLogout()" title="Abmelden">&#x2192;</button>
</div>
<div id="sidebar-scroll">
<div class="sb-section">Kan&auml;le</div>
<div class="sb-section">Chats</div>
<div class="sb-item active" data-view="allgemein" onclick="setView('allgemein')">
<span class="sb-icon">&#128172;</span> Allgemein
<span class="sb-badge" id="sb-badge-open"></span>
@@ -360,13 +367,15 @@ header h1{font-size:1.2rem;font-weight:600;letter-spacing:.3px;white-space:nowra
<span>+</span> Neuer Chat
</div>
<div class="sb-section">Benutzer</div>
<div id="sb-users"></div>
<div id="sb-users" aria-label="Benutzer dieser Praxis"></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 class="sb-section sb-section-future" title="Vorbereitet f&uuml;r eine sp&auml;tere Ausbaustufe">Externe Kontakte</div>
<div class="sb-placeholder" id="sb-external-placeholder">Sp&auml;ter: andere Praxen und externe Kontakte (eigenbereich).</div>
</div>
</aside>
@@ -408,7 +417,7 @@ header h1{font-size:1.2rem;font-weight:600;letter-spacing:.3px;white-space:nowra
<div class="new-chat-box" onclick="event.stopPropagation()">
<h3>Neuer Chat</h3>
<p>W&auml;hlen Sie, wo der Chat laufen soll. Es &ouml;ffnet sich ein <strong>zus&auml;tzliches Fenster</strong> (Browser oder Desktop-H&uuml;lle) &mdash; Sie k&ouml;nnen dort in Ruhe schreiben, <strong>Bilder per Drag &amp; Drop oder Strg+V</strong> einf&uuml;gen.</p>
<label class="nc-opt"><input type="radio" name="nc-mode" value="general" checked onchange="syncNcPeerSelect()"> Allgemein (wie Kanal &bdquo;Allgemein&ldquo;)</label>
<label class="nc-opt"><input type="radio" name="nc-mode" value="general" checked onchange="syncNcPeerSelect()"> Allgemein (Praxis-Inbox, wie Hauptansicht)</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&auml;hlen">
<option value="">&mdash; Benutzer w&auml;hlen &mdash;</option>
@@ -439,7 +448,7 @@ header h1{font-size:1.2rem;font-weight:600;letter-spacing:.3px;white-space:nowra
<div class="status-bar">
<span>Aktualisiert alle 10 Sek.</span>
<span style="opacity:.5" id="ui-version">v2026.04.22</span>
<span style="opacity:.5" id="ui-version">v2026.05.05.2</span>
</div>
<script>
@@ -456,12 +465,14 @@ var serverTasks = [];
var allMessages = [];
var allThreads = [];
var activeView = 'allgemein';
var activeDmPeer = null;
var activeThreadId = null;
var lastDataHash = '';
var previousThreadIds = new Set();
var justSentReply = false;
var pollTimer = null;
var userPollTimer = null;
var fallbackTimer = null;
var pendingReplyAttachments = [];
var REPLY_ATTACH_MAX_BYTES = 2 * 1024 * 1024;
@@ -908,6 +919,9 @@ async function doLogout() {
serverTasks = [];
allMessages = [];
allThreads = [];
activeDmPeer = null;
activeThreadId = null;
activeView = 'allgemein';
lastDataHash = '';
previousThreadIds = new Set();
stopPolling();
@@ -939,6 +953,7 @@ async function doLogout() {
updateSoundBtn();
applyFontSize();
setupReplyBarDropPaste();
bindSidebarUserClicks();
initShellPinUi();
var urlParams = new URLSearchParams(window.location.search);
@@ -992,31 +1007,143 @@ async function doLogout() {
})();
/* ===================================================================
SIDEBAR
SIDEBAR + NAVIGATION (Chats / Benutzer / Direktchat V1, nur eigene Praxis)
=================================================================== */
function senderDisplayCore(abs) {
return (abs || '').split('(')[0].trim();
}
function isGeneralInboxThread(t) {
var ex = t.root.extras || {};
if (ex.recipients && Array.isArray(ex.recipients) && ex.recipients.length >= 2) return false;
var rcpt = (ex.recipient || '').trim();
return !rcpt || rcpt === 'Alle';
}
function threadTouchesDmPeer(t, peerName) {
if (!peerName || !currentSession) return false;
var myName = currentSession.display_name.trim();
var pn = (peerName || '').trim();
var msgs = t.all || [];
for (var i = 0; i < msgs.length; i++) {
var m = msgs[i];
var ex = m.extras || {};
var rcpts = ex.recipients;
if (rcpts && Array.isArray(rcpts) && rcpts.length >= 2) continue;
var rcpt = (ex.recipient || '').trim();
if (!rcpt || rcpt === 'Alle') continue;
if (rcpt.indexOf(',') >= 0) continue;
var sender = senderDisplayCore(m.absender);
var sortedNeed = [myName, pn].sort().join('\x00');
var sortedGot = [sender, rcpt].sort().join('\x00');
if (sortedNeed === sortedGot) return true;
}
return false;
}
function countGeneralOpenThreads() {
return allThreads.filter(function(t) {
return t.root.status === 'offen' && isGeneralInboxThread(t);
}).length;
}
function deviceRecentlyActive(dev) {
var la = dev && dev.last_active;
if (!la) return false;
var t = Date.parse(String(la).replace(' ', 'T'));
if (isNaN(t)) return false;
return Date.now() - t < 120000;
}
function userPresenceDotClass(u, isMe) {
if (isMe) return 'online';
var devs = u.devices;
if (devs && devs.length) {
for (var i = 0; i < devs.length; i++) {
if (deviceRecentlyActive(devs[i])) return 'online';
}
}
return 'offline';
}
function updateSidebarNavHighlight() {
document.querySelectorAll('.sb-item[data-view]').forEach(function(el) {
var v = el.getAttribute('data-view');
el.classList.toggle('active', !activeDmPeer && activeView === v);
});
document.querySelectorAll('#sb-users .sb-user[data-peer]').forEach(function(el) {
var enc = el.getAttribute('data-peer') || '';
var p = enc;
try {
p = decodeURIComponent(enc);
} catch (e) {}
el.classList.toggle('dm-selected', !!(activeDmPeer && p === activeDmPeer));
});
}
function setView(view) {
activeView = view;
activeThreadId = null;
document.querySelectorAll('.sb-item').forEach(function(el) {
el.classList.toggle('active', el.getAttribute('data-view') === view);
});
activeDmPeer = null;
updateSidebarNavHighlight();
renderView();
}
function selectDmPeer(displayName) {
if (!currentSession) {
showLoginOverlay();
return;
}
var raw = String(displayName || '').trim();
var peer = raw;
try {
peer = decodeURIComponent(raw);
} catch (e) {}
peer = peer.trim();
if (!peer) return;
if (peer === currentSession.display_name) {
activeDmPeer = null;
activeThreadId = null;
updateSidebarNavHighlight();
renderView();
return;
}
activeDmPeer = peer;
activeThreadId = null;
updateSidebarNavHighlight();
renderView();
}
function bindSidebarUserClicks() {
var host = document.getElementById('sb-users');
if (!host || host._azaSbBound) return;
host._azaSbBound = true;
host.addEventListener('click', function(ev) {
var row = ev.target.closest('.sb-user[data-peer]');
if (!row) return;
selectDmPeer(row.getAttribute('data-peer'));
});
}
function renderSidebarUsers() {
var el = document.getElementById('sb-users');
if (!practiceUsers.length) {
el.innerHTML = '<div style="padding:4px 18px;font-size:.78rem;color:#bbb">Keine Benutzer</div>';
updateSidebarNavHighlight();
return;
}
el.innerHTML = practiceUsers.map(function(u) {
var isMe = currentSession && u.display_name === currentSession.display_name;
return '<div class="sb-user' + (isMe ? ' me' : '') + '">' +
'<span class="sb-dot ' + (isMe ? 'online' : 'offline') + '"></span>' +
'<span class="sb-user-name">' + esc(u.display_name) + '</span>' +
var name = u.display_name || '';
var isMe = currentSession && name === currentSession.display_name;
var dotCls = userPresenceDotClass(u, isMe);
return '<div class="sb-user' + (isMe ? ' me sb-user-self' : '') + '" data-peer="' + encodeURIComponent(name) + '" title="' +
(isMe ? 'Zur\u00fcck zur Hauptliste Allgemein' : ('Direktchat mit ' + esc(name).replace(/"/g, '&quot;'))) + '">' +
'<span class="sb-dot ' + dotCls + '"></span>' +
'<span class="sb-user-name">' + esc(name) + '</span>' +
(u.role ? '<span class="sb-group-tag">' + esc(u.role) + '</span>' : '') +
'</div>';
}).join('');
updateSidebarNavHighlight();
}
function updateSbMe() {
@@ -1110,7 +1237,7 @@ async function loadMessages() {
justSentReply = false;
previousThreadIds = currentIds;
var openCount = allThreads.filter(function(t){ return t.root.status === 'offen'; }).length;
var openCount = countGeneralOpenThreads();
document.getElementById('count-badge').textContent = openCount + ' offen';
document.getElementById('sb-badge-open').textContent = openCount || '';
@@ -1172,16 +1299,33 @@ function renderThreadList() {
document.getElementById('chat-input-bar').classList.remove('visible');
var filtered;
if (activeView === 'archiv') {
filtered = allThreads.filter(function(t){ return t.root.status === 'erledigt'; });
var base = allThreads.filter(function(t) {
return activeView === 'archiv'
? t.root.status === 'erledigt'
: t.root.status === 'offen';
});
if (activeDmPeer) {
filtered = base.filter(function(t) { return threadTouchesDmPeer(t, activeDmPeer); });
var dmSuffix = activeView === 'archiv' ? ' erledigt' : ' offen';
titleEl.textContent = activeDmPeer + ' (' + filtered.length + dmSuffix + ')';
} else if (activeView === 'archiv') {
filtered = base.filter(isGeneralInboxThread);
titleEl.textContent = 'Archiv';
} else {
filtered = allThreads.filter(function(t){ return t.root.status === 'offen'; });
filtered = base.filter(isGeneralInboxThread);
titleEl.textContent = 'Allgemein (' + filtered.length + ' offen)';
}
if (!filtered.length) {
var hint = activeView === 'archiv' ? 'Keine erledigten Nachrichten.' : 'Keine offenen Nachrichten.';
var hint;
if (activeDmPeer) {
hint = activeView === 'archiv'
? ('Keine erledigten Unterhaltungen mit ' + activeDmPeer + '.')
: ('Keine offenen Unterhaltungen mit ' + activeDmPeer + '.');
} else {
hint = activeView === 'archiv' ? 'Keine erledigten Nachrichten.' : 'Keine offenen Nachrichten.';
}
c.innerHTML = '<div class="conv-empty">' + hint + '</div>';
return;
}
@@ -1252,22 +1396,29 @@ function renderConversation(tid) {
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});
var labelled = [];
if (m.therapieplan) labelled.push({l:'Therapieplan', v:m.therapieplan});
if (m.procedere) labelled.push({l:'Procedere', v:m.procedere});
if (m.medikamente) labelled.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">&#9660;</span>' + esc(f.l) +
'</div>' +
'<div class="conv-field-value">' + esc(f.v) + '</div>' +
'</div>';
});
} else {
var rawKom = (m.kommentar || '').trim();
var hasContent = labelled.length > 0 || (rawKom && rawKom !== '\u200b');
labelled.forEach(function(f) {
html += '<div class="conv-field">' +
'<div class="conv-field-label" onclick="toggleConvField(this)">' +
'<span class="tri">&#9660;</span>' + esc(f.l) +
'</div>' +
'<div class="conv-field-value">' + esc(f.v) + '</div>' +
'</div>';
});
if (rawKom && rawKom !== '\u200b') {
html += '<div class="conv-field-value" style="margin-top:' +
(labelled.length ? '6px' : '0') + '">' + esc(rawKom) + '</div>';
}
if (!hasContent) {
html += '<div style="color:#999;font-size:.85rem">Keine Inhalte</div>';
}
@@ -2253,18 +2404,43 @@ function copyText(el) {
/* ===================================================================
POLLING + CROSS-TAB SYNC
- Schneller Live-Pulse alle 1 s gegen /empfang/pulse: nur dann
loadMessages(), wenn der Server-Tick wechselt. Damit erscheinen
neue Nachrichten und das Tonsignal in unter 1 s, statt nach 10 s
wie bei der alten setInterval-Lösung.
- Fallback: alle 15 s sicherheitshalber komplett neu laden.
=================================================================== */
var lastPulseTick = -1;
async function pulseTick() {
try {
var r = await apiFetch(API_BASE + '/pulse');
if (!r || !r.ok) return;
var d = await r.json();
var t = parseInt(d.tick || 0, 10);
if (t !== lastPulseTick) {
lastPulseTick = t;
loadMessages();
}
} catch(e) {}
}
function startPolling() {
stopPolling();
pollTimer = setInterval(function() {
// Live-Pulse: <1 s Reaktion auf neue Nachrichten.
pollTimer = setInterval(pulseTick, 1000);
// Sicherheits-Refresh + Aufgaben/Benutzer.
fallbackTimer = setInterval(function() {
loadMessages();
loadTasksFromServer();
}, 10000);
}, 15000);
userPollTimer = setInterval(loadUsersFromServer, 30000);
// Sofort einmal anstossen.
pulseTick();
}
function stopPolling() {
if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
if (fallbackTimer) { clearInterval(fallbackTimer); fallbackTimer = null; }
if (userPollTimer) { clearInterval(userPollTimer); userPollTimer = null; }
}