feat(admin): ein Speichern-Button auf Einstellungs-Ebene mit Format-Validierung

- Speichern-Button aus dem einklappbaren „Notfall-Profil" auf die Karten-/
  Einstellungs-Ebene verschoben (immer sichtbar) und in „Speichern" umbenannt.
- Speichert jetzt ALLE Daten: alle Profilfelder + Standort-Freigabe + erlaubte
  Sprachen in einem PUT.
- Vor JEDEM Speichern Format-Validierung pro Feld; bei Verstoß rotes Feld
  (border/ring-red) + Meckertext, Speichern wird abgebrochen. Rote Markierung
  verschwindet beim Nachbessern (input-Listener).
- Regeln: PLZ = 5 Ziffern · Telefon = +/Ziffern/Leer/()/- (6–20) · Geburtsdatum
  = TT.MM.JJJJ (echtes Datum) · Kontakt-E-Mails/-Telefone = CSV gültiger Werte.
  Leere optionale Felder gelten als konform. Validierungslogik in node geprüft.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-06-25 18:03:13 +02:00
commit 57002dc022

View file

@ -330,6 +330,27 @@ async function copyText(text, btn) {
const ALL_LANGS = [["de","DE"],["en","EN"],["fr","FR"],["es","ES"],["it","IT"],
["pt","PT"],["pl","PL"],["ar","AR"],["ru","RU"],["zh","ZH"]];
// --- Format-Validierung der Notfall-Profil-Felder ------------------------
const _isEmail = (v) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v);
const _isPhone = (v) => /^\+?[\d\s/().\-]{6,20}$/.test(v);
function _isGermanDate(v) {
const m = /^(\d{2})\.(\d{2})\.(\d{4})$/.exec(v);
if (!m) return false;
const d = +m[1], mo = +m[2], y = +m[3];
const dt = new Date(y, mo - 1, d);
return dt.getDate() === d && dt.getMonth() === mo - 1 && dt.getFullYear() === y && y >= 1900 && y <= 2100;
}
const _csvAll = (v, fn) => v.split(",").map((s) => s.trim()).filter(Boolean).every(fn);
// pro Feld: ok(wert) wird NUR bei nicht-leerem Wert geprüft + Meckertext.
const PROF_VALIDATORS = {
postal_code: { ok: (v) => /^\d{5}$/.test(v), msg: "PLZ: genau 5 Ziffern" },
phone_mobile: { ok: _isPhone, msg: "Mobil: gültige Telefonnummer (z. B. +49 151 …)" },
phone_landline: { ok: _isPhone, msg: "Festnetz: gültige Telefonnummer" },
birth_date: { ok: _isGermanDate, msg: "Geburtsdatum: TT.MM.JJJJ" },
emergency_contacts: { ok: (v) => _csvAll(v, _isEmail), msg: "Kontakt-E-Mail(s): gültige Adressen (Komma-getrennt)" },
emergency_phones: { ok: (v) => _csvAll(v, _isPhone), msg: "Kontakt-Telefon(e): gültige Nummern (Komma-getrennt)" },
};
// Wendet die vom Admin erlaubten Sprachen auf das Sprachmenü an. Leer = alle erlaubt.
// Genau eine erlaubte Sprache -> Menü ausblenden (kein Stress für den Nutzer).
function applyAllowedLanguages(prefs) {
@ -1100,20 +1121,22 @@ function buildUserCard(u) {
<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)" />
<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 (TT.MM.JJJJ)" />
<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>
<!-- Speichern (Einstellungs-Ebene) prüft Datenformate, speichert ALLE Felder + Sprachen -->
<div class="mt-3 pt-3 border-t border-slate-100 dark:border-slate-700 flex items-center gap-2 flex-wrap">
<button class="prof-save rounded-lg bg-blue-600 hover:bg-blue-700 text-white text-sm px-4 py-1.5 transition-colors">Speichern</button>
<span class="prof-status text-xs text-slate-400"></span>
</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">
@ -1170,17 +1193,49 @@ function buildUserCard(u) {
profSection.classList.toggle("hidden", open);
profArrow.textContent = open ? "▶" : "▼";
});
const profStatus = card.querySelector(".prof-status");
// Prüft jedes Feld gegen sein Format (nur nicht-leere Werte), markiert Fehler rot.
// Liefert die Liste der Meckertexte (leer = alles konform).
function validateProfile() {
const errs = [];
PROF_KEYS.forEach((k) => {
const el = card.querySelector(`.prof-f[data-k="${k}"]`);
if (!el) return;
const v = el.value.trim();
const rule = PROF_VALIDATORS[k];
const bad = !!v && !!rule && !rule.ok(v);
el.classList.toggle("border-red-500", bad);
el.classList.toggle("ring-2", bad);
el.classList.toggle("ring-red-500", bad);
if (bad) errs.push(rule.msg);
});
return errs;
}
// Rote Markierung verschwindet, sobald nachgebessert wird.
PROF_KEYS.forEach((k) => {
const el = card.querySelector(`.prof-f[data-k="${k}"]`);
if (el) el.addEventListener("input", validateProfile);
});
card.querySelector(".prof-save").addEventListener("click", async () => {
const body = { location_consent: !!consentEl.checked };
const errs = validateProfile();
if (errs.length) {
profStatus.className = "prof-status text-xs text-red-600 dark:text-red-400";
profStatus.textContent = "Bitte korrigieren: " + errs.join(" · ");
return;
}
const body = { location_consent: !!consentEl.checked, allowed_languages: allowed.join(",") };
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 …";
profStatus.className = "prof-status text-xs text-slate-400";
profStatus.textContent = "speichere …";
const res = await adminFetch(`/api/admin/users/${uid}/prefs`, "PUT", body);
status.textContent = res ? "✓ gespeichert" : "Fehler";
setTimeout(() => { status.textContent = ""; }, 2500);
profStatus.className = "prof-status text-xs " + (res ? "text-green-600 dark:text-green-400" : "text-red-600 dark:text-red-400");
profStatus.textContent = res ? "✓ alle Daten gespeichert" : "Fehler beim Speichern";
if (res) setTimeout(() => { profStatus.textContent = ""; }, 3000);
});
// Admin-Rechte (persistentes Flag) ein-/ausschalten