Empfang: Backend-Router + Browser-UI

This commit is contained in:
2026-04-16 15:23:14 +02:00
parent 3bdc930d6e
commit 7ed33c3f1e
3 changed files with 350 additions and 0 deletions

View File

@@ -794,6 +794,13 @@ try:
except Exception:
pass
# Empfang (Rezeption) routes
try:
from empfang_routes import router as empfang_router
app.include_router(empfang_router, prefix="/empfang")
except Exception as _empfang_err:
print(f"[EMPFANG] empfang_routes not loaded: {_empfang_err}")
@app.on_event("startup")
def _print_routes():

View File

@@ -0,0 +1,115 @@
# -*- coding: utf-8 -*-
"""
AZA Empfang Backend-Routen für Empfangs-/Rezeptionsnachrichten.
Desktop sendet Nachrichten per POST /empfang/send.
Empfangsoberfläche zeigt sie unter /empfang/ im Browser.
"""
import json
import os
import time
import uuid
from pathlib import Path
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import HTMLResponse, JSONResponse
from pydantic import BaseModel, Field
router = APIRouter()
_DATA_DIR = Path(__file__).resolve().parent / "data"
_EMPFANG_FILE = _DATA_DIR / "empfang_nachrichten.json"
def _ensure_data_dir():
_DATA_DIR.mkdir(parents=True, exist_ok=True)
def _load_messages() -> list[dict]:
if not _EMPFANG_FILE.is_file():
return []
try:
with open(_EMPFANG_FILE, "r", encoding="utf-8") as f:
data = json.load(f)
return data if isinstance(data, list) else []
except Exception:
return []
def _save_messages(messages: list[dict]):
_ensure_data_dir()
tmp = str(_EMPFANG_FILE) + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
json.dump(messages, f, indent=2, ensure_ascii=False)
os.replace(tmp, str(_EMPFANG_FILE))
class EmpfangMessage(BaseModel):
medikamente: str = ""
therapieplan: str = ""
procedere: str = ""
kommentar: str = ""
patient: str = ""
absender: str = ""
zeitstempel: str = ""
extras: dict = Field(default_factory=dict)
@router.post("/send")
async def empfang_send(msg: EmpfangMessage):
entry = {
"id": uuid.uuid4().hex[:12],
"medikamente": msg.medikamente.strip(),
"therapieplan": msg.therapieplan.strip(),
"procedere": msg.procedere.strip(),
"kommentar": msg.kommentar.strip(),
"patient": msg.patient.strip(),
"absender": msg.absender.strip(),
"zeitstempel": msg.zeitstempel.strip() or time.strftime("%Y-%m-%d %H:%M:%S"),
"empfangen": time.strftime("%Y-%m-%d %H:%M:%S"),
"status": "offen",
}
if msg.extras:
entry["extras"] = msg.extras
messages = _load_messages()
messages.insert(0, entry)
_save_messages(messages)
return JSONResponse(content={"success": True, "id": entry["id"]})
@router.get("/messages")
async def empfang_list():
messages = _load_messages()
return JSONResponse(content={"success": True, "messages": messages})
@router.post("/messages/{msg_id}/done")
async def empfang_done(msg_id: str):
messages = _load_messages()
for m in messages:
if m.get("id") == msg_id:
m["status"] = "erledigt"
_save_messages(messages)
return JSONResponse(content={"success": True})
raise HTTPException(status_code=404, detail="Nachricht nicht gefunden")
@router.delete("/messages/{msg_id}")
async def empfang_delete(msg_id: str):
messages = _load_messages()
new = [m for m in messages if m.get("id") != msg_id]
if len(new) == len(messages):
raise HTTPException(status_code=404, detail="Nachricht nicht gefunden")
_save_messages(new)
return JSONResponse(content={"success": True})
@router.get("/", response_class=HTMLResponse)
async def empfang_page(request: Request):
html_path = Path(__file__).resolve().parent / "web" / "empfang.html"
if html_path.is_file():
return HTMLResponse(content=html_path.read_text(encoding="utf-8"))
return HTMLResponse(content="<h1>empfang.html nicht gefunden</h1>", status_code=404)

View File

