Files
dogfather-universe/webdesign/verwaltung.html
T
DogFatherGitandClaude Opus 5 74091590b8 Verwaltung: nur noch EINMAL den Code eingeben
Rueckmeldung 22.08.2026: "ich hab auf der verwaltungs seite 2 mal dass ich
den zugangscode eingeben muss und vanvan auch, ich will es nur einmal
eingeben muessen." Berechtigt.

Die Zugangswand hat serverseitig laengst geprueft, WER da ist -- Dogfather
oder VanVan, laut Masterplan S.4 beide gleichberechtigte
Volladministratoren. Ein zweites Passwort danach bringt keinen
zusaetzlichen Schutz, es kostet nur jedes Mal Zeit.

Warum es nicht einfach "Cookie mitschicken" ist: Die Verwaltungsdaten
liegen hinter postfach.dogfather-universe.com, einem ANDEREN Rechnernamen.
Das Sitzungs-Cookie gilt dort nicht -- so sind Cookies gebaut, und das ist
gut so.

Loesung: Die Zugangswand stellt unter /webdesign/api-ausweis einen
kurzlebigen, signierten Ausweis aus, den die interne API anerkennt.
  - 15 Minuten gueltig, die Seite holt bei Bedarf still einen neuen
  - traegt bereich="wd-admin", damit ein Sitzungs-Token der Zugangswand
    hier NICHT durchgeht und umgekehrt
  - nur mit gueltiger Zugangssitzung zu bekommen
  - gilt AUSSCHLIESSLICH fuer die /webdesign-Endpunkte. Postfach,
    Bewerbungen, Supporter und Teamverwaltung bleiben unberuehrt

Fehlt WEBDESIGN_API_SECRET auf einer der beiden Seiten, gilt kein Ausweis
und die Verwaltung fragt wie bisher nach dem Team-Code. Ein fehlender
Konfigurationswert darf niemals eine Tuer oeffnen -- nur eine schliessen.
Genau das prueft der letzte Testfall.

Bei einem 401 wird EINMAL still ein neuer Ausweis geholt und die Anfrage
wiederholt, statt jemanden mitten im Arbeiten rauszuwerfen.

10/10 Tests, darunter: gefaelschte Signatur, fremdes Geheimnis,
abgelaufen, falscher Bereich, erfundene Rolle.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-22 21:36:11 +02:00

882 lines
40 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Verwaltung — Dogfather Webdesign</title>
<meta name="robots" content="noindex, nofollow, noarchive" />
<link rel="icon" type="image/png" href="/assets/img/favicon.png" />
<meta name="theme-color" content="#05070b" />
<link rel="manifest" href="/webdesign/app.webmanifest" />
<link rel="apple-touch-icon" href="/assets/img/wd-app-180.png" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-title" content="DF Verwaltung" />
<link rel="stylesheet" href="/assets/css/main.css?v=20260822wd2" />
<link rel="stylesheet" href="/assets/css/webdesign.css?v=20260822wd2" />
<style>
/* =====================================================================
Verwaltungsbereich — bewusst NUR AUF DEUTSCH.
Der öffentliche Teil läuft in fünf Sprachen, weil dort Kunden landen.
Hier landen ausschließlich Dogfather und VanVan. Fünf Sprachen wären
fünffache Pflege und fünffache Fehlerfläche ohne einen einzigen
Nutzer, der sie braucht — genau wie im bestehenden internen Bereich
des Universe, der ebenfalls einsprachig ist.
===================================================================== */
/* ---------- Anmeldung ---------- */
.vw-login { max-width: 420px; margin: clamp(2rem,8vh,5rem) auto; }
/* ---------- Kopfleiste ---------- */
.vw-leiste {
display: flex; flex-wrap: wrap; align-items: center; gap: .8rem;
margin-bottom: 1.2rem;
}
.vw-leiste h1 { margin: 0; font-size: clamp(1.4rem, 3.4vw, 2rem); }
.vw-wer { margin-left: auto; display: flex; align-items: center; gap: .6rem; font-size: .9rem; color: var(--wd-text-soft); }
/* ---------- Kennzahlen ---------- */
.vw-zahlen { display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); gap: .7rem; margin-bottom: 1.4rem; }
.vw-zahl {
padding: .9rem 1rem; border-radius: 14px;
background: rgba(16,26,41,.6); border: 1px solid var(--wd-rand);
text-align: left;
}
.vw-zahl-wert { font-family: var(--font-head); font-weight: 800; font-size: 1.7rem; line-height: 1; color: var(--wd-blau); }
.vw-zahl-name { font-size: .78rem; letter-spacing: .06em; color: var(--wd-text-soft); margin-top: .3rem; }
.vw-zahl--achtung .vw-zahl-wert { color: var(--wd-gold); }
/* ---------- Filter ---------- */
.vw-filter { display: flex; flex-wrap: wrap; gap: .5rem; margin-bottom: 1rem; align-items: center; }
.vw-filter-knopf {
min-height: 44px; padding: .5rem .95rem; border-radius: 11px;
border: 1px solid var(--wd-rand); background: transparent;
color: var(--wd-text-mid); font-weight: 600; font-size: .9rem; cursor: pointer;
display: inline-flex; align-items: center; gap: .4rem;
}
.vw-filter-knopf:hover { border-color: var(--wd-rand-stark); color: var(--wd-text); }
.vw-filter-knopf.vw-an { background: rgba(127,208,232,.13); border-color: var(--wd-blau); color: var(--wd-blau); }
.vw-filter-zahl {
font-size: .74rem; padding: .1rem .4rem; border-radius: 6px;
background: rgba(127,208,232,.14); color: var(--wd-blau-hell);
}
.vw-suche { margin-left: auto; min-width: 200px; flex: 0 1 260px; }
/* ---------- Liste ---------- */
.vw-karte {
display: grid; grid-template-columns: auto 1fr auto; gap: .5rem 1rem;
align-items: center;
padding: 1rem 1.1rem; margin-bottom: .6rem;
border-radius: 14px; border: 1px solid var(--wd-rand);
background: rgba(16,26,41,.5);
cursor: pointer; width: 100%; text-align: left;
transition: border-color var(--wd-zeit-schnell) ease, background var(--wd-zeit-schnell) ease;
}
.vw-karte:hover { border-color: var(--wd-rand-stark); background: rgba(127,208,232,.05); }
.vw-karte-nr { font-family: var(--font-head); font-weight: 800; font-size: .82rem; color: var(--wd-blau); white-space: nowrap; }
.vw-karte-mitte { min-width: 0; }
.vw-karte-name { font-weight: 700; color: var(--wd-text); }
.vw-karte-info { font-size: .87rem; color: var(--wd-text-soft); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.vw-karte-rechts { text-align: right; white-space: nowrap; }
.vw-karte-zeit { font-size: .78rem; color: var(--wd-text-soft); margin-top: .25rem; }
@media (max-width: 600px) {
.vw-karte { grid-template-columns: 1fr auto; }
.vw-karte-nr { grid-column: 1 / -1; }
}
/* Statusfarben — Gold nur da, wo wirklich etwas zu tun ist. */
.vw-status { display: inline-block; padding: .22rem .6rem; border-radius: 999px; font-size: .74rem; font-weight: 700; }
.vw-status--neu { background: rgba(232,200,119,.16); color: var(--wd-gold); }
.vw-status--pruefung { background: rgba(127,208,232,.14); color: var(--wd-blau); }
.vw-status--rueckfrage { background: rgba(183,157,255,.16); color: var(--wd-lila); }
.vw-status--angebot { background: rgba(183,157,255,.16); color: var(--wd-lila); }
.vw-status--angenommen { background: rgba(111,214,163,.16); color: var(--wd-ok); }
.vw-status--abgeschlossen { background: rgba(111,214,163,.1); color: var(--wd-ok); }
.vw-status--abgelehnt { background: rgba(255,157,157,.12); color: var(--wd-fehler); }
/* ---------- Detailansicht ---------- */
.vw-detail { position: fixed; inset: 0; z-index: 100; display: none; }
.vw-detail.vw-auf { display: block; }
.vw-detail-schatten { position: absolute; inset: 0; background: rgba(2,4,8,.78); backdrop-filter: blur(3px); }
.vw-detail-blatt {
position: absolute; top: 0; right: 0; bottom: 0;
width: min(680px, 100%);
background: #080d16; border-left: 1px solid var(--wd-rand-stark);
overflow-y: auto; padding: 1.4rem clamp(1rem,3vw,1.8rem) 3rem;
animation: vw-rein .28s cubic-bezier(.22,1,.36,1) both;
}
@keyframes vw-rein { from { transform: translateX(24px); opacity: 0; } to { transform: none; opacity: 1; } }
@media (prefers-reduced-motion: reduce) { .vw-detail-blatt { animation: none; } }
.vw-detail-kopf { display: flex; align-items: flex-start; gap: 1rem; margin-bottom: 1.2rem; }
.vw-zu {
margin-left: auto; min-width: 44px; min-height: 44px;
border-radius: 10px; border: 1px solid var(--wd-rand);
background: transparent; color: var(--wd-text-mid); cursor: pointer; font-size: 1.1rem;
}
.vw-zu:hover { color: var(--wd-text); border-color: var(--wd-rand-stark); }
.vw-abschnitt { margin-bottom: 1.4rem; }
.vw-abschnitt > h3 {
font-size: .74rem; letter-spacing: .13em; text-transform: uppercase;
color: var(--wd-blau); margin-bottom: .6rem;
padding-bottom: .4rem; border-bottom: 1px solid var(--wd-rand);
}
.vw-paar { display: grid; grid-template-columns: minmax(0,.4fr) minmax(0,1fr); gap: .2rem .9rem; margin-bottom: .45rem; }
@media (max-width: 520px) { .vw-paar { grid-template-columns: 1fr; margin-bottom: .8rem; } }
.vw-paar dt { color: var(--wd-text-soft); font-size: .88rem; }
.vw-paar dd { margin: 0; color: var(--wd-text); font-size: .95rem; overflow-wrap: break-word; white-space: pre-wrap; }
.vw-paar dd.vw-leer { color: var(--wd-text-soft); font-style: italic; }
.vw-status-wahl { display: flex; flex-wrap: wrap; gap: .4rem; }
.vw-notiz {
padding: .8rem .95rem; border-radius: 12px; margin-bottom: .5rem;
background: rgba(127,208,232,.05); border: 1px solid var(--wd-rand);
}
.vw-notiz-kopf { font-size: .76rem; color: var(--wd-text-soft); margin-bottom: .3rem; }
.vw-notiz p { margin: 0; font-size: .93rem; white-space: pre-wrap; }
.vw-tabs { display: flex; gap: .4rem; }
.vw-link-anzeige {
padding: 1rem; border-radius: 14px;
background: rgba(232,200,119,.08); border: 1px solid rgba(232,200,119,.35);
}
.vw-link-anzeige code {
display: block; word-break: break-all; margin: .6rem 0;
font-size: .84rem; color: var(--wd-gold); line-height: 1.5;
}
.vw-leer-hinweis { text-align: center; padding: 3rem 1rem; color: var(--wd-text-soft); }
.vw-laedt { text-align: center; padding: 2.5rem 1rem; color: var(--wd-text-soft); }
</style>
</head>
<body class="wd">
<div id="wd-kopf" data-wd-kopf="minimal"></div>
<main id="wd-inhalt">
<section class="wd-abschnitt" style="padding-top:1.6rem">
<div class="wd-container">
<!-- ============ ANMELDUNG ============ -->
<div id="vw-anmeldung" class="wd-karte vw-login" hidden>
<h1 style="font-size:1.4rem">Verwaltung</h1>
<p class="wd-klein wd-leise">
Normalerweise landest du hier gar nicht: Wer durch die Zugangswand
gekommen ist, wird direkt hereingelassen. Diese Maske erscheint nur,
wenn das nicht möglich war — dann brauchst du deinen persönlichen
Zugangscode aus dem internen Bereich (derselbe wie fürs Postfach).
</p>
<form id="vw-login-form" novalidate>
<div class="wd-feld" id="vw-feld-code">
<label for="vw-code">Zugangscode</label>
<input type="password" id="vw-code" autocomplete="current-password"
autocapitalize="off" spellcheck="false" />
<p class="wd-feld-fehler"></p>
</div>
<button type="submit" class="wd-btn wd-btn--haupt wd-btn--breit">Anmelden</button>
</form>
</div>
<!-- ============ ARBEITSBEREICH ============ -->
<div id="vw-bereich" hidden>
<div class="vw-leiste">
<h1 id="vw-titel">Projektanfragen</h1>
<div class="vw-tabs">
<button type="button" class="vw-filter-knopf vw-an" data-tab="anfragen">Anfragen</button>
<button type="button" class="vw-filter-knopf" data-tab="kunden">Kunden</button>
</div>
<div class="vw-wer">
<span id="vw-name">—</span>
<button type="button" class="wd-btn wd-btn--still wd-btn--klein" id="vw-abmelden">Abmelden</button>
</div>
</div>
<div class="vw-zahlen" id="vw-zahlen"></div>
<div class="vw-filter" id="vw-filter"></div>
<div class="wd-feld" style="margin-bottom:1rem">
<input type="search" id="vw-suche" class="vw-suche" placeholder="Nach Name, E-Mail oder Nummer suchen…"
style="width:100%" aria-label="Anfragen durchsuchen" />
</div>
<div class="wd-meldung" id="vw-meldung" hidden></div>
<div id="vw-liste"><div class="vw-laedt">Wird geladen…</div></div>
<!-- ============ KUNDEN ============ -->
<div id="vw-kunden-bereich" hidden>
<div class="wd-karte" style="margin-bottom:1.2rem">
<h2 style="font-size:1.15rem">Neuen Kundenzugang anlegen</h2>
<p class="wd-klein wd-leise">
Der Kunde bekommt einen Einladungslink und setzt sein Passwort selbst.
Den Link siehst du <strong>nur einmal</strong> — danach steht in der
Datenbank nur noch sein verschlüsselter Abdruck. Verloren? Einfach
einen neuen erzeugen.
</p>
<form id="vw-kunde-form" novalidate>
<div class="wd-grid wd-grid--2">
<div class="wd-feld" id="vw-feld-kname">
<label for="vw-kname">Name<span class="wd-pflicht">*</span></label>
<input type="text" id="vw-kname" autocomplete="off" />
<p class="wd-feld-fehler"></p>
</div>
<div class="wd-feld" id="vw-feld-kmail">
<label for="vw-kmail">E-Mail<span class="wd-pflicht">*</span></label>
<input type="email" id="vw-kmail" autocomplete="off" inputmode="email" />
<p class="wd-feld-fehler"></p>
</div>
<div class="wd-feld">
<label for="vw-kfirma">Firma <span class="wd-leise">optional</span></label>
<input type="text" id="vw-kfirma" autocomplete="off" />
</div>
<div class="wd-feld">
<label for="vw-ksprache">Sprache</label>
<select id="vw-ksprache">
<option value="de">Deutsch</option>
<option value="de-CH">Schwiizerdütsch</option>
<option value="en">English</option>
<option value="fr">Français</option>
<option value="pt">Português</option>
</select>
</div>
</div>
<label class="wd-wahl-kachel" style="display:block;margin-bottom:.9rem">
<input type="checkbox" id="vw-kfrei" checked />
<span class="wd-wahl-inhalt">
<span class="wd-wahl-titel">Sofort freischalten</span>
<span class="wd-wahl-text">Ohne Häkchen wird der Zugang angelegt, kommt aber noch nicht hinein — für Kunden, deren Projekt erst später startet.</span>
</span>
</label>
<div class="wd-btn-reihe">
<button type="submit" class="wd-btn wd-btn--haupt">Zugang anlegen</button>
<button type="button" class="wd-btn wd-btn--zweit" id="vw-testzugang">Testzugang für mich</button>
</div>
</form>
<div id="vw-link-box" hidden style="margin-top:1.2rem"></div>
</div>
<h2 style="font-size:1.15rem">Bestehende Zugänge</h2>
<div id="vw-kunden-liste"><div class="vw-laedt">Wird geladen…</div></div>
</div>
</div>
</div>
</section>
</main>
<!-- ============ DETAILANSICHT ============ -->
<div class="vw-detail" id="vw-detail" role="dialog" aria-modal="true" aria-label="Anfrage im Detail">
<div class="vw-detail-schatten" data-schliessen></div>
<div class="vw-detail-blatt" id="vw-detail-blatt"></div>
</div>
<script src="/assets/js/wd-core.js?v=20260822wd2"></script>
<script>
/* =====================================================================
Verwaltung: Anfragen ansehen, Status ändern, Notizen, Archivieren.
Anmeldung: bewusst über die BESTEHENDE Team-Anmeldung des internen
Bereichs (/auth/login) statt einer zweiten eigenen. Zwei getrennte
Anmeldungen für dieselben zwei Personen wären doppelte Pflege und ein
zweiter Ort, an dem ein Zugang vergessen werden kann.
Das Sitzungstoken liegt in sessionStorage, NICHT in localStorage:
Es soll beim Schließen verschwinden — dieselbe Regel wie an der
Zugangswand ("jedes Mal wieder fragen").
===================================================================== */
(function () {
"use strict";
var TOKEN_SCHLUESSEL = "wd-vw-token";
var token = null;
var alleAnfragen = [];
var filterStatus = "";
var filterArchiv = false;
var suchText = "";
var STATUS_TEXT = {
neu: "Neu", pruefung: "In Prüfung", rueckfrage: "Rückfrage",
angebot: "Angebot raus", angenommen: "Angenommen",
abgeschlossen: "Abgeschlossen", abgelehnt: "Abgelehnt",
};
var PAKET_TEXT = {
onepager: "Onepager", website: "Professionelle Website",
shop: "Shop & umfangreiche Website", verwaltung: "Buchhaltungs- & Steuerverwaltung",
betreuung: "Laufende Betreuung", unklar: "Noch unklar",
};
var VORHANDEN_TEXT = { ja: "vorhanden", teilweise: "teilweise", nein: "fehlt noch" };
var BUDGET_TEXT = {
"bis-500": "bis 500 €", "500-1000": "500 – 1.000 €",
"1000-2500": "1.000 – 2.500 €", "ueber-2500": "über 2.500 €", unklar: "noch unklar",
};
var s = window.WD.sicher;
var meldung = document.getElementById("vw-meldung");
function tokenLesen() {
try { return sessionStorage.getItem(TOKEN_SCHLUESSEL); } catch (e) { return null; }
}
function tokenSchreiben(t) {
try { if (t) sessionStorage.setItem(TOKEN_SCHLUESSEL, t); else sessionStorage.removeItem(TOKEN_SCHLUESSEL); } catch (e) { /* egal */ }
}
function api(pfad, optionen) {
var opt = optionen || {};
opt.headers = Object.assign({ Authorization: "Bearer " + token }, opt.headers || {});
return window.WD.api(pfad, opt);
}
/* ---------------- Anmeldung ---------------- */
function zeigeAnmeldung() {
document.getElementById("vw-anmeldung").hidden = false;
document.getElementById("vw-bereich").hidden = true;
var feld = document.getElementById("vw-code");
if (feld) setTimeout(function () { feld.focus(); }, 100);
}
function zeigeBereich() {
document.getElementById("vw-anmeldung").hidden = true;
document.getElementById("vw-bereich").hidden = false;
}
document.getElementById("vw-login-form").addEventListener("submit", async function (e) {
e.preventDefault();
var feld = document.getElementById("vw-feld-code");
var eingabe = document.getElementById("vw-code");
var knopf = e.target.querySelector("button");
feld.classList.remove("wd-feld--fehler");
var code = eingabe.value.trim();
if (!code) {
feld.classList.add("wd-feld--fehler");
feld.querySelector(".wd-feld-fehler").textContent = "Bitte den Zugangscode eingeben.";
return;
}
window.WD.knopfSperren(knopf, true, "Wird geprüft…");
try {
var antwort = await window.WD.api("/auth/login", { method: "POST", body: { code: code } });
token = antwort.token;
tokenSchreiben(token);
document.getElementById("vw-name").textContent = antwort.name || antwort.actor || "angemeldet";
zeigeBereich();
ladeAnfragen();
} catch (fehler) {
feld.classList.add("wd-feld--fehler");
feld.querySelector(".wd-feld-fehler").textContent = fehler.message || "Anmeldung fehlgeschlagen.";
eingabe.select();
} finally {
window.WD.knopfSperren(knopf, false);
}
});
document.getElementById("vw-abmelden").addEventListener("click", async function () {
try { await api("/auth/logout", { method: "POST", body: {} }); } catch (e) { /* egal */ }
token = null; tokenSchreiben(null);
location.reload();
});
/* ---------------- Liste ---------------- */
async function ladeAnfragen() {
var liste = document.getElementById("vw-liste");
liste.innerHTML = '<div class="vw-laedt">Wird geladen…</div>';
try {
var antwort = await api("/webdesign/admin/anfragen?archiviert=" + (filterArchiv ? "1" : "0"));
alleAnfragen = antwort.anfragen || [];
zeichneZahlen(antwort.zaehler || {});
zeichneFilter(antwort.zaehler || {});
zeichneListe();
window.WD.meldungAus(meldung);
} catch (fehler) {
if (fehler.status === 401) {
/* Der Ausweis der Zugangswand laeuft nach 15 Minuten ab. Statt
jemanden mitten im Arbeiten rauszuwerfen, EINMAL still einen
neuen holen und es nochmal versuchen. Klappt auch das nicht,
ist die Zugangssitzung selbst weg -- dann zurueck zur
Anmeldung, nicht zu einer leeren Liste. */
if (!ausweisVersucht) {
ausweisVersucht = true;
var frisch = await ausweisHolen();
if (frisch) {
token = frisch; tokenSchreiben(token);
ausweisVersucht = false;
return ladeAnfragen();
}
}
token = null; tokenSchreiben(null); zeigeAnmeldung();
return;
}
liste.innerHTML = "";
window.WD.meldung(meldung, fehler.message || "Die Anfragen konnten nicht geladen werden.", "fehler");
}
}
function zeichneZahlen(zaehler) {
var offen = (zaehler.neu || 0) + (zaehler.rueckfrage || 0);
var gesamt = Object.keys(zaehler).reduce(function (n, k) { return n + zaehler[k]; }, 0);
var kacheln = [
{ wert: zaehler.neu || 0, name: "neu, ungelesen", achtung: (zaehler.neu || 0) > 0 },
{ wert: zaehler.pruefung || 0, name: "in Prüfung" },
{ wert: zaehler.angebot || 0, name: "Angebot raus" },
{ wert: zaehler.angenommen || 0, name: "angenommen" },
{ wert: gesamt, name: "insgesamt offen" },
];
document.getElementById("vw-zahlen").innerHTML = kacheln.map(function (k) {
return '<div class="vw-zahl' + (k.achtung ? " vw-zahl--achtung" : "") + '">' +
'<div class="vw-zahl-wert">' + k.wert + "</div>" +
'<div class="vw-zahl-name">' + s(k.name) + "</div></div>";
}).join("");
}
function zeichneFilter(zaehler) {
var knoepfe = [{ wert: "", name: "Alle" }].concat(
Object.keys(STATUS_TEXT).map(function (k) { return { wert: k, name: STATUS_TEXT[k] }; })
);
var html = knoepfe.map(function (k) {
var n = k.wert ? (zaehler[k.wert] || 0) : "";
return '<button type="button" class="vw-filter-knopf' + (filterStatus === k.wert ? " vw-an" : "") +
'" data-status="' + k.wert + '">' + s(k.name) +
(n !== "" ? '<span class="vw-filter-zahl">' + n + "</span>" : "") + "</button>";
}).join("");
html += '<button type="button" class="vw-filter-knopf' + (filterArchiv ? " vw-an" : "") +
'" data-archiv="1" style="margin-left:.6rem">Archiv</button>';
var box = document.getElementById("vw-filter");
box.innerHTML = html;
box.querySelectorAll("[data-status]").forEach(function (b) {
b.addEventListener("click", function () { filterStatus = b.dataset.status; zeichneFilter({}); ladeAnfragen(); });
});
box.querySelector("[data-archiv]").addEventListener("click", function () {
filterArchiv = !filterArchiv; filterStatus = ""; ladeAnfragen();
});
}
function zeichneListe() {
var liste = document.getElementById("vw-liste");
var gefiltert = alleAnfragen.filter(function (a) {
if (filterStatus && a.status !== filterStatus) return false;
if (!suchText) return true;
var t = suchText.toLowerCase();
return (a.name || "").toLowerCase().indexOf(t) >= 0 ||
(a.email || "").toLowerCase().indexOf(t) >= 0 ||
(a.nummer || "").toLowerCase().indexOf(t) >= 0;
});
if (!gefiltert.length) {
liste.innerHTML = '<div class="vw-leer-hinweis">' +
(alleAnfragen.length ? "Keine Anfrage passt zu diesem Filter." :
filterArchiv ? "Im Archiv liegt noch nichts." : "Noch keine Anfragen eingegangen.") + "</div>";
return;
}
liste.innerHTML = gefiltert.map(function (a) {
return '<button type="button" class="vw-karte" data-id="' + s(a.id) + '">' +
'<span class="vw-karte-nr">' + s(a.nummer) + "</span>" +
'<span class="vw-karte-mitte">' +
'<span class="vw-karte-name">' + s(a.name) + "</span><br />" +
'<span class="vw-karte-info">' + s(PAKET_TEXT[a.paket] || a.paket) +
(a.budget ? " · " + s(BUDGET_TEXT[a.budget] || a.budget) : "") + "</span>" +
"</span>" +
'<span class="vw-karte-rechts">' +
'<span class="vw-status vw-status--' + s(a.status) + '">' + s(STATUS_TEXT[a.status] || a.status) + "</span>" +
'<span class="vw-karte-zeit">' + window.WD.datum(a.erstellt_am) + "</span>" +
"</span></button>";
}).join("");
liste.querySelectorAll("[data-id]").forEach(function (k) {
k.addEventListener("click", function () { oeffneDetail(k.dataset.id); });
});
}
document.getElementById("vw-suche").addEventListener("input", function (e) {
suchText = e.target.value.trim();
zeichneListe();
});
/* ---------------- Detail ---------------- */
var detail = document.getElementById("vw-detail");
var blatt = document.getElementById("vw-detail-blatt");
var offeneId = null;
function schliesseDetail() {
detail.classList.remove("vw-auf");
offeneId = null;
document.body.style.overflow = "";
}
detail.querySelector("[data-schliessen]").addEventListener("click", schliesseDetail);
document.addEventListener("keydown", function (e) {
if (e.key === "Escape" && detail.classList.contains("vw-auf")) schliesseDetail();
});
function paar(was, wert, roh) {
var leer = !wert;
return '<div class="vw-paar"><dt>' + s(was) + "</dt>" +
'<dd' + (leer ? ' class="vw-leer"' : "") + ">" +
(leer ? "keine Angabe" : s(roh ? roh : wert)) + "</dd></div>";
}
async function oeffneDetail(id) {
offeneId = id;
detail.classList.add("vw-auf");
document.body.style.overflow = "hidden";
blatt.innerHTML = '<div class="vw-laedt">Wird geladen…</div>';
try {
var antwort = await api("/webdesign/admin/anfragen/" + encodeURIComponent(id));
var a = antwort.anfrage;
var notizen = antwort.notizen || [];
var funktionen = (a.funktionen || []).join(", ");
var details = Object.keys(a.details || {}).map(function (k) {
return paar(k.replace(/_/g, " "), a.details[k]);
}).join("");
blatt.innerHTML =
'<div class="vw-detail-kopf">' +
"<div><div class=\"vw-karte-nr\" style=\"font-size:.9rem\">" + s(a.nummer) + "</div>" +
'<h2 style="margin:.2rem 0 .3rem;font-size:1.4rem">' + s(a.name) + "</h2>" +
'<span class="vw-status vw-status--' + s(a.status) + '">' + s(STATUS_TEXT[a.status] || a.status) + "</span></div>" +
'<button class="vw-zu" type="button" aria-label="Schließen">✕</button>' +
"</div>" +
'<div class="vw-abschnitt"><h3>Kontakt</h3>' +
paar("E-Mail", a.email) +
paar("Telefon", a.telefon) +
paar("Bevorzugt", a.kontaktweg === "telefon" ? "Telefon" : "E-Mail") +
paar("Sprache", a.sprache) +
'<div class="wd-btn-reihe" style="margin-top:.7rem">' +
'<a class="wd-btn wd-btn--zweit wd-btn--klein" href="mailto:' + s(a.email) +
"?subject=" + encodeURIComponent("Deine Anfrage " + a.nummer + " bei Dogfather Webdesign") +
'">Antwort schreiben</a></div>' +
"</div>" +
'<div class="vw-abschnitt"><h3>Vorhaben</h3>' +
paar("Paket", PAKET_TEXT[a.paket] || a.paket) +
paar("Ziel", a.ziel) +
paar("Zielgruppe", a.zielgruppe) +
"</div>" +
'<div class="vw-abschnitt"><h3>Umfang</h3>' +
paar("Bereiche", a.seiten) +
paar("Funktionen", funktionen) +
details +
"</div>" +
'<div class="vw-abschnitt"><h3>Vorhandene Inhalte</h3>' +
paar("Texte", VORHANDEN_TEXT[a.hat_texte] || a.hat_texte) +
paar("Bilder", VORHANDEN_TEXT[a.hat_bilder] || a.hat_bilder) +
paar("Logo", VORHANDEN_TEXT[a.hat_logo] || a.hat_logo) +
paar("Domain", VORHANDEN_TEXT[a.hat_domain] || a.hat_domain) +
"</div>" +
'<div class="vw-abschnitt"><h3>Rahmen</h3>' +
paar("Budget", BUDGET_TEXT[a.budget] || a.budget) +
paar("Wunschtermin", a.termin) +
paar("Eingegangen", window.WD.datum(a.erstellt_am)) +
"</div>" +
(a.bemerkung ? '<div class="vw-abschnitt"><h3>Bemerkung</h3>' + paar("", a.bemerkung) + "</div>" : "") +
'<div class="vw-abschnitt"><h3>Status ändern</h3>' +
'<div class="vw-status-wahl">' +
Object.keys(STATUS_TEXT).map(function (k) {
return '<button type="button" class="vw-filter-knopf' + (a.status === k ? " vw-an" : "") +
'" data-neuer-status="' + k + '">' + s(STATUS_TEXT[k]) + "</button>";
}).join("") + "</div>" +
"</div>" +
'<div class="vw-abschnitt"><h3>Interne Notizen (' + notizen.length + ")</h3>" +
'<div id="vw-notizen">' +
(notizen.length ? notizen.map(function (n) {
return '<div class="vw-notiz"><div class="vw-notiz-kopf">' + s(n.autor) + " · " +
window.WD.datum(n.erstellt_am) + '</div><p>' + s(n.text) + "</p></div>";
}).join("") : '<p class="wd-klein wd-leise">Noch keine Notiz.</p>') +
"</div>" +
'<div class="wd-feld" style="margin-top:.7rem;margin-bottom:.5rem">' +
'<textarea id="vw-neue-notiz" rows="2" placeholder="Notiz hinzufügen (nur intern sichtbar)"></textarea>' +
"</div>" +
'<button type="button" class="wd-btn wd-btn--zweit wd-btn--klein" id="vw-notiz-speichern">Notiz speichern</button>' +
"</div>" +
'<div class="vw-abschnitt"><h3>Aufräumen</h3>' +
'<p class="wd-klein wd-leise">Archivieren löscht nichts — die Anfrage bleibt vollständig erhalten und lässt sich jederzeit zurückholen.</p>' +
'<button type="button" class="wd-btn wd-btn--still wd-btn--klein" id="vw-archivieren">' +
(a.archiviert ? "Aus dem Archiv zurückholen" : "Archivieren") + "</button>" +
"</div>";
blatt.querySelector(".vw-zu").addEventListener("click", schliesseDetail);
blatt.querySelectorAll("[data-neuer-status]").forEach(function (b) {
b.addEventListener("click", async function () {
window.WD.knopfSperren(b, true, "…");
try {
await api("/webdesign/admin/anfragen/" + encodeURIComponent(id) + "/status",
{ method: "POST", body: { status: b.dataset.neuerStatus } });
await ladeAnfragen();
oeffneDetail(id);
} catch (f) {
window.WD.knopfSperren(b, false);
alert(f.message || "Status konnte nicht geändert werden.");
}
});
});
blatt.querySelector("#vw-notiz-speichern").addEventListener("click", async function (e) {
var feld = blatt.querySelector("#vw-neue-notiz");
var text = feld.value.trim();
if (!text) { feld.focus(); return; }
window.WD.knopfSperren(e.target, true, "Wird gespeichert…");
try {
await api("/webdesign/admin/anfragen/" + encodeURIComponent(id) + "/notiz",
{ method: "POST", body: { text: text } });
oeffneDetail(id);
} catch (f) {
window.WD.knopfSperren(e.target, false);
alert(f.message || "Notiz konnte nicht gespeichert werden.");
}
});
blatt.querySelector("#vw-archivieren").addEventListener("click", async function (e) {
window.WD.knopfSperren(e.target, true, "…");
try {
await api("/webdesign/admin/anfragen/" + encodeURIComponent(id) + "/archivieren",
{ method: "POST", body: { zurueck: !!a.archiviert } });
schliesseDetail();
ladeAnfragen();
} catch (f) {
window.WD.knopfSperren(e.target, false);
alert(f.message || "Konnte nicht archiviert werden.");
}
});
} catch (fehler) {
blatt.innerHTML = '<div class="vw-leer-hinweis">' + s(fehler.message || "Konnte nicht geladen werden.") +
'<br /><br /><button class="wd-btn wd-btn--still wd-btn--klein" type="button" onclick="this.closest(\'.vw-detail\').classList.remove(\'vw-auf\')">Schließen</button></div>';
}
}
/* =================================================================
KUNDEN
================================================================= */
var aktiverTab = "anfragen";
document.querySelectorAll("[data-tab]").forEach(function (b) {
b.addEventListener("click", function () {
aktiverTab = b.dataset.tab;
document.querySelectorAll("[data-tab]").forEach(function (x) {
x.classList.toggle("vw-an", x.dataset.tab === aktiverTab);
});
var istAnfragen = aktiverTab === "anfragen";
document.getElementById("vw-titel").textContent = istAnfragen ? "Projektanfragen" : "Kunden";
document.getElementById("vw-zahlen").hidden = !istAnfragen;
document.getElementById("vw-filter").hidden = !istAnfragen;
document.getElementById("vw-suche").closest(".wd-feld").hidden = !istAnfragen;
document.getElementById("vw-liste").hidden = !istAnfragen;
document.getElementById("vw-kunden-bereich").hidden = istAnfragen;
if (!istAnfragen) ladeKunden();
});
});
async function ladeKunden() {
var ziel = document.getElementById("vw-kunden-liste");
ziel.innerHTML = '<div class="vw-laedt">Wird geladen…</div>';
try {
var d = await api("/webdesign/admin/kunden");
if (!d.kunden.length) {
ziel.innerHTML = '<div class="vw-leer-hinweis">Noch keine Kundenzugaenge angelegt.</div>';
return;
}
ziel.innerHTML = d.kunden.map(function (k) {
/* Der Zustand eines Zugangs hat vier Stufen, und alle muessen auf
einen Blick unterscheidbar sein. Besonders wichtig ist
"freigeschaltet, aber noch kein Passwort gesetzt": das heisst,
die Einladung liegt noch offen und der Kunde war noch nie drin. */
var marke = k.gesperrt
? '<span class="vw-status vw-status--abgelehnt">gesperrt</span>'
: !k.freigeschaltet
? '<span class="vw-status vw-status--neu">nicht freigeschaltet</span>'
: !k.hat_passwort
? '<span class="vw-status vw-status--rueckfrage">Einladung offen</span>'
: '<span class="vw-status vw-status--angenommen">aktiv</span>';
return '<div class="vw-karte" style="cursor:default">' +
'<span class="vw-karte-nr">' + (k.projekte || 0) + ' Proj.</span>' +
'<span class="vw-karte-mitte">' +
'<span class="vw-karte-name">' + s(k.name) + (k.firma ? " · " + s(k.firma) : "") + "</span><br />" +
'<span class="vw-karte-info">' + s(k.email) + "</span>" +
"</span>" +
'<span class="vw-karte-rechts">' + marke +
'<span class="vw-karte-zeit">' +
(k.letzter_login ? "zuletzt " + window.WD.datum(k.letzter_login) : "noch nie angemeldet") +
"</span>" +
'<span style="display:block;margin-top:.5rem">' +
'<button type="button" class="wd-btn wd-btn--still wd-btn--klein" data-neuer-link="' + s(k.id) + '">Neuer Link</button> ' +
'<button type="button" class="wd-btn wd-btn--still wd-btn--klein" data-schalten="' + s(k.id) +
'" data-frei="' + (k.freigeschaltet ? "0" : "1") + '">' +
(k.freigeschaltet ? "Sperren" : "Freischalten") + "</button>" +
"</span>" +
"</span></div>";
}).join("");
ziel.querySelectorAll("[data-neuer-link]").forEach(function (b) {
b.addEventListener("click", async function () {
window.WD.knopfSperren(b, true, "…");
try {
var r = await api("/webdesign/admin/kunden/" + encodeURIComponent(b.dataset.neuerLink) + "/einladung",
{ method: "POST", body: {} });
zeigeLink(r.einladungsLink);
} catch (f) { window.WD.meldung(meldung, f.message, "fehler"); }
finally { window.WD.knopfSperren(b, false); }
});
});
ziel.querySelectorAll("[data-schalten]").forEach(function (b) {
b.addEventListener("click", async function () {
var frei = b.dataset.frei === "1";
window.WD.knopfSperren(b, true, "…");
try {
await api("/webdesign/admin/kunden/" + encodeURIComponent(b.dataset.schalten) + "/zugang",
{ method: "POST", body: { freigeschaltet: frei } });
ladeKunden();
} catch (f) {
window.WD.knopfSperren(b, false);
window.WD.meldung(meldung, f.message, "fehler");
}
});
});
} catch (fehler) {
if (fehler.status === 401) { token = null; tokenSchreiben(null); zeigeAnmeldung(); return; }
ziel.innerHTML = "";
window.WD.meldung(meldung, fehler.message, "fehler");
}
}
/* Der Link wird EINMAL angezeigt. Deshalb gross, kopierbar und mit einer
klaren Ansage, dass er nicht wiederkommt -- sonst klickt man ihn weg
und wundert sich. */
function zeigeLink(link) {
var box = document.getElementById("vw-link-box");
box.hidden = false;
box.innerHTML = '<div class="vw-link-anzeige">' +
'<p class="wd-hinweis-titel">Einladungslink — wird nur dieses eine Mal angezeigt</p>' +
"<code>" + s(link) + "</code>" +
'<div class="wd-btn-reihe">' +
'<button type="button" class="wd-btn wd-btn--haupt wd-btn--klein" id="vw-link-kopieren">Link kopieren</button>' +
'<a class="wd-btn wd-btn--zweit wd-btn--klein" href="' + s(link) + '" target="_blank" rel="noopener">Selbst oeffnen</a>' +
"</div>" +
'<p class="wd-klein wd-leise" style="margin:.6rem 0 0">Gueltig 14 Tage. Danach oder bei Verlust einfach einen neuen erzeugen.</p>' +
"</div>";
box.scrollIntoView({ behavior: "smooth", block: "center" });
document.getElementById("vw-link-kopieren").addEventListener("click", async function (e) {
try {
await navigator.clipboard.writeText(link);
e.target.textContent = "Kopiert ✓";
} catch (f) {
/* Die Zwischenablage kann blockiert sein (aelterer Browser, kein
HTTPS, verweigerte Berechtigung). Dann den Text markieren statt
einer Fehlermeldung, mit der niemand etwas anfangen kann. */
var bereich = document.createRange();
bereich.selectNodeContents(box.querySelector("code"));
var auswahl = getSelection();
auswahl.removeAllRanges();
auswahl.addRange(bereich);
e.target.textContent = "markiert — jetzt kopieren";
}
});
}
document.getElementById("vw-kunde-form").addEventListener("submit", async function (e) {
e.preventDefault();
await legeKundeAn({
name: document.getElementById("vw-kname").value.trim(),
email: document.getElementById("vw-kmail").value.trim(),
firma: document.getElementById("vw-kfirma").value.trim(),
sprache: document.getElementById("vw-ksprache").value,
freischalten: document.getElementById("vw-kfrei").checked,
}, e.target.querySelector('button[type="submit"]'));
});
/* "Testzugang fuer mich": legt einen Zugang mit einer eindeutig als Test
erkennbaren Adresse an. Bewusst NICHT die echte Geschaeftsadresse --
sonst kollidiert der Test spaeter mit einem echten Kundenkonto, und
die E-Mail-Adresse laesst sich aus gutem Grund nicht mehr aendern. */
document.getElementById("vw-testzugang").addEventListener("click", async function (e) {
var stempel = new Date().toISOString().slice(0, 10).replace(/-/g, "");
await legeKundeAn({
name: "Testzugang (" + stempel + ")",
email: "test+" + stempel + "@dogfather-universe.com",
firma: "Eigener Test",
sprache: window.WD.getSprache(),
freischalten: true,
}, e.target);
});
async function legeKundeAn(daten, knopf) {
["vw-feld-kname", "vw-feld-kmail"].forEach(function (id) {
document.getElementById(id).classList.remove("wd-feld--fehler");
});
window.WD.meldungAus(meldung);
window.WD.knopfSperren(knopf, true, "Wird angelegt…");
try {
var r = await api("/webdesign/admin/kunden", { method: "POST", body: daten });
zeigeLink(r.einladungsLink);
document.getElementById("vw-kunde-form").reset();
document.getElementById("vw-kfrei").checked = true;
ladeKunden();
} catch (fehler) {
window.WD.meldung(meldung, fehler.message, "fehler");
} finally {
window.WD.knopfSperren(knopf, false);
}
}
/* ---------------- Start ----------------
Reihenfolge bewusst so:
1. Ein bereits geholter Ausweis im Sitzungsspeicher.
2. Sonst: einen neuen bei der Zugangswand holen. Die hat serverseitig
laengst geprueft, wer hier ist -- ein zweites Passwort danach
bringt keinen zusaetzlichen Schutz und kostet nur Zeit
(Rueckmeldung 22.08.2026).
3. Erst wenn das nicht klappt (Ausweis nicht eingerichtet, Sitzung
abgelaufen), die eigene Anmeldemaske.
Der Ausweis laeuft nach 15 Minuten ab. Deshalb wird bei jedem 401
einmal stillschweigend ein neuer geholt und die Anfrage wiederholt --
sonst fliegt man mitten im Arbeiten raus. */
var ausweisVersucht = false;
async function ausweisHolen() {
try {
var antwort = await fetch("/webdesign/api-ausweis", { cache: "no-store" });
if (!antwort.ok) return null;
var daten = await antwort.json();
return daten && daten.ausweis ? daten.ausweis : null;
} catch (e) {
return null;
}
}
async function start() {
token = tokenLesen();
if (token) { zeigeBereich(); ladeAnfragen(); return; }
var ausweis = await ausweisHolen();
if (ausweis) {
token = ausweis;
tokenSchreiben(token);
document.getElementById("vw-name").textContent =
(document.cookie.match(/dogiwd_rolle=([^;]+)/) || [])[1] === "vanvan" ? "VanVan" : "Dogfather";
zeigeBereich();
ladeAnfragen();
return;
}
zeigeAnmeldung();
}
if (window.WD) start();
else document.addEventListener("wd-bereit", start);
})();
</script>
</body>
</html>