update
This commit is contained in:
@@ -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 – 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">→</button>
|
||||
</div>
|
||||
<div id="sidebar-scroll">
|
||||
<div class="sb-section">Kanäle</div>
|
||||
<div class="sb-section">Chats</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>
|
||||
@@ -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ür eine spätere Ausbaustufe">Externe Kontakte</div>
|
||||
<div class="sb-placeholder" id="sb-external-placeholder">Spä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ä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="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ählen">
|
||||
<option value="">— Benutzer wählen —</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, '"'))) + '">' +
|
||||
'<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">▼</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">▼</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; }
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user