@@ -0,0 +1,228 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>AZA Empfang</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:'Segoe UI',system-ui,sans-serif;background:#f0f4f8;color:#1a2a3a;min-height:100vh}
header{background:linear-gradient(135deg,#5B8DB3,#3a6d93);color:#fff;padding:18px 24px;display:flex;align-items:center;justify-content:space-between;box-shadow:0 2px 8px rgba(0,0,0,.12)}
header h1{font-size:1.35rem;font-weight:600;letter-spacing:.3px}
.header-right{display:flex;align-items:center;gap:14px}
.badge{background:rgba(255,255,255,.2);border-radius:20px;padding:4px 14px;font-size:.85rem}
.sound-toggle{background:none;border:1px solid rgba(255,255,255,.35);color:#fff;border-radius:6px;padding:4px 10px;font-size:.82rem;cursor:pointer;transition:background .15s}
.sound-toggle:hover{background:rgba(255,255,255,.15)}
.sound-toggle.muted{opacity:.5}
.container{max-width:900px;margin:24px auto;padding:0 16px}
.empty{text-align:center;padding:60px 20px;color:#6a8a9a;font-size:1.05rem}
.card{background:#fff;border-radius:10px;box-shadow:0 1px 6px rgba(0,0,0,.08);margin-bottom:16px;overflow:hidden;border-left:4px solid #5B8DB3;transition:border-color .2s}
.card.done{border-left-color:#8bc49a;opacity:.7}
.card-header{display:flex;justify-content:space-between;align-items:center;padding:14px 18px;background:#fafcfe;border-bottom:1px solid #eef2f6}
.card-header .meta{font-size:.82rem;color:#6a8a9a}
.card-header .patient{font-weight:600;font-size:1rem;color:#1a3a5a}
.card-header .status{font-size:.75rem;padding:3px 10px;border-radius:12px;font-weight:600}
.status-offen{background:#fff3cd;color:#856404}
.status-erledigt{background:#d4edda;color:#155724}
.card-body{padding:16px 18px}
.field{margin-bottom:10px}
.field-label{font-size:.78rem;font-weight:600;color:#5B8DB3;text-transform:uppercase;letter-spacing:.5px;margin-bottom:3px}
.field-value{font-size:.95rem;white-space:pre-wrap;line-height:1.5}
.card-actions{display:flex;gap:8px;padding:10px 18px;background:#fafcfe;border-top:1px solid #eef2f6;flex-wrap:wrap}
.btn{border:none;border-radius:6px;padding:7px 16px;font-size:.82rem;cursor:pointer;font-weight:500;transition:background .15s}
.btn-copy{background:#e8f0f8;color:#2a5a8a}.btn-copy:hover{background:#d4e4f0}
.btn-print{background:#e8f0e8;color:#2a5a2a}.btn-print:hover{background:#d4e8d4}
.btn-done{background:#d4edda;color:#155724}.btn-done:hover{background:#c3e6cb}
.btn-delete{background:#f8e8e8;color:#8a2a2a}.btn-delete:hover{background:#f0d4d4}
.refresh-info{text-align:center;padding:8px;font-size:.78rem;color:#8a9aaa}
@media print{header,.card-actions,.refresh-info{display:none}.card{break-inside:avoid;border-left-width:2px;box-shadow:none}}
</style>
</head>
<body>
<header>
<h1>AZA Empfang</h1>
<div class="header-right">
<button class="sound-toggle" id="sound-btn" onclick="toggleSound()" title="Benachrichtigungston an/aus">🔔 Ton an</button>
<span class="badge" id="count-badge"></span>
</div>
</header>
<div class="container" id="messages-container">
<div class="empty">Nachrichten werden geladen…</div>
</div>
<div class="refresh-info">Aktualisiert automatisch alle 10 Sekunden</div>
<script>
const API_BASE = window.location.origin + '/empfang';
let lastData = '';
let lastOpenCount = -1;
let soundEnabled = localStorage.getItem('empfang_sound') !== 'off';
let audioCtx = null;
function updateSoundBtn() {
const btn = document.getElementById('sound-btn');
if (soundEnabled) {
btn.textContent = '🔔 Ton an';
btn.classList.remove('muted');
} else {
btn.textContent = '🔕 Ton aus';
btn.classList.add('muted');
}
}
updateSoundBtn();
function toggleSound() {
soundEnabled = !soundEnabled;
localStorage.setItem('empfang_sound', soundEnabled ? 'on' : 'off');
updateSoundBtn();
if (soundEnabled) playNotification();
}
function playNotification() {
if (!soundEnabled) return;
try {
if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
const now = audioCtx.currentTime;
const g = audioCtx.createGain();
g.connect(audioCtx.destination);
g.gain.setValueAtTime(0.15, now);
g.gain.exponentialRampToValueAtTime(0.001, now + 0.6);
const o1 = audioCtx.createOscillator();
o1.type = 'sine';
o1.frequency.setValueAtTime(880, now);
o1.frequency.setValueAtTime(1100, now + 0.15);
o1.connect(g);
o1.start(now);
o1.stop(now + 0.6);
} catch(e) {}
}
async function loadMessages() {
try {
const r = await fetch(API_BASE + '/messages');
const d = await r.json();
const raw = JSON.stringify(d.messages || []);
if (raw === lastData) return;
lastData = raw;
const msgs = d.messages || [];
const openCount = msgs.filter(m => m.status === 'offen').length;
if (lastOpenCount >= 0 && openCount > lastOpenCount) {
playNotification();
}
lastOpenCount = openCount;
render(msgs);
} catch (e) {
document.getElementById('messages-container').innerHTML =
'<div class="empty">Backend nicht erreichbar.<br>Bitte Verbindung prüfen.</div>';
}
}
function render(msgs) {
const c = document.getElementById('messages-container');
const open = msgs.filter(m => m.status === 'offen');
document.getElementById('count-badge').textContent = open.length + ' offen';
if (!msgs.length) {
c.innerHTML = '<div class="empty">Keine Nachrichten vorhanden.</div>';
return;
}
c.innerHTML = msgs.map(m => {
const isDone = m.status === 'erledigt';
const fields = [];
if (m.medikamente) fields.push({l:'Medikamente', v:m.medikamente});
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 / Chat', v:m.kommentar});
return `
<div class="card ${isDone?'done':''}">
<div class="card-header">
<div>
<div class="patient">${esc(m.patient || 'Ohne Patientenangabe')}</div>
<div class="meta">${esc(m.absender || '')} · ${esc(m.zeitstempel || m.empfangen || '')}</div>
</div>
<span class="status ${isDone?'status-erledigt':'status-offen'}">${isDone?'Erledigt':'Offen'}</span>
</div>
<div class="card-body">
${fields.map(f => `
<div class="field">
<div class="field-label">${esc(f.l)}</div>
<div class="field-value">${esc(f.v)}</div>
</div>
`).join('')}
${!fields.length?'<div class="field"><div class="field-value" style="color:#999">Keine Inhalte</div></div>':''}
</div>
<div class="card-actions">
<button class="btn btn-copy" onclick="copyCard('${m.id}')">Kopieren</button>
<button class="btn btn-print" onclick="printCard('${m.id}')">Drucken</button>
${!isDone?`<button class="btn btn-done" onclick="markDone('${m.id}')">Erledigt</button>`:''}
<button class="btn btn-delete" onclick="deleteMsg('${m.id}')">Löschen</button>
</div>
</div>`;
}).join('');
}
function esc(s) {
const d = document.createElement('div');
d.textContent = s || '';
return d.innerHTML;
}
function copyCard(id) {
const msgs = JSON.parse(lastData);
const m = msgs.find(x => x.id === id);
if (!m) return;
const parts = [];
if (m.patient) parts.push('Patient: ' + m.patient);
if (m.medikamente) parts.push('Medikamente:\n' + m.medikamente);
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);
parts.push('Absender: ' + (m.absender||'') + ' · ' + (m.zeitstempel||''));
navigator.clipboard.writeText(parts.join('\n\n')).then(() => {
const btn = event.target;
btn.textContent = '✓ Kopiert';
setTimeout(() => btn.textContent = 'Kopieren', 1500);
});
}
function printCard(id) {
const msgs = JSON.parse(lastData);
const m = msgs.find(x => x.id === id);
if (!m) return;
const w = window.open('', '_blank', 'width=600,height=600');
w.document.write(`<html><head><title>Empfang</title>
<style>body{font-family:'Segoe UI',sans-serif;padding:30px;color:#1a2a3a}
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}
</style></head><body>
<h2>${esc(m.patient||'Empfangsnachricht')}</h2>
${m.medikamente?`<div class="f"><div class="fl">Medikamente</div><div class="fv">${esc(m.medikamente)}</div></div>`:''}
${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 / Chat</div><div class="fv">${esc(m.kommentar)}</div></div>`:''}
<div class="meta">Absender: ${esc(m.absender||'')} · ${esc(m.zeitstempel||m.empfangen||'')}</div>
</body></html>`);
w.document.close();
w.print();
}
async function markDone(id) {
await fetch(API_BASE + '/messages/' + id + '/done', {method:'POST'});
lastData = '';
loadMessages();
}
async function deleteMsg(id) {
if (!confirm('Nachricht wirklich löschen?')) return;
await fetch(API_BASE + '/messages/' + id, {method:'DELETE'});
lastData = '';
loadMessages();
}
loadMessages();
setInterval(loadMessages, 10000);
</script>
</body>
</html>