feat: Notruf-Frontend — Geoposition + Admin-Notfallprofil (Phase 2)
- 🆘-Handler erfasst beim Auslösen die Geoposition (One-Shot, navigator.
geolocation, enableHighAccuracy) und hängt sie als geo:{lat,lon,accuracy,ts}
an /api/emergency. Timeout-Race (8 s), damit hängendes GPS den Alarm nie
blockiert; nur mit Freigabe (location_consent).
- loadMe: Prefs global gehalten; bei location_consent Standort-Erlaubnis vorab
angefragt (kein Browser-Dialog im Panikmoment).
- Admin-User-Karte: einklappbares „Notfall-Profil" zum Pflegen von Name,
Adresse, Telefonen, Geburtsdatum, med. Hinweisen, Kontakt-E-Mail/-Telefon
und der Standort-Freigabe; speichert via PUT /api/admin/users/{id}/prefs.
Nutzt das in Phase 1+3 gebaute Backend; keine Backend-Änderung. app.js per
node --check validiert.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
5ed4a386bb
commit
2a013ae9fe
1 changed files with 99 additions and 1 deletions
100
app/web/app.js
100
app/web/app.js
|
|
@ -316,6 +316,7 @@ let activeSources = []; // laufende AudioBufferSourceNodes (stoppbar per Barge-
|
|||
// /api/me abgeleitet; bis dahin null -> ensureSession() wartet auf loadMe().
|
||||
let sessionId = null;
|
||||
let mePromise = null;
|
||||
let myPrefs = {}; // Prefs des aktuellen Nutzers (u. a. location_consent für den Notruf-Standort)
|
||||
let currentUserId = null;
|
||||
const sessionKey = (uid) => "va-session-" + uid;
|
||||
|
||||
|
|
@ -368,6 +369,14 @@ function loadMe() {
|
|||
}
|
||||
// Gespeicherte Nutzer-Präferenzen in die Dropdowns laden.
|
||||
const prefs = me.prefs || {};
|
||||
myPrefs = prefs;
|
||||
// Standort-Erlaubnis ggf. vorab anfragen, damit im Notfall kein Browser-Dialog kommt.
|
||||
if (prefs.location_consent && navigator.geolocation) {
|
||||
try {
|
||||
navigator.geolocation.getCurrentPosition(() => {}, () => {},
|
||||
{ maximumAge: 600000, timeout: 8000 });
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
applyAllowedLanguages(prefs);
|
||||
applyPlaybackChoice(prefs.tts_provider);
|
||||
applyGenderChoice(prefs.voice_gender || 'f');
|
||||
|
|
@ -1073,6 +1082,36 @@ function buildUserCard(u) {
|
|||
<div class="lang-allow flex flex-wrap gap-1"></div>
|
||||
</div>
|
||||
|
||||
<!-- Notfall-Profil -->
|
||||
<div class="mt-3 pt-3 border-t border-slate-100 dark:border-slate-700">
|
||||
<button class="btn-prof text-xs text-slate-500 hover:text-slate-700 dark:hover:text-slate-300 flex items-center gap-1 transition-colors">
|
||||
<span class="prof-arrow">▶</span> Notfall-Profil (fließt in Notruf-Meldungen)
|
||||
</button>
|
||||
<div class="prof-section hidden mt-2 space-y-1.5">
|
||||
<input class="prof-f w-full rounded-lg border border-slate-300 dark:border-slate-600 bg-transparent px-3 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-blue-500" data-k="full_name" placeholder="Voller Name" />
|
||||
<input class="prof-f w-full rounded-lg border border-slate-300 dark:border-slate-600 bg-transparent px-3 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-blue-500" data-k="street" placeholder="Straße + Hausnummer" />
|
||||
<div class="flex gap-1.5">
|
||||
<input class="prof-f w-1/3 rounded-lg border border-slate-300 dark:border-slate-600 bg-transparent px-3 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-blue-500" data-k="postal_code" placeholder="PLZ" />
|
||||
<input class="prof-f w-2/3 rounded-lg border border-slate-300 dark:border-slate-600 bg-transparent px-3 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-blue-500" data-k="city" placeholder="Ort" />
|
||||
</div>
|
||||
<div class="flex gap-1.5">
|
||||
<input class="prof-f w-1/2 rounded-lg border border-slate-300 dark:border-slate-600 bg-transparent px-3 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-blue-500" data-k="phone_mobile" placeholder="Mobil" />
|
||||
<input class="prof-f w-1/2 rounded-lg border border-slate-300 dark:border-slate-600 bg-transparent px-3 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-blue-500" data-k="phone_landline" placeholder="Festnetz" />
|
||||
</div>
|
||||
<input class="prof-f w-full rounded-lg border border-slate-300 dark:border-slate-600 bg-transparent px-3 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-blue-500" data-k="birth_date" placeholder="Geburtsdatum (z. B. 1940-05-01)" />
|
||||
<textarea class="prof-f w-full rounded-lg border border-slate-300 dark:border-slate-600 bg-transparent px-3 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-blue-500" data-k="medical_notes" rows="2" placeholder="Medizinische Hinweise (Allergien, Medikamente, Vorerkrankungen)"></textarea>
|
||||
<input class="prof-f w-full rounded-lg border border-slate-300 dark:border-slate-600 bg-transparent px-3 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-blue-500" data-k="emergency_contacts" placeholder="Kontakt-E-Mail(s), Komma-getrennt" />
|
||||
<input class="prof-f w-full rounded-lg border border-slate-300 dark:border-slate-600 bg-transparent px-3 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-blue-500" data-k="emergency_phones" placeholder="Kontakt-Telefon(e), Komma-getrennt" />
|
||||
<label class="flex items-center gap-2 text-xs text-slate-600 dark:text-slate-300">
|
||||
<input type="checkbox" class="prof-consent" /> Standort im Notfall mitsenden
|
||||
</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<button class="prof-save rounded-lg bg-blue-600 hover:bg-blue-700 text-white text-xs px-3 py-1.5 transition-colors">Profil speichern</button>
|
||||
<span class="prof-status text-xs text-slate-400"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Erinnerungen -->
|
||||
<div class="mt-3 pt-3 border-t border-slate-100 dark:border-slate-700">
|
||||
<button class="btn-mem text-xs text-slate-500 hover:text-slate-700 dark:hover:text-slate-300 flex items-center gap-1 transition-colors">
|
||||
|
|
@ -1111,6 +1150,37 @@ function buildUserCard(u) {
|
|||
}
|
||||
renderLangChips();
|
||||
|
||||
// Notfall-Profil — Admin pflegt Kontaktdaten + med. Hinweise + Standort-Freigabe.
|
||||
const PROF_KEYS = ["full_name", "street", "postal_code", "city", "phone_mobile",
|
||||
"phone_landline", "birth_date", "medical_notes",
|
||||
"emergency_contacts", "emergency_phones"];
|
||||
PROF_KEYS.forEach((k) => {
|
||||
const el = card.querySelector(`.prof-f[data-k="${k}"]`);
|
||||
if (el) el.value = u[k] || "";
|
||||
});
|
||||
const consentEl = card.querySelector(".prof-consent");
|
||||
if (consentEl) consentEl.checked = !!u.location_consent;
|
||||
const profBtn = card.querySelector(".btn-prof");
|
||||
const profSection = card.querySelector(".prof-section");
|
||||
const profArrow = card.querySelector(".prof-arrow");
|
||||
profBtn.addEventListener("click", () => {
|
||||
const open = !profSection.classList.contains("hidden");
|
||||
profSection.classList.toggle("hidden", open);
|
||||
profArrow.textContent = open ? "▶" : "▼";
|
||||
});
|
||||
card.querySelector(".prof-save").addEventListener("click", async () => {
|
||||
const body = { location_consent: !!consentEl.checked };
|
||||
PROF_KEYS.forEach((k) => {
|
||||
const el = card.querySelector(`.prof-f[data-k="${k}"]`);
|
||||
body[k] = el ? el.value.trim() : "";
|
||||
});
|
||||
const status = card.querySelector(".prof-status");
|
||||
status.textContent = "speichere …";
|
||||
const res = await adminFetch(`/api/admin/users/${uid}/prefs`, "PUT", body);
|
||||
status.textContent = res ? "✓ gespeichert" : "Fehler";
|
||||
setTimeout(() => { status.textContent = ""; }, 2500);
|
||||
});
|
||||
|
||||
// Admin-Rechte (persistentes Flag) ein-/ausschalten
|
||||
let isAdmin = !!u.is_admin;
|
||||
const adminBtn = card.querySelector(".btn-admin");
|
||||
|
|
@ -2020,16 +2090,44 @@ $("#load-settings").addEventListener("click", loadSettings);
|
|||
// Bewusste Nutzeraktion mit Bestätigung. Eskalation (Benachrichtigung) folgt
|
||||
// später; vorerst zeigt der Server einen Hinweis in der Nutzersprache.
|
||||
const emergencyBtn = $("#emergency-btn");
|
||||
|
||||
// Standort (One-Shot) für den Notruf erfassen — nur mit Freigabe (location_consent)
|
||||
// und mit Timeout, damit ein hängendes GPS den Alarm nicht blockiert. null = kein Fix.
|
||||
function captureGeo(timeoutMs = 8000) {
|
||||
return new Promise((resolve) => {
|
||||
if (!myPrefs.location_consent || !navigator.geolocation) return resolve(null);
|
||||
let done = false;
|
||||
const finish = (v) => { if (!done) { done = true; resolve(v); } };
|
||||
const timer = setTimeout(() => finish(null), timeoutMs);
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => {
|
||||
clearTimeout(timer);
|
||||
finish({
|
||||
lat: pos.coords.latitude,
|
||||
lon: pos.coords.longitude,
|
||||
accuracy: pos.coords.accuracy,
|
||||
ts: new Date(pos.timestamp).toISOString(),
|
||||
});
|
||||
},
|
||||
() => { clearTimeout(timer); finish(null); },
|
||||
{ enableHighAccuracy: true, timeout: timeoutMs, maximumAge: 60000 }
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (emergencyBtn) {
|
||||
emergencyBtn.addEventListener("click", async () => {
|
||||
if (!confirm("Wirklich Hilfe rufen?")) return;
|
||||
const lang = (langSel && langSel.value) || "de";
|
||||
emergencyBtn.disabled = true;
|
||||
try {
|
||||
const geo = await captureGeo(); // null, wenn keine Freigabe/kein Fix
|
||||
const body = { language: lang };
|
||||
if (geo) body.geo = geo;
|
||||
const res = await fetch("/api/emergency", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ language: lang }),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
const notice = (data && data.notice) ||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue