Empfang: Backend-Router + Browser-UI
This commit is contained in:
@@ -794,6 +794,13 @@ try:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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")
|
@app.on_event("startup")
|
||||||
def _print_routes():
|
def _print_routes():
|
||||||
|
|||||||
115
AzA march 2026/empfang_routes.py
Normal file
115
AzA march 2026/empfang_routes.py
Normal 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)
|
||||||
228
AzA march 2026/web/empfang.html
Normal file
228
AzA march 2026/web/empfang.html
Normal 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>
|
||||||
Reference in New Issue
Block a user