You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

886 lines
35 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#111418">
<title>Monitor-Check</title>
<style>
:root{
--bg:#f5f6f7;
--surface:#ffffff;
--line:#d9dce0;
--line-strong:#b7bcc3;
--ink:#111418;
--ink-2:#5a616b;
--ink-3:#8b929b;
--accent:#1b57d6;
--ok:#1a7f45; --ok-bg:#e4f3ea;
--teil:#9a6400; --teil-bg:#fdf1d8;
--fehler:#b3261e; --fehler-bg:#fbe5e3;
--aus:#5a616b; --aus-bg:#eceef0;
--offen:#5a616b; --offen-bg:#ffffff;
--r:10px;
--tap:48px;
}
*{box-sizing:border-box;-webkit-tap-highlight-color:transparent}
/* muss vor den Layoutregeln stehen: display:block wuerde [hidden] sonst aushebeln */
[hidden]{display:none !important}
html,body{margin:0;padding:0}
body{
background:var(--bg);color:var(--ink);
font:16px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
padding-bottom:calc(72px + env(safe-area-inset-bottom));
}
.mono{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-variant-numeric:tabular-nums}
header{
position:sticky;top:0;z-index:30;background:var(--surface);
border-bottom:1px solid var(--line);
padding:calc(10px + env(safe-area-inset-top)) 14px 10px;
display:flex;align-items:center;gap:10px;
}
header h1{font-size:16px;font-weight:650;margin:0;letter-spacing:-.01em;flex:1}
.netstate{font-size:11px;padding:3px 8px;border-radius:999px;border:1px solid var(--line);color:var(--ink-2);white-space:nowrap}
.netstate.off{background:var(--fehler-bg);border-color:#f0c4c0;color:var(--fehler)}
.netstate.queue{background:var(--teil-bg);border-color:#eddaa8;color:var(--teil)}
main{padding:14px;max-width:720px;margin:0 auto}
.view{display:none}
.view.on{display:block}
nav{
position:fixed;left:0;right:0;bottom:0;z-index:30;
background:var(--surface);border-top:1px solid var(--line);
display:grid;grid-template-columns:repeat(3,1fr);
padding-bottom:env(safe-area-inset-bottom);
}
nav button{
appearance:none;border:0;background:none;color:var(--ink-3);
padding:12px 4px;font:inherit;font-size:12px;font-weight:550;
display:flex;flex-direction:column;align-items:center;gap:4px;cursor:pointer;
min-height:var(--tap);
}
nav button svg{width:22px;height:22px;stroke:currentColor;fill:none;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round}
nav button.on{color:var(--accent)}
.card{background:var(--surface);border:1px solid var(--line);border-radius:var(--r);padding:14px;margin-bottom:12px}
.card h2{font-size:13px;font-weight:650;text-transform:uppercase;letter-spacing:.06em;color:var(--ink-2);margin:0 0 12px}
label{display:block;font-size:13px;font-weight:600;color:var(--ink-2);margin:0 0 6px}
input[type=text],input[type=url],input[type=password],textarea,select{
width:100%;padding:12px;font:inherit;color:var(--ink);
background:#fff;border:1px solid var(--line-strong);border-radius:8px;min-height:var(--tap);
}
input:focus,textarea:focus,select:focus{outline:2px solid var(--accent);outline-offset:-1px;border-color:var(--accent)}
textarea{min-height:76px;resize:vertical}
.field{margin-bottom:14px}
.hint{font-size:12px;color:var(--ink-3);margin-top:5px}
button.btn{
appearance:none;font:inherit;font-weight:600;cursor:pointer;
border:1px solid var(--line-strong);background:#fff;color:var(--ink);
border-radius:8px;padding:13px 16px;min-height:var(--tap);width:100%;
}
button.btn.primary{background:var(--ink);border-color:var(--ink);color:#fff}
button.btn.ghost{background:transparent}
button.btn.danger{color:var(--fehler);border-color:#f0c4c0}
button.btn.danger:active{background:var(--fehler-bg)}
button.btn:disabled{opacity:.5;cursor:default}
.btnrow{display:flex;gap:8px}
.btnrow .btn{width:auto;flex:1}
/* Status-Schalter */
.triple{display:flex;flex-direction:column;gap:10px}
.trow{display:grid;grid-template-columns:78px 1fr;align-items:center;gap:10px}
.trow > span{font-size:14px;font-weight:600}
.seg{display:grid;grid-template-columns:repeat(3,1fr);border:1px solid var(--line-strong);border-radius:8px;overflow:hidden}
.seg button{
appearance:none;border:0;border-right:1px solid var(--line-strong);background:#fff;
font:inherit;font-size:14px;font-weight:600;color:var(--ink-2);
padding:12px 4px;min-height:var(--tap);cursor:pointer;
}
.seg button:last-child{border-right:0}
.seg button[aria-pressed=true][data-v=JA]{background:var(--ok-bg);color:var(--ok);box-shadow:inset 0 0 0 2px var(--ok)}
.seg button[aria-pressed=true][data-v=NEIN]{background:var(--fehler-bg);color:var(--fehler);box-shadow:inset 0 0 0 2px var(--fehler)}
.seg button[aria-pressed=true][data-v="n.A."]{background:var(--aus-bg);color:var(--aus);box-shadow:inset 0 0 0 2px var(--aus)}
.chip{
display:inline-flex;align-items:center;gap:6px;
font-size:12px;font-weight:650;padding:4px 10px;border-radius:999px;border:1px solid transparent;white-space:nowrap;
}
.chip::before{content:"";width:7px;height:7px;border-radius:50%;background:currentColor}
.s-ok{background:var(--ok-bg);color:var(--ok)}
.s-teil{background:var(--teil-bg);color:var(--teil)}
.s-fehler{background:var(--fehler-bg);color:var(--fehler)}
.s-aus{background:var(--aus-bg);color:var(--aus)}
.s-offen{background:#fff;color:var(--offen);border-color:var(--line-strong)}
.preview{
display:flex;align-items:center;justify-content:space-between;gap:10px;
padding:12px;border:1px dashed var(--line-strong);border-radius:8px;margin-top:12px;
}
.preview small{color:var(--ink-3);font-size:12px}
/* Foto */
.photo{position:relative;border:1px dashed var(--line-strong);border-radius:8px;overflow:hidden;background:#fff}
.photo img{display:block;width:100%;max-height:260px;object-fit:cover}
.photo .ph{padding:26px 12px;text-align:center;color:var(--ink-3);font-size:14px}
.photo .rm{position:absolute;top:8px;right:8px;width:34px;height:34px;border-radius:50%;border:0;background:rgba(17,20,24,.78);color:#fff;font-size:18px;cursor:pointer;line-height:1}
input[type=file]{display:none}
/* Liste */
.toolbar{display:flex;gap:8px;margin-bottom:10px}
.toolbar input{flex:1}
.filters{display:flex;gap:6px;overflow-x:auto;padding-bottom:10px;margin-bottom:4px;-webkit-overflow-scrolling:touch}
.filters button{
appearance:none;font:inherit;font-size:13px;font-weight:600;white-space:nowrap;cursor:pointer;
border:1px solid var(--line-strong);background:#fff;color:var(--ink-2);border-radius:999px;padding:8px 13px;
}
.filters button.on{background:var(--ink);border-color:var(--ink);color:#fff}
.item{
display:flex;gap:12px;align-items:center;width:100%;text-align:left;cursor:pointer;
background:var(--surface);border:1px solid var(--line);border-radius:var(--r);
padding:10px;margin-bottom:8px;font:inherit;
}
.item .thumb{width:56px;height:56px;border-radius:6px;background:var(--aus-bg);flex:0 0 auto;object-fit:cover}
.item .thumb.empty{display:flex;align-items:center;justify-content:center;color:var(--ink-3);font-size:11px}
.item .meta{flex:1;min-width:0}
.item .idline{font-size:11px;color:var(--ink-3);letter-spacing:.04em}
.item .name{font-weight:600;font-size:15px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.item .sub{font-size:12px;color:var(--ink-3);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.stats{display:grid;grid-template-columns:repeat(5,1fr);gap:6px;margin-bottom:12px}
.stat{background:var(--surface);border:1px solid var(--line);border-radius:8px;padding:8px 4px;text-align:center}
.stat b{display:block;font-size:18px;font-variant-numeric:tabular-nums}
.stat span{font-size:10px;color:var(--ink-3);text-transform:uppercase;letter-spacing:.04em}
/* Detail-Sheet */
.sheet{position:fixed;inset:0;z-index:50;display:none}
.sheet.on{display:block}
.sheet .bd{position:absolute;inset:0;background:rgba(17,20,24,.45)}
.sheet .pn{
position:absolute;left:0;right:0;bottom:0;max-height:92vh;overflow:auto;
background:var(--bg);border-radius:16px 16px 0 0;padding:16px 14px calc(20px + env(safe-area-inset-bottom));
}
.sheet .grip{width:38px;height:4px;border-radius:2px;background:var(--line-strong);margin:0 auto 14px}
@media(min-width:720px){.sheet .pn{max-width:640px;margin:0 auto;border-radius:16px;bottom:auto;top:50%;transform:translateY(-50%)}}
.toast{
position:fixed;left:50%;transform:translateX(-50%);bottom:calc(84px + env(safe-area-inset-bottom));
z-index:60;background:var(--ink);color:#fff;padding:11px 16px;border-radius:8px;
font-size:14px;font-weight:550;max-width:88vw;text-align:center;opacity:0;pointer-events:none;transition:opacity .18s;
}
.toast.on{opacity:1}
.toast.err{background:var(--fehler)}
.empty{text-align:center;color:var(--ink-3);padding:40px 20px;font-size:14px}
.row2{display:grid;grid-template-columns:1fr 1fr;gap:10px}
dl.kv{margin:0;font-size:13px}
dl.kv div{display:flex;justify-content:space-between;gap:12px;padding:6px 0;border-bottom:1px solid var(--line)}
dl.kv dt{color:var(--ink-3)}
dl.kv dd{margin:0;text-align:right}
</style>
</head>
<body>
<header>
<h1>Monitor-Check</h1>
<span class="netstate" id="netstate">bereit</span>
</header>
<main>
<!-- ============ ERFASSEN ============ -->
<section class="view on" id="v-erfassen">
<div class="card">
<h2>Neuer Eintrag</h2>
<div class="field">
<label for="f-bez">Bezeichnung *</label>
<input type="text" id="f-bez" placeholder="z. B. Monitor Foyer Nord" autocomplete="off">
</div>
<div class="field">
<label for="f-ort">Standort</label>
<input type="text" id="f-ort" placeholder="z. B. Halle 2, Reihe A" autocomplete="off">
</div>
<div class="field">
<label>Foto</label>
<div class="photo" id="f-photo">
<div class="ph" id="f-photo-ph">Tippen zum Fotografieren</div>
<img id="f-photo-img" alt="" hidden>
<button class="rm" id="f-photo-rm" hidden aria-label="Foto entfernen">&times;</button>
</div>
<input type="file" id="f-file" accept="image/*" capture="environment">
<p class="hint" id="f-photo-hint">Wird auf deinen Server geladen, das Sheet speichert die URL.</p>
</div>
<div class="field">
<label>Status sofort erfassen (optional)</label>
<div class="triple" id="f-triple"></div>
<div class="preview">
<span class="chip s-offen" id="f-status">Nicht geprüft</span>
<small>wird automatisch berechnet</small>
</div>
</div>
<div class="field">
<label for="f-notiz">Notiz</label>
<textarea id="f-notiz" placeholder="Auffälligkeiten, Defekte, Zuständigkeit …"></textarea>
</div>
<button class="btn primary" id="f-save">Eintrag anlegen</button>
</div>
</section>
<!-- ============ ÜBERSICHT ============ -->
<section class="view" id="v-liste">
<div class="stats" id="stats"></div>
<div class="toolbar">
<input type="text" id="q" placeholder="ID, Bezeichnung, Standort …" autocomplete="off">
<button class="btn" id="reload" style="width:auto;padding:0 16px" aria-label="Neu laden">&#8635;</button>
</div>
<div class="filters" id="filters"></div>
<div id="list"></div>
</section>
<!-- ============ EINSTELLUNGEN ============ -->
<section class="view" id="v-setup">
<div class="card">
<h2>Verbindung</h2>
<div class="field">
<label for="s-api">Apps-Script Web-App URL</label>
<input type="url" id="s-api" placeholder="https://script.google.com/macros/s/…/exec" autocomplete="off">
</div>
<div class="field">
<label for="s-token">API-Token</label>
<input type="password" id="s-token" placeholder="Token aus Code.gs" autocomplete="off">
</div>
<div class="field">
<label for="s-upload">Upload-Endpoint</label>
<input type="url" id="s-upload" placeholder="https://dein-server/api/upload" autocomplete="off">
<p class="hint">Leer lassen, wenn ohne Foto gearbeitet wird.</p>
</div>
<div class="field">
<label for="s-user">Dein Name</label>
<input type="text" id="s-user" placeholder="wird als Prüfer gespeichert" autocomplete="off">
</div>
<div class="btnrow">
<button class="btn primary" id="s-save">Speichern</button>
<button class="btn" id="s-test">Verbindung testen</button>
</div>
</div>
<div class="card">
<h2>Statusregeln</h2>
<dl class="kv">
<div><dt>Audio / Bild / Sprache alle JA</dt><dd><span class="chip s-ok">Funktionsfähig</span></dd></div>
<div><dt>Teilweise NEIN</dt><dd><span class="chip s-teil">Teilweise</span></dd></div>
<div><dt>Alle bewerteten NEIN</dt><dd><span class="chip s-fehler">Fehlerhaft</span></dd></div>
<div><dt>Alle drei n.A.</dt><dd><span class="chip s-aus">Deaktiviert</span></dd></div>
<div style="border:0"><dt>Nichts erfasst</dt><dd><span class="chip s-offen">Nicht geprüft</span></dd></div>
</dl>
<p class="hint">Einzelne Felder mit „n.A.“ werden aus der Bewertung ausgeschlossen und zählen nicht als Fehler.</p>
</div>
<div class="card">
<h2>Offline-Warteschlange</h2>
<p class="hint" id="q-info" style="margin-top:0">–</p>
<div class="btnrow">
<button class="btn" id="q-flush">Jetzt senden</button>
<button class="btn ghost" id="q-clear">Verwerfen</button>
</div>
</div>
</section>
</main>
<nav>
<button class="on" data-view="erfassen">
<svg viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"/></svg>Erfassen
</button>
<button data-view="liste">
<svg viewBox="0 0 24 24"><path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"/></svg>Übersicht
</button>
<button data-view="setup">
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.9l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-2.9 1.2v.1a2 2 0 1 1-4 0v-.2a1.7 1.7 0 0 0-3-1.1l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0-1.1-2.9H3a2 2 0 1 1 0-4h.2A1.7 1.7 0 0 0 4.3 6l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 2.9-1.1V2a2 2 0 1 1 4 0v.2a1.7 1.7 0 0 0 2.9 1.1l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0 1.1 2.9h.2a2 2 0 1 1 0 4h-.2a1.7 1.7 0 0 0-1.4 1z"/></svg>Setup
</button>
</nav>
<!-- Detail -->
<div class="sheet" id="detail">
<div class="bd" data-close></div>
<div class="pn" id="detail-pn"></div>
</div>
<div class="toast" id="toast"></div>
<script>
"use strict";
/* ================= Konfiguration ================= */
/* Optional hier fest eintragen – sonst über den Setup-Tab pflegen. */
var DEFAULTS = {
api: "",
token: "",
upload: "",
user: ""
};
var LS = "monitorcheck.v1";
var cfg = Object.assign({}, DEFAULTS, load(LS + ".cfg", {}));
var queue = load(LS + ".queue", []);
var items = load(LS + ".cache", []);
function load(k, fb){ try{ var v = localStorage.getItem(k); return v ? JSON.parse(v) : fb; }catch(e){ return fb; } }
function save(k, v){ try{ localStorage.setItem(k, JSON.stringify(v)); }catch(e){} }
/* ================= Statuslogik (identisch zu Code.gs) ================= */
var S = {
OK:"Funktionsfähig", TEIL:"Teilweise funktionsfähig",
FEHLER:"Fehlerhaft", AUS:"Deaktiviert", OFFEN:"Nicht geprüft"
};
var CLS = {};
CLS[S.OK]="s-ok"; CLS[S.TEIL]="s-teil"; CLS[S.FEHLER]="s-fehler";
CLS[S.AUS]="s-aus"; CLS[S.OFFEN]="s-offen";
function norm(v){
var s = String(v == null ? "" : v).trim().toUpperCase();
if(!s) return "";
if(s==="JA"||s==="J"||s==="TRUE") return "JA";
if(s==="NEIN"||s==="N"||s==="FALSE") return "NEIN";
if(s==="N.A."||s==="NA"||s==="N/A"||s==="N.A") return "n.A.";
return "";
}
function berechneStatus(a,b,c){
var w = [norm(a), norm(b), norm(c)];
var gesetzt = w.filter(function(x){ return x !== ""; });
if(!gesetzt.length) return S.OFFEN;
if(w.filter(function(x){ return x==="n.A."; }).length === 3) return S.AUS;
var bew = w.filter(function(x){ return x==="JA" || x==="NEIN"; });
if(!bew.length) return S.AUS;
var nein = bew.filter(function(x){ return x==="NEIN"; }).length;
if(nein === 0) return S.OK;
if(nein === bew.length) return S.FEHLER;
return S.TEIL;
}
/* Vereinheitlicht Statustexte aus der Tabelle – aeltere Zeilen stehen dort
ohne Umlaute ("Funktionsfaehig"). Ohne das greifen Filter und Zaehler nicht. */
function statusKanon(v){
var k = String(v == null ? "" : v).toLowerCase().trim()
.replace(/ä/g,"ae").replace(/ö/g,"oe").replace(/ü/g,"ue").replace(/ß/g,"ss");
if(k === "funktionsfaehig") return S.OK;
if(k === "teilweise funktionsfaehig") return S.TEIL;
if(k === "fehlerhaft") return S.FEHLER;
if(k === "deaktiviert") return S.AUS;
return S.OFFEN;
}
/* Kurzform für die Statistik-Kacheln */
function shortStatus(s){
if(s===S.OK) return "OK";
if(s===S.TEIL) return "Teil";
if(s===S.FEHLER) return "Fehler";
if(s===S.AUS) return "Aus";
return "Offen";
}
/* ================= Hilfen ================= */
var $ = function(sel, root){ return (root||document).querySelector(sel); };
var $$ = function(sel, root){ return Array.prototype.slice.call((root||document).querySelectorAll(sel)); };
function esc(s){
return String(s == null ? "" : s).replace(/[&<>"']/g, function(c){
return {"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c];
});
}
var toastTimer;
function toast(msg, isErr){
var t = $("#toast");
t.textContent = msg;
t.className = "toast on" + (isErr ? " err" : "");
clearTimeout(toastTimer);
toastTimer = setTimeout(function(){ t.className = "toast"; }, 2800);
}
function chip(status){
return '<span class="chip ' + (CLS[status]||"s-offen") + '">' + esc(status) + "</span>";
}
function datum(iso){
if(!iso) return "–";
var d = new Date(iso);
if(isNaN(d)) return String(iso);
return d.toLocaleString("de-DE",{day:"2-digit",month:"2-digit",year:"2-digit",hour:"2-digit",minute:"2-digit"});
}
/* ================= API ================= */
/* text/plain vermeidet den CORS-Preflight, den Apps Script nicht beantwortet. */
function api(action, payload){
if(!cfg.api) return Promise.reject(new Error("Keine Apps-Script-URL hinterlegt (Setup)."));
var body = Object.assign({ action: action, token: cfg.token }, payload || {});
return fetch(cfg.api, {
method: "POST",
redirect: "follow",
headers: { "Content-Type": "text/plain;charset=utf-8" },
body: JSON.stringify(body)
})
.then(function(r){ return r.text(); })
.then(function(txt){
var data;
try{ data = JSON.parse(txt); }
catch(e){ throw new Error("Unerwartete Antwort – Web-App-Zugriff auf „Jeder“ gesetzt?"); }
if(!data.ok) throw new Error(data.error || "Fehler");
return data;
});
}
function uploadFoto(file){
if(!cfg.upload) return Promise.reject(new Error("Kein Upload-Endpoint hinterlegt (Setup)."));
var fd = new FormData();
fd.append("datei", file, file.name || "foto.jpg");
return fetch(cfg.upload, {
method: "POST",
headers: cfg.token ? { "X-Token": cfg.token } : {},
body: fd
})
.then(function(r){ return r.json().catch(function(){ throw new Error("Upload fehlgeschlagen (" + r.status + ")"); }); })
.then(function(d){
if(!d.ok) throw new Error(d.error || "Upload fehlgeschlagen");
return d.url;
});
}
/* ================= Warteschlange ================= */
function enqueue(action, payload){
queue.push({ action: action, payload: payload, ts: Date.now() });
save(LS + ".queue", queue);
renderNet();
window.dispatchEvent(new Event("mc:queue"));
}
function flushQueue(silent){
if(!queue.length){ if(!silent) toast("Warteschlange ist leer."); return Promise.resolve(); }
if(!navigator.onLine){ if(!silent) toast("Offline – später erneut.", true); return Promise.resolve(); }
var pending = queue.slice();
var ok = 0;
return pending.reduce(function(p, job){
return p.then(function(){
return api(job.action, job.payload).then(function(){
ok++;
queue = queue.filter(function(q){ return q !== job; });
save(LS + ".queue", queue);
});
});
}, Promise.resolve())
.then(function(){
if(ok) toast(ok + " Eintrag/Einträge nachgesendet.");
renderNet();
window.dispatchEvent(new Event("mc:queue"));
return ladeListe(true);
})
.catch(function(err){
renderNet();
if(!silent) toast(err.message, true);
});
}
function renderNet(){
var el = $("#netstate");
if(!navigator.onLine){ el.className = "netstate off"; el.textContent = "offline"; return; }
if(queue.length){ el.className = "netstate queue"; el.textContent = queue.length + " wartet"; return; }
el.className = "netstate"; el.textContent = "verbunden";
}
$("#q-info") && (function(){
var upd = function(){ $("#q-info").textContent = queue.length ? queue.length + " Vorgang/Vorgänge warten auf Übertragung." : "Keine offenen Vorgänge."; };
upd();
window.addEventListener("mc:queue", upd);
})();
/* ================= Navigation ================= */
$$("nav button").forEach(function(b){
b.addEventListener("click", function(){
$$("nav button").forEach(function(x){ x.classList.toggle("on", x === b); });
$$(".view").forEach(function(v){ v.classList.toggle("on", v.id === "v-" + b.dataset.view); });
window.scrollTo(0,0);
if(b.dataset.view === "liste") ladeListe(true);
});
});
/* ================= Status-Schalter ================= */
function buildTriple(host, werte, onChange){
host.__werte = werte;
host.__onChange = onChange;
var felder = [["audio","Audio"],["bild","Bild"],["sprache","Sprache"]];
host.innerHTML = felder.map(function(f){
return '<div class="trow"><span>' + f[1] + '</span><div class="seg" data-k="' + f[0] + '">' +
["JA","NEIN","n.A."].map(function(v){
var on = norm(werte[f[0]]) === v;
return '<button type="button" data-v="' + v + '" aria-pressed="' + on + '">' + v + "</button>";
}).join("") + "</div></div>";
}).join("");
// Listener nur einmal pro Host binden – buildTriple wird beim Zuruecksetzen erneut aufgerufen.
if(host.dataset.bound === "1") return;
host.dataset.bound = "1";
host.addEventListener("click", function(ev){
var btn = ev.target.closest("button[data-v]");
if(!btn) return;
var seg = btn.parentNode, k = seg.dataset.k;
var war = btn.getAttribute("aria-pressed") === "true";
$$("button", seg).forEach(function(x){ x.setAttribute("aria-pressed", "false"); });
if(!war){ btn.setAttribute("aria-pressed","true"); host.__werte[k] = btn.dataset.v; }
else { host.__werte[k] = ""; } // erneutes Tippen hebt die Auswahl auf
host.__onChange(host.__werte);
});
}
/* ================= Erfassen ================= */
var neu = { audio:"", bild:"", sprache:"" };
var neuFile = null;
buildTriple($("#f-triple"), neu, function(w){
var st = berechneStatus(w.audio, w.bild, w.sprache);
var el = $("#f-status");
el.className = "chip " + (CLS[st] || "s-offen");
el.textContent = st;
});
$("#f-photo").addEventListener("click", function(ev){
if(ev.target.id === "f-photo-rm") return;
$("#f-file").click();
});
var vorschauUrl = null;
$("#f-file").addEventListener("change", function(){
var f = this.files && this.files[0];
if(!f) return;
neuFile = f;
if(vorschauUrl) URL.revokeObjectURL(vorschauUrl);
vorschauUrl = URL.createObjectURL(f);
var img = $("#f-photo-img");
img.src = vorschauUrl;
img.hidden = false;
$("#f-photo-ph").hidden = true;
$("#f-photo-rm").hidden = false;
});
function fotoEntfernen(){
neuFile = null;
$("#f-file").value = "";
if(vorschauUrl){ URL.revokeObjectURL(vorschauUrl); vorschauUrl = null; }
var img = $("#f-photo-img");
img.removeAttribute("src");
img.hidden = true;
$("#f-photo-ph").hidden = false;
$("#f-photo-rm").hidden = true;
}
$("#f-photo-rm").addEventListener("click", function(ev){
ev.stopPropagation();
fotoEntfernen();
});
$("#f-save").addEventListener("click", function(){
var btn = this;
var bez = $("#f-bez").value.trim();
if(!bez){ toast("Bezeichnung fehlt.", true); $("#f-bez").focus(); return; }
btn.disabled = true;
btn.textContent = "Wird gespeichert …";
var schritt = neuFile
? uploadFoto(neuFile).catch(function(err){
toast("Foto: " + err.message, true);
return "";
})
: Promise.resolve("");
schritt.then(function(url){
var payload = {
bezeichnung: bez,
standort: $("#f-ort").value.trim(),
bildUrl: url,
audio: neu.audio, bild: neu.bild, sprache: neu.sprache,
notiz: $("#f-notiz").value.trim(),
pruefer: cfg.user || ""
};
return api("create", payload).then(function(res){
toast("Angelegt: " + res.id);
resetForm();
ladeListe(true);
}).catch(function(err){
if(!navigator.onLine || /Failed to fetch|NetworkError/i.test(err.message)){
enqueue("create", payload);
toast("Offline gespeichert – wird nachgesendet.");
resetForm();
} else {
toast(err.message, true);
}
});
}).then(function(){
btn.disabled = false;
btn.textContent = "Eintrag anlegen";
});
});
function resetForm(){
$("#f-bez").value = ""; $("#f-ort").value = ""; $("#f-notiz").value = "";
neu.audio = neu.bild = neu.sprache = "";
buildTriple($("#f-triple"), neu, function(w){
var st = berechneStatus(w.audio, w.bild, w.sprache);
var el = $("#f-status");
el.className = "chip " + (CLS[st] || "s-offen");
el.textContent = st;
});
$("#f-status").className = "chip s-offen";
$("#f-status").textContent = S.OFFEN;
fotoEntfernen();
window.scrollTo(0, 0);
}
/* ================= Übersicht ================= */
var filter = "alle";
var suche = "";
var FILTERS = [["alle","Alle"],[S.OFFEN,"Nicht geprüft"],[S.FEHLER,"Fehlerhaft"],[S.TEIL,"Teilweise"],[S.OK,"Funktionsfähig"],[S.AUS,"Deaktiviert"]];
$("#filters").innerHTML = FILTERS.map(function(f){
return '<button data-f="' + esc(f[0]) + '"' + (f[0]==="alle"?' class="on"':"") + ">" + esc(f[1]) + "</button>";
}).join("");
$("#filters").addEventListener("click", function(ev){
var b = ev.target.closest("button[data-f]");
if(!b) return;
filter = b.dataset.f;
$$("#filters button").forEach(function(x){ x.classList.toggle("on", x === b); });
renderListe();
});
$("#q").addEventListener("input", function(){ suche = this.value.trim().toLowerCase(); renderListe(); });
$("#reload").addEventListener("click", function(){ ladeListe(false); });
function setzeItems(arr){
items = (arr || []).map(function(i){
i.status = statusKanon(i.status);
return i;
});
save(LS + ".cache", items);
}
function ladeListe(silent){
if(!cfg.api){ if(!silent) toast("Bitte zuerst die Verbindung einrichten.", true); renderListe(); return Promise.resolve(); }
return api("list").then(function(res){
setzeItems(res.items || []);
renderListe();
if(!silent) toast(items.length + " Einträge geladen.");
}).catch(function(err){
renderListe();
if(!silent) toast(err.message, true);
});
}
function renderListe(){
var zahl = {};
zahl[S.OK]=0; zahl[S.TEIL]=0; zahl[S.FEHLER]=0; zahl[S.AUS]=0; zahl[S.OFFEN]=0;
items.forEach(function(i){ if(zahl[i.status] !== undefined) zahl[i.status]++; else zahl[S.OFFEN]++; });
$("#stats").innerHTML = [S.OK,S.TEIL,S.FEHLER,S.AUS,S.OFFEN].map(function(s){
return '<div class="stat"><b>' + zahl[s] + "</b><span>" + esc(shortStatus(s)) + "</span></div>";
}).join("");
var gefiltert = items.filter(function(i){
if(filter !== "alle" && i.status !== filter) return false;
if(!suche) return true;
return (i.id + " " + i.bezeichnung + " " + i.standort + " " + i.notiz).toLowerCase().indexOf(suche) >= 0;
});
if(!gefiltert.length){
$("#list").innerHTML = '<div class="empty">' +
(items.length ? "Keine Treffer." : "Noch keine Einträge – oben rechts neu laden oder im Tab „Erfassen“ anlegen.") +
"</div>";
return;
}
$("#list").innerHTML = gefiltert.map(function(i){
var thumb = i.bildUrl
? '<img class="thumb" src="' + esc(i.bildUrl) + '" alt="" loading="lazy">'
: '<div class="thumb empty">kein<br>Bild</div>';
return '<button class="item" data-id="' + esc(i.id) + '">' + thumb +
'<div class="meta">' +
'<div class="idline mono">' + esc(i.id) + "</div>" +
'<div class="name">' + esc(i.bezeichnung) + "</div>" +
'<div class="sub">' + esc(i.standort || "–") + "</div>" +
"</div>" + chip(i.status) + "</button>";
}).join("");
}
$("#list").addEventListener("click", function(ev){
var b = ev.target.closest(".item");
if(b) oeffneDetail(b.dataset.id);
});
/* ================= Detail / Statusmeldung ================= */
var aktiv = null;
var aktivWerte = null;
function oeffneDetail(id){
var it = items.filter(function(x){ return x.id === id; })[0];
if(!it) return;
aktiv = it;
aktivWerte = { audio: it.audio || "", bild: it.bild || "", sprache: it.sprache || "" };
$("#detail-pn").innerHTML =
'<div class="grip"></div>' +
'<div class="card">' +
'<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:12px;margin-bottom:10px">' +
"<div><div class='idline mono' style='font-size:12px;color:var(--ink-3)'>" + esc(it.id) + "</div>" +
"<div style='font-size:18px;font-weight:650'>" + esc(it.bezeichnung) + "</div>" +
"<div style='font-size:13px;color:var(--ink-3)'>" + esc(it.standort || "–") + "</div></div>" +
chip(it.status) +
"</div>" +
(it.bildUrl ? '<img src="' + esc(it.bildUrl) + '" alt="" style="width:100%;border-radius:8px;margin-bottom:12px">' : "") +
'<div class="triple" id="d-triple"></div>' +
'<div class="preview"><span class="chip ' + (CLS[it.status]||"s-offen") + '" id="d-preview">' + esc(it.status) + "</span><small>neuer Status</small></div>" +
'<div class="field" style="margin-top:14px"><label for="d-notiz">Notiz</label><textarea id="d-notiz">' + esc(it.notiz) + "</textarea></div>" +
'<div class="btnrow"><button class="btn primary" id="d-save">Status melden</button>' +
'<button class="btn ghost" data-close>Schließen</button></div>' +
'<button class="btn ghost danger" id="d-del" style="margin-top:8px">Eintrag löschen</button>' +
"</div>" +
'<div class="card"><h2>Verlauf</h2><dl class="kv">' +
"<div><dt>Erfasst</dt><dd>" + esc(datum(it.erfasstAm)) + "</dd></div>" +
"<div><dt>Zuletzt geprüft</dt><dd>" + esc(datum(it.geprueftAm)) + "</dd></div>" +
"<div style='border:0'><dt>Prüfer</dt><dd>" + esc(it.pruefer || "–") + "</dd></div>" +
"</dl></div>";
buildTriple($("#d-triple"), aktivWerte, function(w){
var st = berechneStatus(w.audio, w.bild, w.sprache);
var el = $("#d-preview");
el.className = "chip " + (CLS[st] || "s-offen");
el.textContent = st;
});
$("#d-save").addEventListener("click", speichereStatus);
$("#d-del").addEventListener("click", loescheEintrag);
$("#detail").classList.add("on");
document.body.style.overflow = "hidden";
}
function schliesseDetail(){
$("#detail").classList.remove("on");
document.body.style.overflow = "";
aktiv = null;
}
document.addEventListener("click", function(ev){
if(ev.target.matches("[data-close]")) schliesseDetail();
});
document.addEventListener("keydown", function(ev){
if(ev.key === "Escape" && $("#detail").classList.contains("on")) schliesseDetail();
});
function speichereStatus(){
if(!aktiv) return;
var btn = $("#d-save");
var payload = {
id: aktiv.id,
audio: aktivWerte.audio, bild: aktivWerte.bild, sprache: aktivWerte.sprache,
notiz: $("#d-notiz").value.trim(),
pruefer: cfg.user || ""
};
var neuStatus = berechneStatus(payload.audio, payload.bild, payload.sprache);
btn.disabled = true;
btn.textContent = "Wird gemeldet …";
api("status", payload).then(function(res){
lokalAktualisieren(payload, res.status || neuStatus, res.geprueftAm);
toast(aktiv.id + ": " + (res.status || neuStatus));
schliesseDetail();
}).catch(function(err){
if(!navigator.onLine || /Failed to fetch|NetworkError/i.test(err.message)){
enqueue("status", payload);
lokalAktualisieren(payload, neuStatus, new Date().toISOString());
toast("Offline gespeichert – wird nachgesendet.");
schliesseDetail();
} else {
toast(err.message, true);
btn.disabled = false;
btn.textContent = "Status melden";
}
});
}
function loescheEintrag(){
if(!aktiv) return;
var it = aktiv;
if(!confirm("„" + it.id + " – " + it.bezeichnung + "“ wirklich löschen?\n\nDie Zeile wird aus der Tabelle entfernt. Das lässt sich nicht rückgängig machen.")) return;
var btn = $("#d-del");
btn.disabled = true;
btn.textContent = "Wird gelöscht …";
api("delete", { id: it.id, pruefer: cfg.user || "" }).then(function(){
items = items.filter(function(x){ return x.id !== it.id; });
save(LS + ".cache", items);
renderListe();
toast(it.id + " gelöscht.");
schliesseDetail();
}).catch(function(err){
toast(err.message, true);
btn.disabled = false;
btn.textContent = "Eintrag löschen";
});
}
function lokalAktualisieren(p, status, zeit){
items = items.map(function(i){
if(i.id !== p.id) return i;
return Object.assign({}, i, {
audio: norm(p.audio), bild: norm(p.bild), sprache: norm(p.sprache),
status: statusKanon(status), notiz: p.notiz,
geprueftAm: zeit || i.geprueftAm, pruefer: p.pruefer || i.pruefer
});
});
save(LS + ".cache", items);
renderListe();
}
/* ================= Setup ================= */
function fuelleSetup(){
$("#s-api").value = cfg.api || "";
$("#s-token").value = cfg.token || "";
$("#s-upload").value = cfg.upload || "";
$("#s-user").value = cfg.user || "";
}
fuelleSetup();
$("#s-save").addEventListener("click", function(){
cfg.api = $("#s-api").value.trim();
cfg.token = $("#s-token").value.trim();
cfg.upload = $("#s-upload").value.trim();
cfg.user = $("#s-user").value.trim();
save(LS + ".cfg", cfg);
toast("Einstellungen gespeichert.");
ladeListe(true);
});
$("#s-test").addEventListener("click", function(){
var btn = this;
cfg.api = $("#s-api").value.trim();
cfg.token = $("#s-token").value.trim();
btn.disabled = true;
api("ping").then(function(r){ toast("Verbindung ok (" + datum(r.time) + ")"); })
.catch(function(e){ toast(e.message, true); })
.then(function(){ btn.disabled = false; });
});
$("#q-flush").addEventListener("click", function(){ flushQueue(false); });
$("#q-clear").addEventListener("click", function(){
if(!queue.length){ toast("Warteschlange ist leer."); return; }
if(confirm("Wirklich " + queue.length + " nicht gesendete Vorgänge verwerfen?")){
queue = []; save(LS + ".queue", queue); renderNet();
window.dispatchEvent(new Event("mc:queue"));
toast("Warteschlange geleert.");
}
});
/* ================= Start ================= */
window.addEventListener("online", function(){ renderNet(); flushQueue(true); });
window.addEventListener("offline", renderNet);
renderNet();
setzeItems(items); // Zwischenspeicher aus aelteren Versionen vereinheitlichen
renderListe();
if(cfg.api){ flushQueue(true).then(function(){ ladeListe(true); }); }
else { $$("nav button")[2].click(); }
</script>
</body>
</html>

Powered by TurnKey Linux.