Files
dogfather-universe/webdesign/verwaltung.html
T
DogFatherGitandClaude Opus 5 51c3d4402b Audit des Creator Workspace, App-Symbole fuer alle Apps der Domain
AUDIT (Auftrag: vollstaendiger Durchgang, Fehler direkt beheben)

Ausgangslage waren 40 Pruefungen mit 1616 Einzelpunkten, alle gruen.
Acht neue Pruefungen kamen dazu; sie haben gefunden, was die alten nicht
sehen konnten.

Der schwerste Fund: Die Zugangsschranke verglich req.path EXAKT gegen
eine Liste. Express raeumt Punkt-Segmente selbst weg, mehrfache
Schraegstriche aber nicht. Damit kam //workspace/start.html OHNE
Anmeldung mit HTTP 200, und ein Creator bekam ueber
/workspace//personen.html die Verwaltungsseite. Die DATEN waren nie
betroffen (nachgemessen: 404 bzw. 401). Behoben durch Normalisierung
UND eine Umkehr der Logik -- jetzt ist jede .html geschuetzt ausser der
Anmeldeseite, statt nur die in der Liste. Eine vergessene neue Seite
steht damit nicht mehr versehentlich offen.

Weiter behoben:
  * Kaputter/abgebrochener Rumpf ergab 500 in HTML statt 400 in JSON --
    die Oberflaeche ruft ueberall a.json() und lief in einen zweiten
    Fehler; der Knopf hing ohne Meldung.
  * POST /zustand/sichern war der einzige von 60 schreibenden Wegen
    ohne Herkunftspruefung.
  * workspace-sicherung.js gab interne Pfade in Fehlermeldungen nach
    aussen; alle 23 anderen Module antworten neutral.
  * admin_notiz war als einziges von 14 Feldern ohne <label>.
  * Der aktive Filter hatte keinen sichtbaren Fokus (CSS-Spezifitaet
    0,3,0 schlug 0,2,0) -- genau der Knopf, auf dem man steht.
  * h1 -> h3 ohne Zwischenstufe auf zwei Seiten.
  * HSTS ging auch ueber http mit (RFC 6797, 7.2 verbietet das).
  * upgrade-insecure-requests galt auch auf 127.0.0.1 -- dadurch war
    WebKit/Safari ueberhaupt nicht pruefbar, also der Browser, den
    jedes iPhone benutzt.
  * pruef-grosscheck las readdirSync(".") und pruefte aus server/
    gestartet NULL oeffentliche Seiten -- meldete aber "ok".

Neue Pruefungen: struktur, schranke, haerte, alle-wege,
barrierefrei-workspace, breiten, tempo-workspace, browser.
Jede mit Gegenprobe und mit der geprueften Anzahl in der Bedingung.
Vier davon sind beim Bauen durch die eigene Gegenprobe aufgeflogen und
haetten sonst dauerhaft gruen gemeldet, ohne etwas zu messen.

APP-SYMBOLE (Wunsch: alle Apps der Domain, jede anders, ausser
safeaddress)

Zehn Apps, zehn Stile, zehn in OKLCH gerechnete Farben. Zusammen haelt
sie dasselbe Logo, dieselbe Eckenrundung und eine gemeinsame gedeckte
Farbreihe. Beim Bauen wird gemessen, ob sich das Logo vom Grund abhebt
(19 bis 58 Helligkeitsstufen).

Dabei aufgefallen: Das Kundenportal hatte kein eigenes Manifest und
trug Namen und Symbol der Webdesign-Seite. Der Workspace hatte gar
keins und war als App nicht installierbar. Beide haben jetzt eins.

Geaendert wurde AUSSCHLIESSLICH das Symbol. Ein Zwischenstand hatte
auch die Themenfarben gesetzt; das war mehr als bestellt und wurde
zurueckgenommen.

Werkzeuge: tools/logo-freistellen.mjs, tools/app-symbole.mjs,
tools/app-symbole-einbinden.mjs -- alles im Browser gerechnet, kein
Bildprogramm, keine neue Abhaengigkeit.

Gitea und Nextcloud sind bereits live und nachgeprueft.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-05 11:42:17 +02:00

6267 lines
288 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" />
<!-- viewport-fit=cover ist die Voraussetzung dafuer, dass env(safe-area-inset-*)
ueberhaupt einen Wert liefert. Ohne diese Angabe gibt jedes env() null zurueck
-- die Sicherheitsabstaende im CSS waeren totes Recht. Zusammen mit
apple-mobile-web-app-status-bar-style=black-translucent (siehe unten) laeuft
der Inhalt sonst installiert unter Uhr und Akkuanzeige. -->
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>Verwaltung — Dogfather Webdesign</title>
<meta name="robots" content="noindex, nofollow, noarchive" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/wdverwalt-32.png" />
<meta name="theme-color" content="#05070b" />
<!-- EIGENES Manifest, nicht das des Webdesign-Bereichs (26.08.2026).
Damit erscheint die Verwaltung als eigenstaendige App neben
"Dogfather Webdesign", mit eigenem Namen und eigenem Symbol. Der
Unterschied macht die Datei "id": Ohne sie waeren beide fuer den
Browser dieselbe App, und die zweite Installation wuerde die erste
ueberschreiben.
⚠️ Der Geltungsbereich ("scope") umfasst bewusst den GANZEN
Webdesign-Bereich und nicht nur die Verwaltungsseite.
Naheliegender waere "/webdesign/verwaltung" gewesen -- sauber
abgegrenzt. Es haette die App aber unbrauchbar gemacht: Ohne
gueltigen Ausweis leitet der Server auf /webdesign/zugang.html um,
und alles ausserhalb des Geltungsbereichs oeffnet der Browser in
einem eigenen Fenster. Weil der Zugang beim Schliessen der Seite
endet (Wunsch vom 22.08.2026), waere das bei fast jedem Start
passiert: Man tippt auf die App und landet im Browser.
Nachgemessen, nicht vermutet: verwaltung.html antwortet ohne
Ausweis mit 302 auf zugang.html. -->
<link rel="manifest" href="/webdesign/verwaltung.webmanifest" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/wdverwalt-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=20260823wd20" />
<link rel="stylesheet" href="/assets/css/webdesign.css?v=20260823wd20" />
<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; }
/* ---------- Schmuck ----------
Die Verwaltung ist ein Arbeitsplatz. Hier wird nicht geworben,
hier werden Listen gelesen und Zahlen verglichen -- deshalb ist
der Schmuck hier bewusst viel zurueckhaltender als auf den
oeffentlichen Seiten.
Zwei Stellen, beide ausserhalb des Arbeitsflusses:
1. Die Anmeldemaske. Dort wird nichts gelesen ausser drei Zeilen,
also darf das Motiv dort deutlich zu sehen sein.
2. Ein Streifen ganz oben, der nach unten wegblendet. Er liegt
hinter der Kopfleiste und ist verschwunden, bevor die erste
Tabelle anfaengt.
Was hier ABSICHTLICH nicht passiert: kein Motiv hinter Listen,
Tabellen oder Zahlen. Ein Bild hinter einer Zahlenspalte ist genau
die Art von Schoenheit, die ein Werkzeug unbrauchbar macht. */
/* ---------- DIE BEREICHS-SIGNATUR ----------
Jeder der sechs Bereiche bekommt ein eigenes Motiv UND eine eigene
Leitfarbe. Beides wechselt beim Reiterklick.
Die Zuordnung ist nicht ausgewuerfelt, sondern gelesen:
Uebersicht Wappen Die Zentrale, wo alles zusammenlaeuft.
Anfragen Portal Ein Tor. Hier kommt Neues herein.
Projekte Monolith Etwas, das aufrecht steht und gebaut wird.
Kunden Thron Wer bestellt, steht auf dem Podest.
Zahlungen Kristall Der Wert selbst. Dazu Liquid Gold.
Postfach Portal Wieder ein Tor -- Nachrichten gehen durch.
Anderer Ausschnitt, andere Farbe, damit es
trotzdem nicht wie "Anfragen" aussieht.
Warum das mehr ist als Deko: Nach ein paar Tagen erkennt man den
Bereich an der Farbe, bevor man den Titel gelesen hat. Der Schmuck
wird zur Orientierung.
Die Regel von vorher gilt unveraendert weiter: NIE ein Motiv hinter
Listen, Tabellen oder Zahlen. Der Streifen ist verschwunden, bevor
die erste Tabelle anfaengt. Ein Bild hinter einer Zahlenspalte ist
genau die Art von Schoenheit, die ein Werkzeug unbrauchbar macht. */
/* Die Variablen haengen am WURZELELEMENT, nicht am Arbeitsbereich.
Beim Bauen hingen sie an #vw-bereich -- und alle sechs Bereiche
zeigten dasselbe Bild. Grund: Der Schmuckstreifen liegt ausserhalb
von #vw-bereich, erbt die Variablen also nie und faellt auf den
Rueckfallwert zurueck. Die Farben wechselten (Reiter und Titel
liegen drinnen), die Motive nicht. */
:root {
/* Rueckfall, falls das Kennzeichen mal fehlt: Dann sieht es aus wie
die Uebersicht, statt gar nicht. */
--vw-motiv: url("/assets/img/cryo-wappen.webp");
--vw-motiv-klein: url("/assets/img/cryo-wappen-klein.webp");
--vw-farbe: var(--wd-blau-rgb);
--vw-blick: center top;
}
[data-vw-bereich="uebersicht"] {
--vw-motiv: url("/assets/img/cryo-wappen.webp");
--vw-motiv-klein: url("/assets/img/cryo-wappen-klein.webp");
--vw-farbe: var(--wd-blau-rgb);
--vw-blick: 76% 52%;
}
[data-vw-bereich="anfragen"] {
--vw-motiv: url("/assets/img/cryo-ring.webp");
--vw-motiv-klein: url("/assets/img/cryo-ring-klein.webp");
--vw-farbe: var(--wd-aqua-rgb);
--vw-blick: 72% 46%;
}
[data-vw-bereich="projekte"] {
--vw-motiv: url("/assets/img/cryo-monolith.webp");
--vw-motiv-klein: url("/assets/img/cryo-monolith-klein.webp");
--vw-farbe: var(--wd-lila-rgb);
--vw-blick: 80% 46%;
}
[data-vw-bereich="kunden"] {
--vw-motiv: url("/assets/img/cryo-thron.webp");
--vw-motiv-klein: url("/assets/img/cryo-thron-klein.webp");
/* Prism Indigo -- hier unbedenklich, weil es NUR Flaeche und Kante
faerbt. Als Schriftfarbe faellt es durch (3,25:1), deshalb steht
weiter unten ausdruecklich, dass die Schrift davon unberuehrt
bleibt. */
--vw-farbe: var(--wd-indigo-rgb);
--vw-blick: 84% 52%;
}
[data-vw-bereich="zahlungen"] {
--vw-motiv: url("/assets/img/cryo-kristall.webp");
--vw-motiv-klein: url("/assets/img/cryo-kristall-klein.webp");
--vw-farbe: var(--wd-gold-rgb);
/* Oberer Streifen: zeigt die geschwungenen Kristallbänder statt der
flachen Mitte -- siehe die .vw-buehne-Ausnahme weiter unten, die
diesen Wert als einzige tatsächlich anwendet. */
--vw-blick: center 15%;
}
[data-vw-bereich="postfach"] {
--vw-motiv: url("/assets/img/cryo-ring.webp");
--vw-motiv-klein: url("/assets/img/cryo-ring-klein.webp");
--vw-farbe: var(--wd-ion-rgb);
/* Bewusst der andere Bildrand als bei "Anfragen": gleiches Bild,
voellig anderer Ausschnitt. */
--vw-blick: 12% 54%;
}
/* ---------- DIE BUEHNE ----------
Das Motiv fuellt den ganzen Bildschirm. Fest stehend, ungeschnitten,
in voller Leuchtkraft. Es wird NICHT gezoomt und NICHT einseitig
abgedunkelt.
Warum das der zweite Anlauf ist: Vorher war es ein schmaler Streifen
mit 190 % Zoom, aus dem ein Ausschnitt gewaehlt und dessen linke
Haelfte voll zugedeckt wurde -- damit der Text darauf lesbar bleibt.
Uebrig blieb ein Zipfel. Von einer Kristallwelt, die ueber das ganze
Bild geht, war praktisch nichts mehr zu sehen.
Der Denkfehler war, Bild und Text auf dieselbe Ebene zu zwingen und
dann das Bild zu opfern. Jetzt liegen sie auf zwei Ebenen: Das Bild
ist die Buehne, der Inhalt schwebt als Milchglas darueber. Dadurch
steht Text NIE direkt auf dem Bild -- und das Bild muss nirgends
mehr weichen. */
.vw-raum {
position: fixed;
inset: 0;
z-index: -2;
pointer-events: none;
overflow: hidden;
/* Hier endet die Mischung der Lampe. */
isolation: isolate;
}
.vw-buehne {
position: absolute;
inset: 0;
z-index: 0;
pointer-events: none;
background: var(--vw-motiv, url("/assets/img/cryo-wappen.webp"))
center center / cover no-repeat;
/* Kein Zoom: "cover" zeigt bei einem 16:9-Motiv auf einem
ueblichen Bildschirm praktisch das ganze Bild. */
}
/* Ausnahme Zahlungen (28.08.2026): Das cryo-kristall-Motiv ist als
einziges HOCHKANT (702x941) statt quer (1672x941) wie die anderen
fünf. Bei "cover" auf der breiten Bühne wird davon nur ein
horizontaler Streifen gezeigt -- der mittige (center center) trifft
die unruhigsten Kristallsplitter und wirkt flach. Der obere Streifen
zeigt stattdessen die eleganten, geschwungenen Kristallbänder rechts
und lässt links ruhigen, dunklen Raum für die Karten darüber. Nur
hier überschrieben, die anderen Motive bleiben mittig. Der Wert
kommt aus --vw-blick, das für die Bereiche längst definiert, bisher
aber nirgends ausgelesen wurde. */
[data-vw-bereich="zahlungen"] .vw-buehne { background-position: center 15%; }
/* Eine ruhige Toenung darueber. Sie nimmt dem Bild nicht die Zeichnung,
sondern legt nur so viel Tiefe darunter, dass die Glasflaechen sich
abheben. Oben etwas dichter, weil dort die Kopfleiste sitzt. */
.vw-buehne::after {
content: "";
position: absolute;
inset: 0;
background:
radial-gradient(125% 95% at 50% 8%,
rgb(3 8 16 / .18) 0%, rgb(3 8 16 / .38) 52%, rgb(3 8 16 / .60) 100%),
linear-gradient(180deg,
rgb(3 8 16 / .14) 0%, rgb(6 19 35 / .20) 55%, rgb(3 8 16 / .40) 100%);
}
/* ---------- DIE BUEHNE ATMET MIT ----------
Eine sehr leichte Gegenbewegung zum Mauszeiger. Der Ausschlag liegt
bei wenigen Bildpunkten -- gerade genug, dass sich der Raum echt
anfuehlt statt wie eine Tapete, aber nicht so viel, dass beim Lesen
etwas wackelt.
Die Buehne ist dafuer minimal groesser als der Bildschirm skaliert.
Ohne diesen Vorhalt wuerde beim Verschieben an einer Kante ein
schwarzer Streifen auftauchen. */
.vw-buehne {
/* Die Verschiebung steht bewusst in "translate" und NICHT in
"transform".
Grund: Die Einblend-Animation weiter unten animiert "transform"
und haelt ihren Endwert fest (fill-mode both). Eine Animation
schlaegt jede normale Regel -- die Parallaxe waere damit
wirkungslos gewesen. Genau das ist beim Bauen passiert: Die Werte
kamen sauber an, das Bild stand trotzdem still.
"translate" ist eine eigene Eigenschaft und wird VOR "transform"
angewendet. Beide koennen sich so nicht mehr in die Quere kommen. */
translate: calc(var(--vw-px, 0) * -14px) calc(var(--vw-py, 0) * -10px);
transform: scale(1.045);
transition: translate .45s cubic-bezier(.2,.7,.3,1);
will-change: translate;
}
/* Beim Bereichswechsel wechselt der ganze Raum. */
@keyframes vw-buehne-ein {
from { opacity: 0; transform: scale(1.10); }
to { opacity: 1; transform: scale(1.045); }
}
.vw-buehne { animation: vw-buehne-ein .75s cubic-bezier(.16,.8,.3,1) both; }
/* ---------- DER LICHTREFLEX BEIM WECHSEL ----------
Ein einzelner heller Streifen zieht schraeg ueber die Buehne, wenn
der Bereich wechselt. Er laeuft genau einmal und ist danach weg --
kein Dauerflackern, sondern ein Moment. */
.vw-buehne::before {
content: "";
position: absolute;
inset: -20%;
background: linear-gradient(108deg,
transparent 38%,
rgb(var(--vw-farbe) / .16) 47%,
rgb(255 255 255 / .22) 50%,
rgb(var(--vw-farbe) / .16) 53%,
transparent 62%);
opacity: 0;
animation: vw-reflex 1.05s cubic-bezier(.3,.6,.2,1) .12s both;
}
@keyframes vw-reflex {
0% { opacity: 0; transform: translateX(-62%); }
22% { opacity: 1; }
78% { opacity: 1; }
100% { opacity: 0; transform: translateX(62%); }
}
@media (prefers-reduced-motion: reduce) {
/* Alles Bewegte aus. Die Buehne bleibt, der Reflex entfaellt --
er ist reine Zugabe und traegt keine Information. */
.vw-buehne {
animation: none; transition: none;
transform: scale(1.045); translate: none;
}
.vw-buehne::before { animation: none; opacity: 0; }
}
@media (max-width: 900px) {
/* Auf dem Handy die kleine Bildfassung. Und "cover" schneidet bei
einem hochkant gehaltenen Telefon zwangslaeufig die Seiten weg --
deshalb hier der Bildmittelpunkt, wo bei allen Motiven das
Hauptgeschehen sitzt. */
.vw-buehne {
background-image: var(--vw-motiv-klein, url("/assets/img/cryo-wappen-klein.webp"));
background-position: center center;
}
/* Auf kleinen Schirmen liegt mehr Inhalt uebereinander, also etwas
mehr Tiefe unter dem Glas. */
.vw-buehne::after {
background:
linear-gradient(180deg,
rgb(3 8 16 / .52) 0%, rgb(6 19 35 / .58) 50%, rgb(3 8 16 / .72) 100%);
}
}
/* ---------- DIE ZEIGERLAMPE ----------
Ein weicher Lichtkegel wandert ueber die Buehne und hellt die
Kristalle dort auf, wo der Zeiger steht. Damit wird das Bild zu
einer Flaeche, die auf einen reagiert, statt nur dazuzuliegen.
Der Kniff steckt in mix-blend-mode: soft-light. Ein normaler heller
Verlauf wuerde das Bild ueberdecken und milchig machen. "soft-light"
rechnet stattdessen mit dem, was darunter liegt -- dunkle Stellen
bleiben dunkel, vorhandene Lichtkanten der Kristalle werden
verstaerkt. Das Bild wird nicht ueberstrahlt, es wird beleuchtet.
Bewusst NICHT "screen" oder "overlay": Beide reissen die hellen
Stellen zu und lassen die Eiskanten ausbrennen. Genau die machen
aber den Reiz der Motive aus. */
.vw-lampe {
position: absolute;
inset: 0;
z-index: 1;
pointer-events: none;
mix-blend-mode: soft-light;
/* Gross und weich, nicht klein und hell.
Die Kacheln tragen eine Rueckseiten-Unschaerfe -- sie nehmen also
auf, was hinter ihnen liegt, und das ist unter anderem diese
Lampe. Das ist genau richtig so: Licht, das durch Milchglas
scheint. Bei einem engen Kegel war davon aber ein scharf
umrissener Fleck mitten auf der Karte uebrig.
Deshalb ein weiter Radius mit vielen flachen Stufen: Der
Helligkeitsunterschied verteilt sich ueber die halbe Kachel und
liest sich als Schimmer statt als Kreis. */
/* Der Lichtkegel sitzt FEST in der Mitte des Elements und wird per
transform verschoben -- nicht ueber die Position des Verlaufs.
Das ist der Unterschied zwischen ruckelig und fluessig: Aendert
sich die Verlaufsposition, muss der Browser die Flaeche bei jeder
Zeigerbewegung neu zeichnen. Und weil darueber Glasflaechen mit
Rueckseiten-Unschaerfe liegen, muessen die gleich mit. Gemessen
waren es dadurch 17 Bilder pro Sekunde.
Eine Verschiebung per transform laeuft dagegen auf der
Grafikkarte: Der Verlauf wird einmal gezeichnet und danach nur
noch bewegt. */
width: 2200px;
height: 2200px;
inset: auto auto auto auto;
left: 50%;
top: 50%;
margin-left: -1100px;
margin-top: -1100px;
will-change: transform;
transform: translate3d(var(--vw-lx, 0px), var(--vw-ly, 0px), 0);
/* Die Bewegung wird vom Stilblatt geglaettet, nicht vom Skript.
Der Grund ist Rechenzeit: Jede Verschiebung der Lampe aendert den
Untergrund SAEMTLICHER Glaskacheln darueber -- und jede davon muss
daraufhin ihre Rueckseiten-Unschaerfe neu berechnen. Bei 60
Aktualisierungen je Sekunde faellt das 60-mal an.
Das Skript setzt die Position deshalb nur noch etwa 12-mal je
Sekunde. Die Uebergangszeit fuellt die Luecken dazwischen weich
auf -- fuer das Auge laeuft die Lampe unveraendert fluessig, der
Rechner macht ein Fuenftel der Arbeit. */
transition: transform .22s cubic-bezier(.25,.8,.35,1);
background: radial-gradient(
circle 1100px at 50% 50%,
rgb(var(--vw-farbe) / .34) 0%,
rgb(var(--vw-farbe) / .26) 22%,
rgb(var(--vw-farbe) / .16) 42%,
rgb(255 255 255 / .06) 62%,
rgb(var(--vw-farbe) / .04) 80%,
transparent 100%);
opacity: 0;
transition: opacity .5s ease;
}
/* Erst zeigen, wenn der Zeiger wirklich da war -- sonst haengt beim
Laden ein Lichtfleck in der Bildmitte, den niemand dorthin gelegt
hat. */
/* Die Zeigerlampe ist ABGESCHALTET.
Sie war der zweite Teil der teuren Wechselwirkung: Ihre Bewegung
zwang jede Glasflaeche darueber zum Neurechnen. Zusammen mit dem
Glas kostete sie zwei Drittel der Bildrate.
Das Licht, das dem Zeiger folgt, gibt es weiterhin -- auf den
Kacheln selbst. Das ist der Effekt, der zaehlt, und er ist billig:
Er betrifft nur die Kachel unter dem Zeiger, nicht den ganzen
Bildschirm.
Die Regel bleibt bewusst stehen statt geloescht zu werden, damit
nachvollziehbar ist, dass es eine Entscheidung war. */
.vw-lampe { display: none; }
@media (prefers-reduced-motion: reduce) {
.vw-lampe { transition: none; }
}
/* Ohne feinen Zeiger gibt es kein Schweben -- dann ergibt eine
Zeigerlampe keinen Sinn. */
@media not all and (hover: hover) {
.vw-lampe { display: none; }
}
/* ---------- SCHWEBENDE EISPARTIKEL ----------
Wenige Lichtpunkte steigen sehr langsam auf, zwischen Buehne und
Inhalt. Rein aus CSS, ohne Zeichenflaeche -- eine Zeichenflaeche
wuerde dauerhaft Rechenzeit kosten, und das auf einer Seite, auf
der man arbeitet.
Bewusst nur neun Stueck und sehr langsam. Es soll wirken wie
Staub im Lichtkegel, nicht wie Schneefall. */
.vw-flocken {
position: fixed;
inset: 0;
z-index: -1;
pointer-events: none;
overflow: hidden;
}
.vw-flocken i {
position: absolute;
display: block;
width: 3px; height: 3px;
border-radius: 50%;
background: rgb(var(--vw-farbe) / .75);
box-shadow: 0 0 10px 1px rgb(var(--vw-farbe) / .45);
opacity: 0;
will-change: transform, opacity;
animation: vw-schweben linear infinite;
}
@keyframes vw-schweben {
0% { transform: translateY(0) translateX(0); opacity: 0; }
12% { opacity: .85; }
88% { opacity: .85; }
100% { transform: translateY(-118vh) translateX(28px); opacity: 0; }
}
/* Jede Flocke eigene Bahn, Dauer und Startzeit -- sonst sieht man
sofort, dass es immer dieselbe Bewegung ist. */
.vw-flocken i:nth-child(1) { left: 6%; bottom: -6px; animation-duration: 34s; animation-delay: -2s; }
.vw-flocken i:nth-child(2) { left: 17%; bottom: -6px; animation-duration: 47s; animation-delay: -9s; width: 2px; height: 2px; }
.vw-flocken i:nth-child(3) { left: 29%; bottom: -6px; animation-duration: 39s; animation-delay: -21s; }
.vw-flocken i:nth-child(4) { left: 41%; bottom: -6px; animation-duration: 52s; animation-delay: -14s; width: 2px; height: 2px; }
.vw-flocken i:nth-child(5) { left: 55%; bottom: -6px; animation-duration: 36s; animation-delay: -30s; }
.vw-flocken i:nth-child(6) { left: 67%; bottom: -6px; animation-duration: 44s; animation-delay: -5s; width: 4px; height: 4px; }
.vw-flocken i:nth-child(7) { left: 78%; bottom: -6px; animation-duration: 41s; animation-delay: -25s; }
.vw-flocken i:nth-child(8) { left: 88%; bottom: -6px; animation-duration: 49s; animation-delay: -17s; width: 2px; height: 2px; }
.vw-flocken i:nth-child(9) { left: 96%; bottom: -6px; animation-duration: 37s; animation-delay: -11s; }
@media (prefers-reduced-motion: reduce) {
/* Komplett weg, nicht nur angehalten: Ein eingefrorener Punkt
mitten im Bild waere ein Fleck ohne Sinn. */
.vw-flocken { display: none; }
}
/* ---------- GLAS ----------
Alles, worauf gelesen wird, bekommt eine Rueckseiten-Unschaerfe.
Das Bild bleibt sichtbar, verliert hinter dem Glas aber jede
Struktur -- und genau das macht Text darauf ruhig lesbar.
Wo der Browser backdrop-filter nicht kann, traegt die Farbe allein:
Deshalb ist der Hintergrund auch ohne Unschaerfe schon dicht genug.
Das ist kein Schoenheitsfehler, sondern der Rueckfall. */
/* GLAS NUR AUF WENIGEN, GROSSEN FLAECHEN.
Eine Rueckseiten-Unschaerfe ist der teuerste Effekt auf dieser
Seite -- und ihre Kosten fallen pro Element an. Auf jeder Kachel
einer langen Liste bedeutet das: Bei jeder Zeigerbewegung rechnet
der Browser sie fuer jede Kachel einzeln neu.
Gemessen mit echter Mausbewegung, sechs Kacheln:
alles an 6,4 Bilder/s
ohne Glas 9,7
ohne Lampe 9,2
ohne Glas UND Lampe 16,4
Die beiden verstaerken sich gegenseitig: Die Lampe verschiebt den
Untergrund, woraufhin JEDE Glasflaeche darueber neu rechnen muss.
Die Kacheln bekommen deshalb einen dichteren Hintergrund statt
einer Unschaerfe. Der Unterschied ist gering -- die Buehne
schimmert weiterhin durch, nur ohne Weichzeichnung -- und ein
Arbeitswerkzeug, das bei jeder Mausbewegung ruckelt, ist kein
Gewinn an Schoenheit.
Das Detailblatt behaelt seine Unschaerfe: Davon gibt es immer nur
genau eines, und es liegt gross ueber der Seite. */
/* Ohne #vw-bereich davor: Das Detailblatt liegt NICHT im
Arbeitsbereich, sondern in einer eigenen Ueberlagerung
(#vw-detail). Mit dem Vorsatz griff die Regel nie -- der Test
meldete "keins", und die einzige Flaeche, die die Unschaerfe
wirklich verdient, hatte als einzige keine. */
.vw-detail-blatt {
-webkit-backdrop-filter: blur(14px) saturate(1.15);
backdrop-filter: blur(14px) saturate(1.15);
}
/* 14 statt 22 Punkte Unschaerfe. Optisch kaum zu unterscheiden --
die Struktur des Bildes ist schon bei 14 vollstaendig aufgeloest --
aber deutlich billiger: Die Rechenzeit einer solchen Unschaerfe
waechst mit dem Radius, und sie faellt bei JEDER Bewegung neu an. */
/* Die Reiter und die Bedienknoepfe schweben ebenfalls. */
/* Reiter und Bedienknoepfe: dichte Flaeche statt Unschaerfe, aus
demselben Grund. Es sind sechs Reiter plus zwei Knoepfe -- also
acht Flaechen, die sonst bei jeder Bewegung mitrechnen. */
#vw-bereich .vw-filter-knopf { background: rgb(6 19 35 / .78); }
/* Meldungen wie "Wird geladen" oder "Nichts vorhanden" stehen frei
zwischen den Kacheln -- also direkt auf der Buehne. Sie bekommen
deshalb ihre eigene kleine Glasflaeche statt nur Text zu sein.
Sonst haengt ausgerechnet die Zeile, die etwas erklaeren soll,
ungeschuetzt vor einer Kristallwand. */
#vw-bereich .vw-laedt,
#vw-bereich .vw-leer {
display: inline-block;
padding: .55rem 1.1rem;
border-radius: 999px;
background: rgb(6 19 35 / .72);
border: 1px solid rgb(var(--vw-farbe) / .22);
-webkit-backdrop-filter: blur(16px);
backdrop-filter: blur(16px);
}
/* Die Glaskacheln bekommen eine Leuchtkante in der Leitfarbe. Das
bindet Inhalt und Buehne zusammen, statt die Kacheln wie
aufgeklebte Zettel wirken zu lassen. */
/* ---------- DER GLEITENDE LEUCHTBALKEN ----------
Unter der Reiterreihe liegt ein Balken in der Leitfarbe. Beim
Wechsel springt er nicht, sondern gleitet zum neuen Reiter und
faerbt sich dabei um.
Position und Breite kommen aus dem ECHTEN Reiter, gemessen im
Browser. Feste Werte waeren hier zwangslaeufig falsch: Die Reiter
sind unterschiedlich breit ("Kunden" gegen "Zahlungen"), sie
verschieben sich beim Sprachwechsel, und auf schmalen Schirmen
brechen sie um. */
.vw-tabs {
position: relative;
}
.vw-tabs::after {
content: "";
position: absolute;
left: 0;
top: var(--vw-balken-y, 100%);
width: var(--vw-balken-b, 0px);
height: 2px;
border-radius: 2px;
transform: translateX(var(--vw-balken-x, 0px));
background: linear-gradient(90deg,
rgb(var(--vw-farbe) / 0) 0%,
rgb(var(--vw-farbe) / .95) 22%,
rgb(var(--vw-farbe) / .95) 78%,
rgb(var(--vw-farbe) / 0) 100%);
box-shadow: 0 0 16px -2px rgb(var(--vw-farbe) / .75);
transition:
transform .42s cubic-bezier(.22,.9,.24,1),
width .42s cubic-bezier(.22,.9,.24,1),
top .42s cubic-bezier(.22,.9,.24,1);
pointer-events: none;
/* Erst zeigen, wenn er vermessen ist -- sonst blitzt beim Laden
kurz ein Balken der Breite 0 an der linken Kante auf. */
opacity: var(--vw-balken-da, 0);
}
@media (prefers-reduced-motion: reduce) {
.vw-tabs::after { transition: none; }
}
/* ---------- KACHELN TAUCHEN AUF ----------
Beim Bereichswechsel erscheinen die Kacheln nacheinander statt alle
auf einmal. Der Versatz ist klein; er soll den Blick von oben nach
unten fuehren, nicht auf eine Animation warten lassen.
Nur die ersten acht bekommen einen Versatz. Bei einer langen Liste
wuerde die letzte Kachel sonst spuerbar spaeter kommen -- das
fuehlt sich nicht mehr elegant an, sondern langsam. */
/* Die Auftauch-Animation nutzt "translate" und "scale" als EIGENE
Eigenschaften, nicht "transform".
Grund: Die raeumliche Neigung weiter unten braucht "transform" fuer
sich. Eine Animation haelt ihren Endwert fest und schlaegt jede
normale Regel -- die Neigung waere sonst wirkungslos. Dieselbe
Falle wie bei der Buehne, nur eine Ebene tiefer. */
@keyframes vw-kachel-auf {
from { opacity: 0; translate: 0 14px; scale: .985; }
to { opacity: 1; translate: none; scale: 1; }
}
#vw-bereich .wd-karte,
#vw-bereich .vw-karte,
#vw-bereich .vw-meld {
animation: vw-kachel-auf .5s cubic-bezier(.2,.8,.3,1) both;
}
#vw-bereich .wd-karte:nth-child(1),
#vw-bereich .vw-karte:nth-child(1),
#vw-bereich .vw-meld:nth-child(1) { animation-delay: .02s; }
#vw-bereich .wd-karte:nth-child(2),
#vw-bereich .vw-karte:nth-child(2),
#vw-bereich .vw-meld:nth-child(2) { animation-delay: .07s; }
#vw-bereich .wd-karte:nth-child(3),
#vw-bereich .vw-karte:nth-child(3),
#vw-bereich .vw-meld:nth-child(3) { animation-delay: .12s; }
#vw-bereich .wd-karte:nth-child(4),
#vw-bereich .vw-karte:nth-child(4),
#vw-bereich .vw-meld:nth-child(4) { animation-delay: .17s; }
#vw-bereich .wd-karte:nth-child(5),
#vw-bereich .vw-karte:nth-child(5),
#vw-bereich .vw-meld:nth-child(5) { animation-delay: .22s; }
#vw-bereich .wd-karte:nth-child(6),
#vw-bereich .vw-karte:nth-child(6),
#vw-bereich .vw-meld:nth-child(6) { animation-delay: .27s; }
#vw-bereich .wd-karte:nth-child(7),
#vw-bereich .vw-karte:nth-child(7),
#vw-bereich .vw-meld:nth-child(7) { animation-delay: .32s; }
#vw-bereich .wd-karte:nth-child(n+8),
#vw-bereich .vw-karte:nth-child(n+8),
#vw-bereich .vw-meld:nth-child(n+8) { animation-delay: .36s; }
@media (prefers-reduced-motion: reduce) {
#vw-bereich .wd-karte { animation: none; }
}
/* ---------- DAS LICHT FOLGT DER MAUS ----------
Das Lumen-Licht war nie weg: --wd-lichtx/--wd-lichty werden weiter
gesetzt, der Lichtkegel steht korrekt. Er war nur unsichtbar
geworden. Die 28 % Deckkraft aus dem Grundsystem waren fuer eine
dunkle, undurchsichtige Kachel gedacht -- auf einer Glasflaeche, durch
die eine beleuchtete Kristallwelt schimmert, geht das unter.
Hier bekommt es deshalb Kraft, und zwar auf ZWEI Ebenen:
1. Der Lichtkegel auf der Flaeche (das ::after aus dem System).
2. Die KANTE der Kachel leuchtet dort auf, wo der Zeiger steht.
Zusammen sieht es aus, als laege eine echte Lichtquelle ueber dem
Glas -- und nicht, als waere ein Fleck aufgemalt. */
#vw-bereich .wd-karte,
#vw-bereich .vw-karte,
#vw-bereich .vw-meld {
background:
/* Die Flaeche: dicht genug, dass Text darauf ruhig steht. */
/* Deutlich dichter als zuvor (.52/.68).
Der Grund ist gemessen: Solange die Kachel durchscheinend ist,
muss der Browser das bildschirmfuellende Buehnenbild bei jeder
Neuzeichnung der Kachel mitrechnen. Ohne Buehnenbild stieg die
Bildrate von 24 auf 34,6 -- das Bild unter dem Glas war der
groesste verbliebene Posten.
Bei .92/.96 bleibt die Buehne rings um die Kacheln voll
sichtbar, durch die Kachel selbst schimmert sie nur noch als
Ahnung. Das kostet etwas Tiefe und bringt Fluessigkeit -- auf
einer Seite, auf der gearbeitet wird, ist das der richtige
Tausch. */
linear-gradient(180deg,
rgb(var(--wd-glas-rgb) / .92), rgb(6 19 35 / .96)) padding-box,
/* Die Kante: hier steckt der Zeiger drin. Der Verlauf sitzt an
der Mausposition, dadurch wandert der helle Punkt am Rand
entlang mit. */
/* PRISMA-SCHIMMER.
Am hellsten Punkt sitzt die Leitfarbe, daneben faechert die
Kante in Nachbartoene auf -- wie Licht, das sich in einer
Glaskante bricht.
Bewusst KEIN Regenbogen: Volle Spektralfarben sehen nach
Seifenblase aus, nicht nach geschliffenem Eis. Deshalb bleibt
es innerhalb der kalten Haelfte der Palette (Aqua, Ion,
Violet), und die Nachbartoene liegen deutlich unter der
Leitfarbe. */
radial-gradient(340px circle at var(--wd-lichtx, 50%) var(--wd-lichty, 0%),
rgb(255 255 255 / .55) 0%,
rgb(var(--vw-farbe) / .95) 9%,
rgb(var(--wd-aqua-rgb) / .42) 24%,
rgb(var(--wd-ion-rgb) / .30) 40%,
rgb(var(--wd-lila-rgb) / .22) 62%,
rgb(var(--vw-farbe) / .12) 84%,
rgb(var(--wd-lila-rgb) / .14) 100%) border-box;
/* 1,5 px statt 1 px: Bei genau einem Punkt verschluckt das
Bildschirmraster die Farbaufaecherung fast vollstaendig -- man
sieht dann nur noch die Leitfarbe. */
border: 1.5px solid transparent;
box-shadow:
0 24px 60px -30px rgb(0 0 0 / .85),
inset 0 1px 0 rgb(var(--vw-farbe) / .18);
}
/* Der Lichtkegel auf der Flaeche, kraeftiger als im Grundsystem. */
@media (hover: hover) and (pointer: fine) {
#vw-bereich .wd-karte::after,
#vw-bereich .vw-karte::after,
#vw-bereich .vw-meld::after {
content: "";
position: absolute;
inset: 0;
z-index: 0;
border-radius: inherit;
pointer-events: none;
background: radial-gradient(300px circle at var(--wd-lichtx) var(--wd-lichty),
rgb(var(--vw-farbe) / .30) 0%,
rgb(var(--vw-farbe) / .12) 42%,
transparent 72%);
}
}
/* ---------- DIE KACHEL ALS GESCHLIFFENE GLASPLATTE ----------
Sie liegt nicht flach auf der Seite, sondern im Raum. Faehrt der
Zeiger darueber, neigt sie sich ihm entgegen -- als wuerde man eine
echte Glasplatte kippen.
Der Ausschlag ist bewusst sehr klein (hoechstens 7 Grad). Alles
darueber verzerrt die Schrift sichtbar, und auf diesen Kacheln wird
gearbeitet, nicht nur geschaut. Genau deshalb kippt auch nur die
Kachel, waehrend der Text darin durch translateZ wieder nach vorn
geholt wird: Er steht dadurch fast senkrecht zum Auge und bleibt
scharf, obwohl die Flaeche unter ihm schraeg liegt. */
@media (hover: hover) and (pointer: fine) {
#vw-bereich {
/* Ohne Perspektive gaebe es keine Tiefe -- die Neigung saehe aus
wie ein platt gedruecktes Parallelogramm. */
perspective: 1400px;
}
#vw-bereich .wd-karte,
#vw-bereich .vw-karte,
#vw-bereich .vw-meld {
transform-style: preserve-3d;
transform:
rotateX(calc(var(--vw-ny, 0) * -7deg))
rotateY(calc(var(--vw-nx, 0) * 7deg))
translateZ(0);
transition:
transform .32s cubic-bezier(.2,.8,.3,1),
box-shadow .32s ease;
will-change: transform;
}
/* Beim Ueberfahren kommt die Kachel dem Betrachter entgegen und
wirft einen laengeren Schatten -- das erst macht aus der Neigung
ein Objekt im Raum statt eines schraegen Bildes. */
#vw-bereich .wd-karte:hover,
#vw-bereich .vw-karte:hover,
#vw-bereich .vw-meld:hover {
transform:
rotateX(calc(var(--vw-ny, 0) * -7deg))
rotateY(calc(var(--vw-nx, 0) * 7deg))
translateZ(32px);
box-shadow:
0 44px 90px -34px rgb(0 0 0 / .95),
0 0 60px -18px rgb(var(--vw-farbe) / .45),
inset 0 1px 0 rgb(var(--vw-farbe) / .30);
}
/* Der Inhalt steht vor der Flaeche. Ueberschriften weiter vorn als
Fliesstext -- dadurch entsteht beim Neigen echte Staffelung
statt einer flachen Ebene, die sich mitdreht. */
#vw-bereich .wd-karte > *,
#vw-bereich .vw-karte > *,
#vw-bereich .vw-meld > * { transform: translateZ(18px); }
#vw-bereich .wd-karte h2, #vw-bereich .wd-karte h3,
#vw-bereich .vw-karte .vw-karte-name,
#vw-bereich .vw-karte .vw-karte-nr,
#vw-bereich .vw-meld strong { transform: translateZ(34px); }
/* DER GLANZSTREIFEN.
Ein schmales Licht, das ueber die Platte laeuft und mit der
Neigung wandert. Es sitzt im ::before, weil ::after schon den
Lichtkegel traegt. */
#vw-bereich .wd-karte::before,
#vw-bereich .vw-karte::before,
#vw-bereich .vw-meld::before {
content: "";
position: absolute;
inset: 0;
z-index: 0;
border-radius: inherit;
pointer-events: none;
background: linear-gradient(
calc(112deg + var(--vw-nx, 0) * 26deg),
transparent 34%,
rgb(255 255 255 / .07) 46%,
rgb(var(--vw-farbe) / .16) 50%,
rgb(255 255 255 / .07) 54%,
transparent 66%);
opacity: 0;
transition: opacity .3s ease;
}
#vw-bereich .wd-karte:hover::before,
#vw-bereich .vw-karte:hover::before,
#vw-bereich .vw-meld:hover::before { opacity: 1; }
}
/* Die Ecken sind ungleich gerundet -- diagonal weit, diagonal knapp.
Eine gleichmaessig gerundete Kachel liest sich als Knopf; die
ungleiche Rundung nimmt die Facetten der Motive auf. */
#vw-bereich .wd-karte,
#vw-bereich .vw-karte,
#vw-bereich .vw-meld {
border-radius: 36px 6px 36px 6px;
position: relative;
isolation: isolate;
overflow: hidden;
}
@media (prefers-reduced-motion: reduce) {
#vw-bereich .wd-karte,
#vw-bereich .vw-karte,
#vw-bereich .vw-meld {
transition: none;
transform: none;
animation: none;
}
#vw-bereich .wd-karte:hover,
#vw-bereich .vw-karte:hover,
#vw-bereich .vw-meld:hover { transform: none; }
#vw-bereich .wd-karte > *, #vw-bereich .vw-karte > *, #vw-bereich .vw-meld > *,
#vw-bereich .wd-karte h2, #vw-bereich .wd-karte h3 { transform: none; }
#vw-bereich .wd-karte::before,
#vw-bereich .vw-karte::before,
#vw-bereich .vw-meld::before { display: none; }
}
/* Der Titel steht als einziges frei auf der Buehne. Damit er das
aushaelt, bekommt er einen weichen dunklen Hof -- kein Kasten, der
die Buehne zerschneiden wuerde. */
#vw-bereich .vw-leiste h1 {
text-shadow:
0 2px 18px rgb(3 8 16 / .95),
0 1px 4px rgb(3 8 16 / .9);
}
/* Auch die Reiter reagieren auf den Zeiger. Der Punkt ist klein und
sitzt genau unter dem Finger -- er soll die Flaeche beleben, nicht
vom Beschriftungstext ablenken. */
@media (hover: hover) and (pointer: fine) {
#vw-bereich .vw-filter-knopf {
position: relative;
overflow: hidden;
isolation: isolate;
}
#vw-bereich .vw-filter-knopf::before {
content: "";
position: absolute;
inset: 0;
z-index: -1;
background: radial-gradient(90px circle at var(--wd-lichtx, 50%) var(--wd-lichty, 50%),
rgb(var(--vw-farbe) / .30) 0%,
rgb(var(--vw-farbe) / .10) 45%,
transparent 72%);
opacity: 0;
transition: opacity .25s ease;
}
#vw-bereich .vw-filter-knopf:hover::before { opacity: 1; }
}
/* Suchen und Abmelden sitzen oben rechts -- also genau dort, wo das
Motiv am hellsten ist. Bedienelemente muessen immer lesbar sein,
egal was dahinter liegt. Deshalb bekommen sie hier einen eigenen,
dichten Grund statt der sonst halbdurchsichtigen Flaeche.
backdrop-filter ist bewusst nur eine Zugabe: Wo der Browser es
nicht kann, traegt schon die Farbe allein. */
#vw-bereich .vw-wer {
background: rgb(3 8 16 / .82);
border-radius: 999px;
padding: .3rem .5rem;
-webkit-backdrop-filter: blur(9px);
backdrop-filter: blur(9px);
}
/* ---------- Die Leitfarbe faerbt mit ---------- */
/* Der aktive Reiter traegt die Farbe seines Bereichs. Das ist der
Moment, in dem aus Deko Orientierung wird. */
#vw-bereich .vw-filter-knopf.vw-an {
border-color: rgb(var(--vw-farbe) / .55);
background: rgb(var(--vw-farbe) / .14);
box-shadow: 0 0 18px -6px rgb(var(--vw-farbe) / .5);
}
/* Die Schrift bleibt ABSICHTLICH hell und wird NICHT eingefaerbt.
Sonst waere der Reiter im Bereich "Kunden" in Prism Indigo
geschrieben -- die einzige Farbe der Palette, die als Text
durchfaellt (3,25:1 gegen die dunklen Flaechen). */
#vw-bereich .vw-filter-knopf.vw-an { color: var(--wd-text); }
/* Ein feiner Leuchtstrich unter dem Titel, in der Leitfarbe.
Traegt keine Bedeutung, die nur ueber Farbe laeuft -- der Titel
daneben sagt dasselbe in Worten. */
#vw-bereich .vw-leiste h1 { position: relative; }
#vw-bereich .vw-leiste h1::after {
content: "";
position: absolute;
left: 0; bottom: -.32em;
width: clamp(46px, 22%, 120px);
height: 2px;
border-radius: 2px;
background: linear-gradient(90deg,
rgb(var(--vw-farbe) / .95), rgb(var(--vw-farbe) / 0));
}
/* Das Licht, das den Kacheln unter dem Mauszeiger folgt, nimmt
ebenfalls die Leitfarbe an. Dadurch fuehlt sich jeder Bereich
anders an, ohne dass irgendwo ein zweites Bild noetig waere. */
#vw-bereich .wd-karte,
#vw-bereich .vw-karte,
#vw-bereich .vw-meld { --wd-lumen: var(--vw-farbe); }
/* Die Anmeldekarte bekommt das Wappen. Sie ist halbdurchsichtig,
das Motiv scheint also durch die Glasflaeche hindurch.
Warum so wenig: Bei .38 lag der Hundekopf mitten im Erklaertext,
und die Zeilen liefen quer ueber Schnauze und Schriftzug. Lesbar
war das nicht mehr. Entscheidend ist hier nicht die Deckkraft an
sich, sondern wie stark der Untergrund unter der Schrift
schwankt -- ein ruhiger dunkler Grund vertraegt viel, ein Motiv mit
hellen Kanten fast nichts. */
.vw-login.wd-motiv::before {
opacity: .16;
/* Das Wappen sitzt zentriert genau dort, wo der Text steht. Nach
unten versetzt liegt es hinter Knopf und Rand statt hinter den
Zeilen. */
background-position: center 72%;
}
/* Die Glasflaeche zwischen Bild und Schrift wird hier dichter als auf
den oeffentlichen Seiten -- dort steht Fliesstext, hier ein
Eingabefeld, das man treffen koennen muss. */
.vw-login.wd-motiv::after {
background: linear-gradient(180deg,
rgb(6 19 35 / .82) 0%,
rgb(6 19 35 / .88) 55%,
rgb(3 8 16 / .92) 100%);
}
@media (max-width: 900px) {
.vw-login.wd-motiv::before { background-image: url("/assets/img/cryo-wappen-klein.webp"); }
}
/* ---------- Kopfleiste ---------- */
/* Zwei Zeilen statt einer: oben WO man ist, darunter WOHIN man kann.
Vorher standen Titel und Reiter nebeneinander. Das liest sich wie
eine einzige lange Zeile, in der der Titel nur der erste von sechs
Knöpfen zu sein scheint -- man sieht nicht mehr auf einen Blick, in
welchem Bereich man gerade ist. Getrennt beantwortet die erste Zeile
"wo bin ich", die zweite "wo kann ich hin". */
.vw-leiste {
display: grid;
/* minmax(0, …) statt "1fr auto" (26.08.2026).
Rasterspalten schrumpfen von sich aus NICHT unter die Breite
ihres Inhalts — sie haben "min-width: auto". Auf einem 390px
breiten Gerät summierten sich die beiden Spalten dadurch auf
372,7px, während die Leiste selbst nur 358px breit ist. Das
Raster war also breiter als sein eigener Behälter, und alles
darin schob sich nach rechts über den Bildschirmrand: die
Kopfzeile 8px, der Reiterstreifen 12px.
Sichtbar war das kaum — der Streifen ist ohnehin wischbar, und
rechts fehlte nur ein Stück Rand. Genau deshalb blieb es liegen.
Mit minmax(0, …) darf jede Spalte kleiner werden als ihr Inhalt.
Die Überschrift kürzt dann mit Auslassungspunkten, statt das
ganze Raster aufzublähen. */
grid-template-columns: minmax(0, 1fr) minmax(0, auto);
align-items: center;
gap: .6rem 1rem;
margin-bottom: 1.2rem;
}
.vw-leiste h1 {
margin: 0; font-size: clamp(1.4rem, 3.4vw, 2rem);
grid-column: 1; grid-row: 1;
/* Damit die Spalte oben tatsächlich schrumpfen darf, muss auch das
Kind es dürfen. Lange Titel wie "Projektanfragen" kürzen dann,
statt zu schieben. */
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.vw-leiste .vw-wer { grid-column: 2; grid-row: 1; min-width: 0; }
/* Die Reiter über die volle Breite darunter. */
.vw-leiste .vw-tabs {
grid-column: 1 / -1; grid-row: 2;
padding-top: .2rem;
/* ⚠️ min-width: 0 ist hier NICHT überflüssig (26.08.2026).
Ein Rasterkind bekommt von sich aus "min-width: auto" — es darf
also breiter werden als seine Spalte, wenn sein Inhalt das
verlangt. Genau das passierte: Sobald ein Reiter einen Zähler
trug ("Anfragen ③"), wuchs der Streifen auf volle Fensterbreite
und ragte, weil die Leiste links 12px Abstand hat, rechts 12px
über den Bildschirmrand hinaus.
Gemessen auf einem 390px breiten Gerät: In der Übersicht endete
der Streifen bei 378px, im Anfragen-Bereich bei 402px. Sichtbar
wurde es kaum — der Streifen ist ohnehin seitlich wischbar, also
fiel nur auf, dass der letzte Reiter schwerer zu erreichen war.
Ein Fehler, der sich als Eigenart tarnt.
Mit min-width: 0 darf er wieder schmaler werden als sein Inhalt —
und genau dann greift das seitliche Wischen, wofür es gedacht
ist. */
min-width: 0;
}
@media (max-width: 560px) {
/* Titel und Abmelden bleiben zusammen in Zeile 1.
Die erste Fassung schob den Abmelden-Knopf in eine dritte Zeile.
Zusammen mit den damals zweizeiligen Reitern gingen so 190px
verloren, bevor die erste Zahl kam -- fast der halbe sichtbare
Bereich eines Handys, nur fuer die Kopfzeile. */
.vw-leiste { gap: .5rem .6rem; }
.vw-leiste h1 { font-size: 1.35rem; }
/* Der Name steht schon in der App-Leiste; hier kostet er nur Platz. */
.vw-leiste .vw-wer #vw-name { display: none; }
}
.vw-wer { display: flex; justify-content: flex-end; align-items: center; gap: .6rem; font-size: .9rem; color: var(--wd-text-soft); }
/* ================================================================
ÜBERSICHT — das Cockpit
Gestalterische Grundregel hier: Ruhe als Normalzustand. Eine
Verwaltung, in der alles leuchtet, hat keine Dringlichkeitsstufen
mehr — man sieht überall Farbe und deshalb nirgends ein Signal.
Deshalb sind die Kacheln standardmäßig zurückhaltend, und nur was
wirklich liegen geblieben ist, bekommt Farbe.
Farbe ist außerdem NIE der einzige Träger einer Aussage. Neben
jedem farbigen Zustand steht ein Text ("älteste seit 6 Tagen"),
damit die Information auch ankommt, wenn man Farben nicht
unterscheiden kann (WCAG 1.4.1).
================================================================ */
/* Zwei Klassen, nicht eine -- und das ist kein Schoenheitsfehler.
Mit `.vw-ub-h` (eine Klasse) gewinnt `.wd h2` (Klasse + Element),
und die Zwischenueberschriften erschienen in 44px Schaugroesse:
Sie waren doppelt so gross wie die Zahlen, um die es geht, und die
Seite las sich wie ein Plakat statt wie ein Arbeitsplatz. Dieselbe
Falle wie frueher bei `.wd a` und `.wd .wd-btn--haupt`. */
/* ---------- Benachrichtigungen ---------- */
.vw-meld {
display: flex; align-items: flex-start; gap: .7rem;
padding: .7rem .85rem; margin-bottom: .5rem;
border-radius: var(--wd-radius-m, 14px);
border: 1px solid rgb(var(--wd-blau-rgb) / .3);
background: linear-gradient(rgb(var(--wd-blau-rgb) / .07), rgb(var(--wd-blau-rgb) / .07)),
var(--wd-flaeche-2, #16191d);
}
/* Dringend heisst hier: Geld ist geflossen oder eine Frist laeuft.
Nur DIESE bekommen Farbe -- bekaeme jede Meldung eine, waere keine
mehr auffaellig. */
.vw-meld--dringend {
border-color: rgb(var(--wd-gold-rgb) / .5);
background: linear-gradient(rgb(var(--wd-gold-rgb) / .08), rgb(var(--wd-gold-rgb) / .08)),
var(--wd-flaeche-2, #16191d);
}
.vw-meld-inhalt { flex: 1; min-width: 0; cursor: pointer; text-align: left;
background: none; border: 0; color: inherit; font: inherit; padding: 0; }
.wd .vw-meld-titel { font-weight: 700; font-size: .95rem; margin: 0; }
.wd .vw-meld-text { font-size: .84rem; color: var(--wd-text-soft); margin: .15rem 0 0; }
.vw-meld-weg {
flex-shrink: 0; min-width: 44px; min-height: 44px;
background: none; border: 0; color: var(--wd-text-soft);
cursor: pointer; font-size: 1.1rem; border-radius: 8px;
}
.vw-meld-weg:hover { background: rgb(255 255 255 / .07); color: var(--wd-text); }
.vw-meld-weg:focus-visible { outline: 2px solid rgb(var(--wd-blau-rgb) / .95); outline-offset: 2px; }
.vw-meld-alle { margin: .1rem 0 1rem; }
.vw-ag-details { margin: .6rem 0 .8rem; }
.wd .vw-ag-details summary {
cursor: pointer; font-size: .86rem; color: var(--wd-text-soft);
padding: .3rem 0; min-height: 44px; display: flex; align-items: center;
}
.wd .vw-ag-details summary:hover { color: var(--wd-text); }
.wd .vw-ag-details textarea { font-family: var(--font-body); font-size: .86rem; line-height: 1.5; }
.vw-abbruch-rechnung {
padding: .7rem .85rem; margin-bottom: .8rem;
border-radius: var(--wd-radius-m, 14px);
background: rgb(255 255 255 / .04);
border: 1px solid rgb(255 255 255 / .08);
}
.wd .vw-abbruch-rechnung p { font-size: .86rem; margin: 0 0 .25rem; }
.wd .vw-abbruch-vorschlag { color: var(--wd-gold); }
.vw-abbruch-info { border-left: 3px solid #e78278; padding-left: .8rem; }
.wd .vw-ub-h {
font-size: 1.05rem; font-weight: 650;
margin: 1.9rem 0 .75rem;
letter-spacing: .01em; color: var(--wd-text);
/* Eine leise Linie darunter gliedert die drei Bloecke, ohne sie mit
Kaesten zu umranden -- Rahmen um Rahmen wuerde unruhig. */
padding-bottom: .35rem;
border-bottom: 1px solid rgb(255 255 255 / .07);
}
.wd .vw-ub-h:first-child { margin-top: 0; }
.vw-ub-hinweis { margin: -.4rem 0 .8rem; max-width: 62ch; }
.vw-ub-gitter {
display: grid; gap: .7rem;
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
margin-bottom: .4rem;
}
/* ================================================================
MEINS ODER SEINS — auf einen Blick unterscheidbar
Wunsch vom 24.08.2026: "ich will dass die kunden sachen auch in der
verwaltungs seite von kacheln eine andere farbe haben wie meine
damit ich sie gut unterscheide."
Umgesetzt über den TON, nicht über die Warnfarbe: Was bei mir liegt,
bleibt im Markenblau — das ist meine Arbeit. Was beim Kunden liegt,
bekommt Lila. Der Unterschied ist deutlich, ohne dass eine der
beiden Seiten nach Alarm aussieht.
Warum nicht Rot/Grün: Die Warnstufen (gelb, rot) sind an das ALTER
vergeben und müssen frei bleiben. Eine Kachel, die gleichzeitig
"beim Kunden" und "seit acht Tagen überfällig" färben müsste,
könnte nur eine der beiden Aussagen zeigen — und die Frist ist die
wichtigere.
Zusätzlich zur Farbe eine Kante links. Farbe allein trägt die
Aussage nicht: Wer sie nicht unterscheiden kann, sähe sonst zwei
gleich aussehende Blöcke (WCAG 1.4.1). */
#vw-ub-beikunde .vw-ub-kachel {
border-color: rgb(var(--wd-lila-rgb) / .28);
border-left: 3px solid rgb(var(--wd-lila-rgb) / .55);
}
#vw-ub-beikunde .vw-ub-kachel .vw-ub-zahl { color: var(--wd-lila); }
#vw-ub-beikunde .vw-ub-kachel:hover:not(:disabled) {
border-color: rgb(var(--wd-lila-rgb) / .5);
}
/* Auch die Null-Kachel bleibt zugeordnet — sonst wäre ausgerechnet
die gute Nachricht die einzige ohne erkennbare Herkunft. */
#vw-ub-beikunde .vw-ub-kachel--leer { border-left-color: rgb(var(--wd-lila-rgb) / .3); }
/* Meine Seite bekommt spiegelbildlich eine blaue Kante, damit der
Unterschied ein Gegensatz ist und nicht "eine Gruppe ist markiert". */
#vw-ub-beimir .vw-ub-kachel { border-left: 3px solid rgb(var(--wd-blau-rgb) / .5); }
#vw-ub-beimir .vw-ub-kachel--warn { border-left-color: #e9c46a; }
#vw-ub-beimir .vw-ub-kachel--rot { border-left-color: #e78278; }
@media (max-width: 560px) {
/* Zwei Spalten statt einer.
Mit einer Kachel pro Zeile war die Uebersicht auf dem Handy
2635px lang -- man musste an allem vorbeiwischen, um das Geld zu
sehen, und der Sinn einer UEBERSICHT ist, dass man nicht wischen
muss. Zwei Spalten halbieren das. Die Schrift bleibt gleich
gross; verkleinert wuerde sie schwerer lesbar, und lesbar ist
hier wichtiger als kompakt. */
.vw-ub-gitter { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .55rem; }
.vw-ub-kachel { padding: .8rem .75rem; }
}
.vw-ub-kachel {
display: flex; flex-direction: column; align-items: flex-start; gap: .18rem;
text-align: left; width: 100%;
padding: .95rem 1rem;
border-radius: var(--wd-radius-m, 14px);
border: 1px solid rgb(var(--wd-blau-rgb) / .16);
background: var(--wd-flaeche-2, #16191d);
color: var(--wd-text);
cursor: pointer;
font: inherit;
box-shadow: var(--wd-hoehe-1);
transition: transform .16s ease, border-color .16s ease, background-color .16s ease;
}
.vw-ub-kachel:hover:not(:disabled) {
transform: translateY(-2px);
border-color: rgb(var(--wd-blau-rgb) / .42);
}
/* Sichtbarer Fokus, weil das hier eine Tastatur-bedienbare Fläche ist
und die Standardmarkierung auf dunklem Grund untergeht. */
.vw-ub-kachel:focus-visible {
outline: 2px solid rgb(var(--wd-blau-rgb) / .95);
outline-offset: 2px;
}
.vw-ub-kachel:disabled { cursor: default; }
.vw-ub-zahl {
font-size: clamp(1.6rem, 4vw, 2.05rem); font-weight: 700; line-height: 1.05;
/* Ziffern in gleicher Breite: Sonst springt die Zahl beim
Aktualisieren seitlich, weil eine 1 schmaler ist als eine 8. */
font-variant-numeric: tabular-nums;
color: rgb(var(--wd-blau-rgb));
}
.vw-ub-titel { font-size: .92rem; font-weight: 600; }
.vw-ub-unten { font-size: .8rem; color: var(--wd-text-soft); }
/* Nichts zu tun — die Kachel tritt zurück, statt zu verschwinden.
Weg wäre falsch: "0 offene Anfragen" ist eine Aussage, und wenn die
Kachel fehlt, weiß man nicht, ob geprüft wurde. */
.vw-ub-kachel--leer { opacity: .62; border-color: rgb(255 255 255 / .07); }
.vw-ub-kachel--leer .vw-ub-zahl { color: var(--wd-text-soft); }
/* Zwei Stufen statt einer. Ein einziger Warnzustand zwingt dazu, alles
Liegengebliebene gleich laut zu machen — dann geht das wirklich
Dringende im Dauerwarnen unter. */
.vw-ub-kachel--warn {
border-color: rgb(233 196 106 / .48);
background: linear-gradient(rgb(233 196 106 / .05), rgb(233 196 106 / .05)), var(--wd-flaeche-2, #16191d);
}
.vw-ub-kachel--warn .vw-ub-zahl { color: #e9c46a; }
.vw-ub-kachel--rot {
border-color: rgb(231 130 120 / .55);
background: linear-gradient(rgb(231 130 120 / .06), rgb(231 130 120 / .06)), var(--wd-flaeche-2, #16191d);
}
/* Gedämpftes Korallrot statt reinem Rot: auf dunklem Grund ist volles
Rot blendend und schlecht lesbar — dieser Ton hält den Kontrast und
bleibt trotzdem ruhig genug fürs lange Hinsehen. */
.vw-ub-kachel--rot .vw-ub-zahl { color: #e78278; }
/* ---- Projekte ---- */
.vw-ub-projekt {
display: block; width: 100%; text-align: left;
padding: .85rem 1rem; margin-bottom: .55rem;
border-radius: var(--wd-radius-m, 14px);
border: 1px solid rgb(255 255 255 / .08);
background: var(--wd-flaeche-2, #16191d);
color: var(--wd-text); cursor: pointer; font: inherit;
transition: border-color .16s ease, transform .16s ease;
}
.vw-ub-projekt:hover { border-color: rgb(var(--wd-blau-rgb) / .38); transform: translateY(-1px); }
.vw-ub-projekt:focus-visible { outline: 2px solid rgb(var(--wd-blau-rgb) / .95); outline-offset: 2px; }
.vw-ub-pkopf {
display: flex; flex-wrap: wrap; align-items: baseline;
justify-content: space-between; gap: .5rem;
}
.vw-ub-pname { font-weight: 650; font-size: .98rem; }
.vw-ub-pwer {
font-size: .74rem; padding: .12rem .5rem; border-radius: 999px;
background: rgb(255 255 255 / .07); color: var(--wd-text-soft);
white-space: nowrap;
}
/* Nur das, was bei MIR liegt, wird hervorgehoben. Was beim Kunden
liegt, ist keine Aufgabe und braucht keine Aufmerksamkeit. */
.vw-ub-pwer--ich {
background: rgb(var(--wd-blau-rgb) / .17);
color: rgb(var(--wd-blau-rgb));
}
.vw-ub-pkunde { display: block; font-size: .8rem; color: var(--wd-text-soft); margin-top: .12rem; }
.vw-ub-balken {
display: block; height: 6px; margin: .55rem 0 .3rem;
border-radius: 999px; background: rgb(255 255 255 / .08); overflow: hidden;
}
.vw-ub-balken-voll {
display: block; height: 100%; border-radius: 999px;
background: linear-gradient(90deg, rgb(var(--wd-blau-rgb) / .75), rgb(var(--wd-gold-rgb) / .8));
transition: width .5s ease;
}
.vw-ub-punten { display: block; font-size: .78rem; color: var(--wd-text-soft); font-variant-numeric: tabular-nums; }
/* ---- Verlauf ---- */
.vw-ub-verlauf { list-style: none; margin: 0; padding: 0; }
.vw-ub-verlauf li {
display: grid; grid-template-columns: auto 1fr auto; gap: .2rem .8rem;
align-items: baseline;
padding: .42rem .2rem;
border-bottom: 1px solid rgb(255 255 255 / .055);
font-size: .85rem;
}
.vw-ub-verlauf li:last-child { border-bottom: 0; }
.vw-ub-vzeit { color: var(--wd-text-soft); font-variant-numeric: tabular-nums; white-space: nowrap; }
.vw-ub-vwas { color: var(--wd-text); }
.vw-ub-vwer { color: var(--wd-text-soft); font-size: .78rem; white-space: nowrap; }
@media (max-width: 560px) {
/* Auf dem Handy untereinander statt in drei Spalten — sonst wird die
mittlere Spalte so schmal, dass jede Zeile dreifach umbricht. */
.vw-ub-verlauf li { grid-template-columns: 1fr; }
.vw-ub-vwer { white-space: normal; }
}
@media (prefers-reduced-motion: reduce) {
.vw-ub-kachel, .vw-ub-projekt, .vw-ub-balken-voll { transition: none; }
.vw-ub-kachel:hover:not(:disabled), .vw-ub-projekt:hover { transform: none; }
}
/* ================================================================
SUCHE ÜBER ALLES
Liegt über der Seite, nicht daneben: Sie ist ein Werkzeug, das man
kurz benutzt und wieder wegtut. Ein fest eingebautes Suchfeld
nähme dauerhaft Platz für etwas, das man zehnmal am Tag für drei
Sekunden braucht.
================================================================ */
.vw-palette { position: fixed; inset: 0; z-index: 200; }
.vw-palette[hidden] { display: none; }
.vw-palette-hg {
position: absolute; inset: 0;
background: rgb(0 0 0 / .55);
backdrop-filter: blur(3px);
}
.vw-palette-kasten {
position: relative;
/* Oben statt mittig: Die Liste wächst nach unten, und ein mittig
zentrierter Kasten würde bei jedem Tastendruck springen. */
margin: max(8vh, calc(1rem + var(--wd-sicher-oben, 0px))) auto 0;
width: min(680px, calc(100vw - 1.6rem));
max-height: min(70vh, 620px);
display: flex; flex-direction: column;
background: var(--wd-flaeche-2, #16191d);
border: 1px solid rgb(var(--wd-blau-rgb) / .28);
border-radius: var(--wd-radius-l, 20px);
box-shadow: 0 24px 70px -20px rgb(0 0 0 / .85);
overflow: hidden;
}
.vw-palette-kopf {
display: flex; align-items: center; gap: .6rem;
padding: .85rem 1rem;
border-bottom: 1px solid rgb(255 255 255 / .07);
}
.vw-palette-lupe { color: var(--wd-text-soft); flex-shrink: 0; }
.vw-palette-feld {
flex: 1; min-width: 0;
background: none; border: 0; outline: none;
color: var(--wd-text);
/* 16px, damit iOS beim Antippen nicht in die Seite hineinzoomt. */
font-size: 16px; font-family: var(--font-body);
}
.vw-palette-feld::placeholder { color: var(--wd-text-soft); }
.vw-palette-weg {
flex-shrink: 0; min-height: 44px; min-width: 44px;
background: rgb(255 255 255 / .06); border: 1px solid rgb(255 255 255 / .1);
border-radius: 8px; color: var(--wd-text-soft);
font-size: .74rem; font-weight: 700; cursor: pointer;
}
.vw-palette-weg:focus-visible { outline: 2px solid rgb(var(--wd-blau-rgb) / .95); outline-offset: 2px; }
/* Zwei Klassen, weil `.wd p` (Klasse + Element) sonst gewinnt.
Dieselbe Falle wie schon bei `.wd a`, `.wd .wd-btn--haupt` und den
Zwischenueberschriften der Uebersicht: Die Fusszeile und der Zaehler
erschienen in 17,9px statt 11,5px -- ein Tastaturhinweis, der so
gross ist wie der Inhalt, den er erklaert. */
.wd .vw-palette-zahl {
margin: 0; padding: .35rem 1rem 0;
font-size: .76rem; color: var(--wd-text-soft);
}
.wd .vw-palette-zahl:empty { display: none; }
.vw-palette-liste {
list-style: none; margin: 0; padding: .35rem;
overflow-y: auto; flex: 1;
}
.vw-pal-treffer {
display: flex; align-items: center; gap: .7rem;
padding: .6rem .65rem; border-radius: 10px;
cursor: pointer;
}
/* Die Auswahl ist eine FLÄCHE, kein Rahmen: Ein Rahmen verschiebt bei
manchen Schriftgrössen die Zeile um einen Pixel, und beim Durchgehen
mit den Pfeiltasten zappelt dann die ganze Liste. */
.vw-pal-treffer.vw-pal-an { background: rgb(var(--wd-blau-rgb) / .16); }
.vw-pal-art {
flex-shrink: 0; width: 62px; text-align: center;
padding: .16rem 0; border-radius: 999px;
font-size: .68rem; font-weight: 800; letter-spacing: .03em;
text-transform: uppercase;
background: rgb(255 255 255 / .07); color: var(--wd-text-soft);
}
.vw-pal-art--anfrage { background: rgb(var(--wd-gold-rgb) / .16); color: var(--wd-gold); }
.vw-pal-art--kunde { background: rgb(var(--wd-blau-rgb) / .16); color: var(--wd-blau); }
.vw-pal-art--projekt { background: rgb(var(--wd-lila-rgb) / .18); color: var(--wd-lila); }
.vw-pal-art--zahlung { background: rgb(var(--wd-ok-rgb) / .16); color: var(--wd-ok); }
.vw-pal-mitte { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.vw-pal-titel {
font-size: .93rem; font-weight: 650; color: var(--wd-text);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.vw-pal-unten {
font-size: .78rem; color: var(--wd-text-soft);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.vw-pal-marke {
flex-shrink: 0; font-size: .72rem; color: var(--wd-text-soft);
padding: .12rem .5rem; border-radius: 999px;
background: rgb(255 255 255 / .06);
}
.wd .vw-pal-hinweis {
padding: 1.4rem 1rem; text-align: center;
color: var(--wd-text-soft); font-size: .9rem;
}
.vw-pal-hinweis--fehler { color: #e78278; }
.wd .vw-palette-fuss {
margin: 0; padding: .55rem 1rem;
border-top: 1px solid rgb(255 255 255 / .07);
font-size: .72rem; color: var(--wd-text-soft);
}
.wd .vw-palette-fuss kbd {
display: inline-block; padding: .05rem .35rem; margin: 0 .1rem;
border: 1px solid rgb(255 255 255 / .14); border-radius: 5px;
background: rgb(255 255 255 / .05); font-size: .9em;
font-family: var(--font-body);
}
@media (max-width: 620px) {
/* Auf dem Handy gibt es keine Tastatur mit Strg -- der Hinweis wäre
dort eine Anleitung für etwas Unmögliches. */
.wd .vw-palette-fuss { display: none; }
.vw-palette-kasten { max-height: min(80vh, 620px); }
}
/* Der Lupenknopf in der Kopfzeile. */
.vw-suche-auf {
display: inline-flex; align-items: center; justify-content: center; gap: .4rem;
min-height: 44px; padding: 0 .8rem;
background: rgb(255 255 255 / .05);
border: 1px solid var(--wd-rand); border-radius: 10px;
color: var(--wd-text-soft); cursor: pointer; font: inherit; font-size: .84rem;
}
.vw-suche-auf:hover { border-color: rgb(var(--wd-blau-rgb) / .4); color: var(--wd-text); }
.vw-suche-auf:focus-visible { outline: 2px solid rgb(var(--wd-blau-rgb) / .95); outline-offset: 2px; }
.vw-suche-auf kbd {
padding: .05rem .3rem; border: 1px solid rgb(255 255 255 / .14);
border-radius: 4px; font-size: .72rem; font-family: var(--font-body);
}
@media (max-width: 620px) { .vw-suche-auf kbd { display: none; } }
/* ---------- Kennzahlen, gehobene Fassung ----------
Die erste Fassung war eine Reihe stiller Zahlen. Zahlen, die man
nur anschauen kann, sind Dekoration. Diese hier sind Werkzeuge:
jede Kachel filtert die Liste darunter, sobald man sie antippt.
Eine Null wird bewusst gedaempft. Sonst konkurriert "0 abgelehnt"
optisch mit "3 neu" -- und genau die Drei ist die, auf die man
schauen soll. Was nichts zu tun gibt, soll auch nicht leuchten. */
.vw-zahlen {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: .7rem; margin-bottom: 1.4rem;
}
.vw-zahl {
position: relative; overflow: hidden;
display: block; width: 100%; text-align: left; cursor: pointer;
padding: .95rem 1.05rem;
border-radius: 14px;
background: linear-gradient(180deg, rgba(20,32,50,.62), rgba(9,15,26,.8));
border: 1px solid var(--wd-rand);
transition: border-color var(--wd-zeit-schnell) ease,
transform var(--wd-zeit-schnell) ease,
box-shadow var(--wd-zeit) ease;
}
.vw-zahl:hover { border-color: var(--wd-rand-stark); transform: translateY(-2px); }
.vw-zahl.vw-an {
border-color: var(--wd-blau);
box-shadow: 0 0 0 1px var(--wd-blau) inset, 0 14px 34px -20px rgb(var(--wd-blau-rgb) / .7);
}
.vw-zahl-kopf { display: flex; align-items: center; gap: .45rem; margin-bottom: .35rem; }
.vw-zahl-symbol { font-size: 1rem; line-height: 1; opacity: .85; }
.vw-zahl-wert {
font-family: var(--font-head); font-weight: 800;
font-size: clamp(1.5rem, 3.4vw, 1.9rem); line-height: 1;
color: var(--wd-blau); font-variant-numeric: tabular-nums;
}
.vw-zahl-name { font-size: .78rem; letter-spacing: .03em; color: var(--wd-text-soft); }
.vw-zahl-zusatz { font-size: 0.8rem; color: var(--wd-text-soft); margin-top: .3rem; display: block; }
/* Null: alles gedaempft, damit es nicht um Aufmerksamkeit buhlt. */
.vw-zahl--null { opacity: .5; }
.vw-zahl--null .vw-zahl-wert { color: var(--wd-text-soft); }
/* Handlungsbedarf: Gold, und nur hier. */
.vw-zahl--achtung { border-color: rgb(var(--wd-gold-rgb) / .34); background: linear-gradient(180deg, rgb(var(--wd-gold-rgb) / .07), rgba(9,15,26,.8)); }
.vw-zahl--achtung .vw-zahl-wert { color: var(--wd-gold); }
.vw-zahl--achtung .vw-zahl-zusatz { color: var(--wd-gold); }
/* Dringend: etwas liegt zu lange. */
.vw-zahl--dringend { border-color: rgb(var(--wd-fehler-rgb) / .4); background: linear-gradient(180deg, rgb(var(--wd-fehler-rgb) / .07), rgba(9,15,26,.8)); }
.vw-zahl--dringend .vw-zahl-wert,
.vw-zahl--dringend .vw-zahl-zusatz { color: var(--wd-fehler); }
/* ---------- 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: 10px;
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: rgb(var(--wd-blau-rgb) / .13); border-color: var(--wd-blau); color: var(--wd-blau); }
.vw-filter-zahl {
font-size: 0.8rem; padding: .1rem .4rem; border-radius: 6px;
background: rgb(var(--wd-blau-rgb) / .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: rgb(var(--wd-glas-rgb) / .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: rgb(var(--wd-blau-rgb) / .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; display: flex; flex-direction: column; gap: .1rem; }
.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: 0.8rem; font-weight: 700; }
.vw-status--neu { background: rgb(var(--wd-gold-rgb) / .16); color: var(--wd-gold); }
.vw-status--pruefung { background: rgb(var(--wd-blau-rgb) / .14); color: var(--wd-blau); }
.vw-status--rueckfrage { background: rgb(var(--wd-lila-rgb) / .16); color: var(--wd-lila); }
.vw-status--angebot { background: rgb(var(--wd-lila-rgb) / .16); color: var(--wd-lila); }
.vw-status--angenommen { background: rgb(var(--wd-ok-rgb) / .16); color: var(--wd-ok); }
.vw-status--abgeschlossen { background: rgb(var(--wd-ok-rgb) / .1); color: var(--wd-ok); }
.vw-status--abgelehnt { background: rgb(var(--wd-fehler-rgb) / .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); }
/* ---------- Detailansicht: zentriertes Fenster ----------
Vorher eine seitlich eingeschobene Leiste. Fuer so viel Inhalt ist
das das falsche Format: Der Blick springt beim Oeffnen nach rechts,
die Liste dahinter bleibt halb sichtbar und lenkt ab, und alles muss
sich in eine schmale Spalte quetschen -- "keine Angabe" stand
dadurch achtmal untereinander.
Jetzt mittig, breiter und zweispaltig. Der Blick bleibt, wo er ist,
und zusammengehoerende Angaben stehen nebeneinander statt
hintereinander. */
.vw-detail-blatt {
position: absolute;
top: 50%; left: 50%; transform: translate(-50%, -50%);
width: min(940px, calc(100vw - 2rem));
max-height: min(88vh, 900px);
background: linear-gradient(180deg, #0b1523, #070c15);
border: 1px solid var(--wd-rand-stark);
border-radius: 20px;
box-shadow: 0 50px 120px -40px rgba(0,0,0,.95), 0 0 0 1px rgb(var(--wd-blau-rgb) / .06) inset;
overflow-y: auto;
padding: clamp(1.2rem, 3vw, 1.9rem) clamp(1rem, 3vw, 2rem) clamp(1.4rem, 3vw, 2rem);
animation: vw-rein .26s cubic-bezier(.22,1,.36,1) both;
}
@keyframes vw-rein {
from { transform: translate(-50%, -46%) scale(.97); opacity: 0; }
to { transform: translate(-50%, -50%) scale(1); opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
.vw-detail-blatt { animation: none; }
}
/* Kopfzeile klebt oben fest — bei langen Anfragen weiss man sonst nach
dem Scrollen nicht mehr, wessen Daten man gerade liest. */
.vw-detail-kopf {
position: sticky; top: calc(clamp(1.2rem, 3vw, 1.9rem) * -1);
margin: calc(clamp(1.2rem, 3vw, 1.9rem) * -1) calc(clamp(1rem, 3vw, 2rem) * -1) 1.2rem;
padding: 1.1rem clamp(1rem, 3vw, 2rem);
background: rgba(11, 21, 35, .96);
backdrop-filter: blur(10px);
border-bottom: 1px solid var(--wd-rand);
border-radius: 20px 20px 0 0;
z-index: 2;
}
/* ---------- Zwei Spalten MIT BEDEUTUNG ----------
Vorher "columns: 2" — also Zeitungsspalten. Die füllen sich von
selbst: erst Spalte eins bis oben voll, dann Spalte zwei. Welcher
Kasten wo landet, hängt allein davon ab, wie lang die davor sind.
Bei einer Anfrage stand "Interne Notizen" oben rechts, bei der
nächsten unten links. Genau das war das Durcheinander
(Rückmeldung 22.08.2026) — es gab schlicht keine Ordnung, der man
hätte folgen können.
Jetzt ein festes Raster mit einer Aussage:
LINKS = was der Kunde geschickt hat (lesen)
RECHTS = was du damit machst (handeln)
Diese Trennung ist immer gleich. Man weiss nach der zweiten Anfrage,
wo man hinschauen muss, ohne zu suchen.
Die rechte Spalte ist schmaler: Sie enthaelt Knoepfe und kurze Texte,
die linke laufende Angaben. Gleich breit wuerde rechts Luft und
links Enge erzeugen.
Und sie klebt beim Scrollen mit. Die Handlungen sind der Grund, warum
man diese Ansicht ueberhaupt oeffnet — sie duerfen nicht nach unten
aus dem Bild wandern, waehrend man oben die Angaben liest. */
@media (min-width: 900px) {
.vw-detail-spalten {
display: grid;
grid-template-columns: minmax(0, 1.25fr) minmax(0, 1fr);
gap: 0 1.3rem;
align-items: start;
}
.vw-dt-links, .vw-dt-rechts { min-width: 0; }
.vw-dt-rechts {
position: sticky;
/* Abstand zur oben klebenden Kopfzeile, sonst schiebt sich der
erste Kasten darunter. */
top: 6.2rem;
}
}
/* Unter 900px untereinander — in der Reihenfolge lesen, dann handeln. */
.vw-dt-links, .vw-dt-rechts { display: flex; flex-direction: column; }
/* ---------- Auf einen Blick ----------
Die vier Fragen, die man IMMER zuerst hat: Was will der? Wie viel
Geld? Bis wann? Wie alt ist die Anfrage? Sie standen bisher verteilt
in drei verschiedenen Kaesten weiter unten. Jetzt einmal oben,
gross, ohne Suchen. */
.vw-blick {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(115px, 1fr));
gap: .5rem;
margin-bottom: 1.1rem;
}
.vw-blick-feld {
padding: .7rem .8rem;
border-radius: 10px;
background: rgb(var(--wd-blau-rgb) / .055);
border: 1px solid var(--wd-rand);
min-width: 0;
}
.vw-blick-was {
display: block; font-size: 0.75rem; letter-spacing: .11em;
text-transform: uppercase; color: var(--wd-text-soft); margin-bottom: .22rem;
}
.vw-blick-wert {
display: block; font-family: var(--font-head); font-weight: 700;
font-size: .98rem; color: var(--wd-text); line-height: 1.25;
overflow-wrap: break-word;
}
.vw-blick-wert.vw-leer { font-family: var(--font-body); font-weight: 400; opacity: .5; font-size: .88rem; }
/* ---------- Nicht Angegebenes ----------
Vorher bekam jede fehlende Angabe eine eigene Zeile mit "keine
Angabe". Bei einer knappen Anfrage bestand die halbe Ansicht daraus —
der Kasten "Umfang" enthielt nichts als zweimal diesen Text und war
trotzdem so gross wie ein Kasten mit Inhalt.
Leere Felder sind aber keine Information, sondern deren Fehlen. Sie
stehen jetzt als EINE zurueckhaltende Zeile am Fuss des Kastens.
Weggelassen werden sie nicht: Man muss sehen koennen, wonach gefragt
wurde und was unbeantwortet blieb — genau daraus entstehen die
Rueckfragen. */
.vw-ohne {
margin-top: .55rem; padding-top: .5rem;
border-top: 1px dashed var(--wd-rand);
font-size: .8rem; color: var(--wd-text-soft); line-height: 1.5;
}
.vw-ohne b { font-weight: 600; color: var(--wd-text-soft); }
/* Ein Kasten, in dem GAR NICHTS ausgefuellt wurde, tritt als Ganzes
zurueck — er soll nicht dieselbe Aufmerksamkeit bekommen wie einer
mit Inhalt. */
.vw-abschnitt--leer { opacity: .62; }
.vw-abschnitt--leer:hover { opacity: 1; }
/* Jeder Abschnitt wird eine eigene Flaeche statt nur einer Linie. */
.vw-detail-spalten .vw-abschnitt {
padding: .95rem 1.1rem 1rem;
border-radius: 14px;
background: rgb(var(--wd-glas-rgb) / .5);
border: 1px solid var(--wd-rand);
margin-bottom: 1.1rem;
}
.vw-detail-spalten .vw-abschnitt > h3 { border-bottom: none; padding-bottom: 0; margin-bottom: .7rem; }
/* "keine Angabe" tritt zurueck: Es ist die Abwesenheit einer Information
und darf nicht so aussehen wie eine. */
.vw-paar dd.vw-leer { font-size: .88rem; opacity: .55; }
@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: 0.8rem; 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: 10px; margin-bottom: .5rem;
background: rgb(var(--wd-blau-rgb) / .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; flex-wrap: wrap; gap: .4rem; }
@media (max-width: 700px) {
/* Sechs Reiter brauchen umgebrochen zwei bis drei Zeilen. Statt in
die Hoehe zu wachsen, wird daraus EIN Streifen zum Wischen.
overflow-x statt Umbruch, scroll-snap damit ein Reiter nie halb
abgeschnitten stehen bleibt, und ein Auslauf am rechten Rand
(die Maske unten), damit man SIEHT, dass es weitergeht -- ein
harter Schnitt sieht aus wie das Ende der Liste. */
.vw-tabs {
flex-wrap: nowrap;
overflow-x: auto;
scroll-snap-type: x proximity;
scrollbar-width: none;
/* Der Streifen darf ueber den Containerrand hinauslaufen und
nimmt den Innenabstand selbst mit -- sonst endet er optisch
mitten auf der Seite. */
margin-inline: calc(var(--wd-sicher-links, 0px) * -1 - 4px)
calc(var(--wd-sicher-rechts, 0px) * -1 - 4px);
padding-inline: 4px;
/* scroll-padding zusaetzlich zu padding.
Ohne das richtet scroll-snap den ersten Reiter am SCROLLRAND
aus und schluckt den Innenabstand: Der Knopf sass dann bei 12px
statt bei 16px und stand damit vier Pixel links neben dem Titel
darueber -- eine Fehlstellung, die man sieht, aber schwer
benennen kann. scroll-padding verschiebt die Einrastkante nach
innen, sodass Titel und erster Reiter buendig sind. */
scroll-padding-inline: 4px;
padding-bottom: 2px;
-webkit-overflow-scrolling: touch;
mask-image: linear-gradient(90deg, #000 0, #000 calc(100% - 26px), transparent 100%);
}
.vw-tabs::-webkit-scrollbar { display: none; }
.vw-tabs > * { scroll-snap-align: start; flex: 0 0 auto; }
}
.vw-link-anzeige {
padding: 1rem; border-radius: 14px;
background: rgb(var(--wd-gold-rgb) / .08); border: 1px solid rgb(var(--wd-gold-rgb) / .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;
}
/* ---------- Anfragekarte, gehobene Fassung ----------
Die erste Fassung zeigte Nummer, Name, Paket, Status, Datum. Das ist
korrekt, aber es beantwortet nicht die Fragen, die man beim
Draufschauen WIRKLICH hat: Wie dringend ist das? Wie viel Geld steckt
drin? Wie lange liegt es schon? Genau die stehen jetzt drin.
Ein farbiger Streifen links codiert den Status zusaetzlich zur
Textmarke — Farbe ALLEIN waere fuer farbfehlsichtige Menschen keine
Information, zusammen mit dem Text ist sie ein schneller Anker. */
.vw-karte {
position: relative;
overflow: hidden;
padding-left: 1.5rem;
}
.vw-karte::before {
content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 4px;
background: var(--wd-rand-stark);
}
.vw-karte--neu::before { background: var(--wd-gold); }
.vw-karte--pruefung::before,
.vw-karte--angebot::before { background: var(--wd-blau); }
.vw-karte--rueckfrage::before { background: var(--wd-lila); }
.vw-karte--angenommen::before,
.vw-karte--abgeschlossen::before { background: var(--wd-ok); }
.vw-karte--abgelehnt::before { background: var(--wd-fehler); }
.vw-karte-marken { display: flex; flex-wrap: wrap; gap: .35rem; margin-top: .4rem; }
.vw-mini {
font-size: 0.8rem; font-weight: 600; padding: .15rem .5rem;
border-radius: 6px; background: rgb(var(--wd-blau-rgb) / .09);
color: var(--wd-text-mid); border: 1px solid var(--wd-rand);
white-space: nowrap;
}
.vw-mini--geld { color: var(--wd-gold); border-color: rgb(var(--wd-gold-rgb) / .3); background: rgb(var(--wd-gold-rgb) / .07); }
.vw-mini--alt { color: var(--wd-fehler); border-color: rgb(var(--wd-fehler-rgb) / .3); background: rgb(var(--wd-fehler-rgb) / .06); }
/* Leerer Zustand — auch hier fuehren statt nur feststellen. */
.vw-leer-gross { text-align: center; padding: clamp(2rem,6vw,3.5rem) 1.5rem; }
.vw-leer-gross-symbol { font-size: 2.6rem; opacity: .45; margin-bottom: .6rem; }
.vw-leer-gross h3 { margin-bottom: .3em; }
.vw-leer-gross p { max-width: 46ch; margin-left: auto; margin-right: auto; }
/* ---------- Übernahme: Anfrage wird Kunde ----------
Der wichtigste Handgriff im ganzen Bereich. Bisher hätte man den
Namen, die E-Mail, das Paket und den Budgetrahmen von Hand in die
Kundenmaske abgetippt — vier Gelegenheiten für einen Tippfehler, und
einer davon (die E-Mail) ist später nicht mehr korrigierbar, weil sie
der Anmeldename ist. */
.vw-uebernahme {
border-radius: var(--wd-r);
padding: 1.1rem 1.2rem;
background: linear-gradient(180deg, rgb(var(--wd-blau-rgb) / .09), rgba(9,15,26,.6));
border: 1px solid var(--wd-rand-stark);
}
.vw-uebernahme h4 { margin: 0 0 .3rem; font-size: 1rem; color: var(--wd-text); }
.vw-uebernahme p { margin: 0 0 .8rem; font-size: .9rem; color: var(--wd-text-mid); }
.vw-uebernahme-vorschau {
display: grid; grid-template-columns: auto 1fr; gap: .25rem .8rem;
font-size: .88rem; margin-bottom: .9rem;
padding: .7rem .9rem; border-radius: 10px;
background: rgba(5,7,11,.5); border: 1px solid var(--wd-rand);
}
.vw-uebernahme-vorschau dt { color: var(--wd-text-soft); }
.vw-uebernahme-vorschau dd { margin: 0; color: var(--wd-text); overflow-wrap: anywhere; }
.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); }
/* =====================================================================
PROJEKTE UND AUFGABEN
Die vier Farben sind DIESELBEN wie im Portal und auf der Ablaufseite
(--wd-p1 bis --wd-p4). Das ist der eigentliche Punkt: Eine Farbe
bedeutet ueberall im Bereich dasselbe. Sonst waere sie Deko.
===================================================================== */
/* Ein Abzeichen an der Reiterleiste. Zeigt es nichts an, ist es GANZ
weg statt eine 0 zu zeigen -- eine Null, die man taeglich sieht,
wird zu Rauschen und man uebersieht irgendwann auch die 3. */
.vw-abzeichen {
display: inline-block; min-width: 19px; padding: 0 .32rem;
margin-left: .38rem; border-radius: 999px;
background: var(--wd-gold); color: #17120a;
font-size: .7rem; font-weight: 800; line-height: 19px; text-align: center;
vertical-align: text-top;
}
/* ---------- Fortschrittsbalken in vier Abschnitten ----------
Ein einzelner Balken sagt "60 %". Vier Abschnitte sagen zusaetzlich
WO die 60 % liegen -- ob die Gestaltung durch ist oder ob ueberall
ein bisschen angefangen wurde. Das ist beim Draufschauen die
nuetzlichere Information. */
.vw-fortschritt { display: flex; gap: 3px; height: 7px; margin: .55rem 0 .3rem; }
.vw-fs-teil {
flex: 1; border-radius: 999px; overflow: hidden;
background: rgba(255,255,255,.07);
position: relative;
}
.vw-fs-teil > i {
position: absolute; inset: 0 auto 0 0; display: block;
border-radius: 999px;
transition: width var(--wd-zeit) ease;
}
.vw-fs-teil[data-k="start"] > i { background: var(--wd-p1); }
.vw-fs-teil[data-k="gestaltung"] > i { background: var(--wd-p2); }
.vw-fs-teil[data-k="umsetzung"] > i { background: var(--wd-p3); }
.vw-fs-teil[data-k="abschluss"] > i { background: var(--wd-p4); }
.vw-fs-text { font-size: .78rem; color: var(--wd-text-soft); }
.vw-fs-text b { color: var(--wd-text); font-weight: 700; }
/* "Wartet auf dich"-Hinweis. Gold ist im ganzen Bereich fuer Hinweise
und Zahlung reserviert -- hier passt es: Es ist ein Hinweis, kein
Fehler. Rot waere zu laut fuer "der Kunde muss noch Texte schicken". */
.vw-wartet {
display: inline-flex; align-items: center; gap: .3rem;
padding: .1rem .5rem; border-radius: 999px;
background: rgb(var(--wd-gold-rgb) / .13); border: 1px solid rgb(var(--wd-gold-rgb) / .3);
color: var(--wd-gold); font-size: .76rem; font-weight: 600;
}
/* ---------- Aufgabenliste ---------- */
.vw-aufg-gruppe { margin-bottom: 1.1rem; }
.vw-aufg-kopf {
display: flex; align-items: baseline; gap: .5rem;
font-size: 0.8rem; letter-spacing: .13em; text-transform: uppercase;
margin-bottom: .5rem; padding-bottom: .35rem;
border-bottom: 1px solid var(--wd-rand);
}
.vw-aufg-kopf .vw-aufg-zahl { margin-left: auto; letter-spacing: 0; text-transform: none; font-size: .8rem; color: var(--wd-text-soft); }
.vw-aufg-gruppe[data-k="start"] .vw-aufg-kopf { color: var(--wd-p1); }
.vw-aufg-gruppe[data-k="gestaltung"] .vw-aufg-kopf { color: var(--wd-p2); }
.vw-aufg-gruppe[data-k="umsetzung"] .vw-aufg-kopf { color: var(--wd-p3); }
.vw-aufg-gruppe[data-k="abschluss"] .vw-aufg-kopf { color: var(--wd-p4); }
.vw-aufg {
display: grid; grid-template-columns: auto 1fr auto;
gap: .1rem .7rem; align-items: start;
padding: .55rem .7rem; margin-bottom: .3rem;
border-radius: 10px; border: 1px solid transparent;
}
.vw-aufg:hover { background: rgb(var(--wd-blau-rgb) / .045); border-color: var(--wd-rand); }
/* Das Kaestchen ist ein echter Knopf, kein Symbol mit Klickfläche
drumherum: Es muss ueber die Tastatur erreichbar sein und beim
Anspringen sichtbar umrandet werden. */
/* JEDES Kind bekommt Zeile UND Spalte ausdruecklich zugewiesen.
Ohne die Spaltenangabe geht es schief, und zwar auf eine Art, die man
nicht erraet: Das Raster platziert zuerst alle Elemente mit
FESTGELEGTER Zeile und erst danach die freien. Kaestchen und
Knopfgruppe haben beide "grid-row: 1 / span 2" -- sie wurden deshalb
zusammen nach vorne gesetzt (Spalte 1 und 2), und der Titel landete
als letzter in Spalte 3, ganz rechts. Im Bild sah das aus wie ein
falsch herum gebautes Formular. */
.vw-aufg-haken {
grid-row: 1 / span 2; grid-column: 1;
width: 24px; height: 24px; margin-top: .1rem; padding: 0;
border-radius: 6px; border: 2px solid var(--wd-rand-stark);
background: transparent; color: transparent;
cursor: pointer; font-size: .9rem; line-height: 1;
display: grid; place-items: center;
transition: all var(--wd-zeit-schnell) ease;
}
.vw-aufg-haken:hover { border-color: var(--wd-blau); }
.vw-aufg--erledigt .vw-aufg-haken { background: var(--wd-ok); border-color: var(--wd-ok); color: #06120c; }
.vw-aufg--arbeit .vw-aufg-haken { border-color: var(--wd-p2); color: var(--wd-p2); }
.vw-aufg--weg .vw-aufg-haken { border-style: dashed; color: var(--wd-text-soft); }
.vw-aufg-titel { grid-row: 1; grid-column: 2; font-size: .93rem; color: var(--wd-text); overflow-wrap: anywhere; }
.vw-aufg--erledigt .vw-aufg-titel { color: var(--wd-text-soft); text-decoration: line-through; text-decoration-thickness: 1px; }
.vw-aufg--weg .vw-aufg-titel { color: var(--wd-text-soft); text-decoration: line-through; opacity: .6; }
.vw-aufg-text { grid-row: 2; grid-column: 2; font-size: .82rem; color: var(--wd-text-soft); line-height: 1.5; }
.vw-aufg-rechts { grid-row: 1 / span 2; grid-column: 3; display: flex; align-items: center; gap: .3rem; }
/* Punkte, die auf den KUNDEN warten, muessen sich unterscheiden --
sonst liest man die Liste als reine Fortschrittsanzeige und
uebersieht, dass man selbst gar nicht am Zug ist. */
.vw-aufg--kunde { background: rgb(var(--wd-gold-rgb) / .055); }
.vw-aufg--kunde:hover { background: rgb(var(--wd-gold-rgb) / .1); }
.vw-aufg-wer {
font-size: 0.75rem; letter-spacing: .05em; text-transform: uppercase;
padding: .12rem .4rem; border-radius: 999px; white-space: nowrap;
background: rgb(var(--wd-gold-rgb) / .15); color: var(--wd-gold);
}
.vw-aufg-nicht {
display: inline-block; margin-left: .35rem;
font-size: 0.75rem; letter-spacing: .05em; text-transform: uppercase;
padding: .12rem .4rem; border-radius: 999px;
background: rgb(var(--wd-fehler-rgb) / .12); color: var(--wd-fehler);
}
.vw-mini {
padding: .2rem .45rem; border-radius: 6px; font-size: .82rem;
border: 1px solid var(--wd-rand); background: transparent;
color: var(--wd-text-soft); cursor: pointer; line-height: 1;
min-width: 30px; min-height: 30px;
}
.vw-mini:hover { color: var(--wd-text); border-color: var(--wd-rand-stark); }
/* =====================================================================
POSTFACH
===================================================================== */
.vw-pf { display: grid; gap: 1rem; }
@media (min-width: 900px) { .vw-pf { grid-template-columns: minmax(0,320px) minmax(0,1fr); align-items: start; } }
.vw-pf-eintrag {
display: block; width: 100%; text-align: left; cursor: pointer;
padding: .8rem .9rem; margin-bottom: .4rem;
border-radius: 10px; border: 1px solid var(--wd-rand);
background: var(--wd-glas); color: inherit;
}
.vw-pf-eintrag:hover { border-color: var(--wd-rand-stark); background: rgb(var(--wd-blau-rgb) / .05); }
.vw-pf-eintrag.vw-an { border-color: var(--wd-blau); background: rgb(var(--wd-blau-rgb) / .09); }
.vw-pf-eintrag--neu { border-left: 3px solid var(--wd-gold); }
.vw-pf-name { font-weight: 700; display: flex; align-items: center; gap: .4rem; }
.vw-pf-vorschau {
font-size: .84rem; color: var(--wd-text-soft); margin-top: .2rem;
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
overflow: hidden;
}
.vw-pf-zeit { font-size: 0.8rem; color: var(--wd-text-soft); margin-top: .3rem; }
/* Verlauf: Der Kunde links, wir rechts -- die im Nachrichtenverkehr
gewohnte Anordnung. Man erkennt den Absender an der Seite, noch
bevor man den Namen liest. */
.vw-pf-verlauf {
max-height: 60vh; overflow-y: auto;
padding: .3rem .5rem .3rem .3rem;
display: flex; flex-direction: column; gap: .6rem;
}
.vw-blase {
max-width: 82%; padding: .65rem .85rem; border-radius: 14px;
font-size: .92rem; line-height: 1.55; white-space: pre-wrap;
overflow-wrap: anywhere;
}
.vw-blase--kunde {
align-self: flex-start;
background: rgba(255,255,255,.055); border: 1px solid var(--wd-rand);
border-bottom-left-radius: 5px;
}
.vw-blase--wir {
align-self: flex-end;
background: rgb(var(--wd-blau-rgb) / .12); border: 1px solid var(--wd-rand-stark);
border-bottom-right-radius: 5px;
}
/* Interne Notiz: deutlich anders. Sie darf keinesfalls wie eine
gesendete Nachricht aussehen -- sonst haelt man sie fuer verschickt
oder, schlimmer, tippt Vertrauliches ins falsche Feld. */
.vw-blase--intern {
align-self: flex-end;
background: rgb(var(--wd-gold-rgb) / .08);
border: 1px dashed rgb(var(--wd-gold-rgb) / .45);
border-bottom-right-radius: 5px;
}
.vw-blase-kopf { font-size: .7rem; color: var(--wd-text-soft); margin-bottom: .25rem; }
.vw-blase--intern .vw-blase-kopf { color: var(--wd-gold); font-weight: 700; }
/* Der Schalter "interne Notiz" veraendert die Farbe des Schreibfeldes.
Ein Haekchen allein uebersieht man -- die Umgebung muss sich sichtbar
aendern, sonst landet irgendwann eine interne Bemerkung beim Kunden. */
.vw-pf-schreiben { margin-top: .8rem; padding: .8rem; border-radius: 14px; border: 1px solid var(--wd-rand); }
.vw-pf-schreiben--intern { border-color: rgb(var(--wd-gold-rgb) / .45); background: rgb(var(--wd-gold-rgb) / .05); }
.vw-pf-schalter { display: flex; align-items: center; gap: .45rem; font-size: .85rem; color: var(--wd-text-soft); cursor: pointer; }
.vw-pf-schalter input { width: 18px; height: 18px; accent-color: var(--wd-gold); }
</style>
</head>
<body class="wd">
<div id="wd-kopf" data-wd-kopf="minimal"></div>
<main id="wd-inhalt">
<!-- position:relative als Bezugspunkt fuer den Schmuckstreifen.
Ohne das haengt er sich an das naechste positionierte Elternteil
und landet irgendwo -- im schlimmsten Fall mitten in einer
Tabelle statt oben am Rand. -->
<section class="wd-abschnitt" style="padding-top:1.6rem; position:relative">
<!-- Rein dekorativ, deshalb aria-hidden: Es gibt hier nichts
vorzulesen, und ein leeres Element im Vorlesefluss ist nur
eine Stolperstelle. -->
<!-- Buehne und Zeigerlampe stecken zusammen in einem eigenen Raum.
Das ist keine Kosmetik, sondern noetig: Die Lampe arbeitet mit
mix-blend-mode und mischt sich sonst mit ALLEM, was in ihrem
Stapelkontext liegt -- auch mit den Kacheln, die eigentlich
darueber liegen. Beim Bauen lag dadurch ein gruenlicher Fleck
mitten auf einer Karte.
Der Raum traegt isolation: isolate. Damit endet die Mischung an
seiner Grenze, und die Lampe beleuchtet nur noch das Bild.
Rein dekorativ, deshalb aria-hidden: Es gibt nichts
vorzulesen, und leere Elemente im Vorlesefluss sind nur
Stolperstellen. -->
<div class="vw-raum" aria-hidden="true">
<div class="vw-buehne"></div>
<div class="vw-lampe"></div>
</div>
<div class="vw-flocken" aria-hidden="true"
><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i
></div>
<div class="wd-container">
<!-- ============ ANMELDUNG ============ -->
<div id="vw-anmeldung" class="wd-karte vw-login wd-motiv wd-motiv--wappen" 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">Übersicht</h1>
<div class="vw-tabs">
<button type="button" class="vw-filter-knopf vw-an" data-tab="uebersicht">Übersicht</button>
<button type="button" class="vw-filter-knopf" data-tab="anfragen">Anfragen<span
class="vw-abzeichen" id="vw-anf-zaehler" hidden>0</span></button>
<button type="button" class="vw-filter-knopf" data-tab="projekte">Projekte</button>
<button type="button" class="vw-filter-knopf" data-tab="kunden">Kunden</button>
<button type="button" class="vw-filter-knopf" data-tab="zahlungen">Zahlungen</button>
<button type="button" class="vw-filter-knopf" data-tab="postfach">Postfach<span
class="vw-abzeichen" id="vw-pf-zaehler" hidden>0</span></button>
</div>
<div class="vw-wer">
<!-- Der Lupenknopf steht neben dem Namen, nicht in der
Reiterleiste: Suchen ist keine Ansicht, sondern ein
Werkzeug -- es gehoert nicht zwischen die Kategorien. -->
<button type="button" class="vw-suche-auf" id="vw-suche-auf"
aria-label="Suche über alles öffnen">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" aria-hidden="true">
<circle cx="11" cy="11" r="7" /><path d="M20 20l-3.5-3.5" />
</svg>
Suchen <kbd>Strg K</kbd>
</button>
<span id="vw-name">—</span>
<button type="button" class="wd-btn wd-btn--still wd-btn--klein" id="vw-abmelden">Abmelden</button>
</div>
</div>
<!-- ============ ÜBERSICHT — das Cockpit ============
Die Verwaltung öffnete bisher mit der Anfragenliste. Eine
Liste ist eine Ablage: Sie zeigt, WAS es gibt, nicht was zu
TUN ist. Wer hereinkommt, musste durch fünf Reiter klicken,
um sich ein Bild zu machen — und übersah dabei zuverlässig
das eine, das seit Tagen liegt.
Der Aufbau folgt einer Frage in drei Stufen:
1. Was wartet auf MICH? (das ist heute meine Arbeit)
2. Was wartet auf den KUNDEN? (da bin ich raus)
3. Wie steht es ums Geld? (Umsatz, Gebühren, Offenes)
Erst danach Projekte und Verlauf. -->
<!-- ============ SUCHE ÜBER ALLES ============
Aufbau nach dem ARIA-Muster "Combobox mit Listbox-Popup"
(W3C Authoring Practices). Das ist kein Selbstzweck: Ein
Suchfeld, das Vorschläge einblendet, ist für einen
Screenreader ohne diese Auszeichnung stumm — man tippt, es
erscheinen Treffer, und angesagt wird nichts.
Die Rollen sind bewusst getrennt: Das EINGABEFELD behält den
Tastaturfokus die ganze Zeit (deshalb aria-activedescendant
statt echtem Fokuswechsel), die Liste daneben ist nur
Anzeige. Würde der Fokus in die Liste springen, könnte man
nicht weitertippen, um die Suche zu verfeinern. -->
<div class="vw-palette" id="vw-palette" hidden>
<div class="vw-palette-hg" data-palette-zu></div>
<div class="vw-palette-kasten" role="dialog" aria-modal="true"
aria-labelledby="vw-pal-titel">
<h2 id="vw-pal-titel" class="wd-visuell-versteckt">Suche über alles</h2>
<div class="vw-palette-kopf">
<svg class="vw-palette-lupe" width="18" height="18" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<circle cx="11" cy="11" r="7" /><path d="M20 20l-3.5-3.5" />
</svg>
<input type="text" id="vw-pal-feld" class="vw-palette-feld"
role="combobox" aria-expanded="false" aria-controls="vw-pal-liste"
aria-autocomplete="list" aria-label="Nach Anfragen, Kunden, Projekten oder Zahlungen suchen"
placeholder="Name, Nummer, E-Mail …" autocomplete="off" spellcheck="false" />
<button type="button" class="vw-palette-weg" data-palette-zu
aria-label="Suche schließen">Esc</button>
</div>
<!-- Der Zähler wird angesagt, ohne den Fokus zu stören.
aria-live="polite": Wer gerade tippt, wird nicht
unterbrochen, hört die Zahl aber nach dem Tippen. -->
<p class="vw-palette-zahl" id="vw-pal-zahl" role="status" aria-live="polite"></p>
<ul class="vw-palette-liste" id="vw-pal-liste" role="listbox"
aria-label="Suchergebnisse"></ul>
<p class="vw-palette-fuss">
<kbd>↑</kbd><kbd>↓</kbd> auswählen · <kbd>Enter</kbd> öffnen ·
<kbd>Esc</kbd> schließen · <kbd>Strg</kbd>+<kbd>K</kbd> von überall
</p>
</div>
</div>
<div id="vw-uebersicht-bereich">
<div class="vw-laedt" id="vw-ub-laedt">Wird geladen…</div>
<div id="vw-ub-inhalt" hidden>
<!-- Benachrichtigungen stehen GANZ OBEN.
Sie sind das Einzige, was seit dem letzten Hinsehen neu
ist -- alles andere sind Bestaende, die auch morgen noch
da sind. Steht das Neue unter dem Bestand, sieht man es
erst nach dem Scrollen, und der Sinn ist dahin. -->
<div id="vw-meldungen" hidden></div>
<!-- Benachrichtigung aufs Handy (26.08.2026).
Steht bewusst hier oben und nicht in einem
Einstellungsreiter: Der bisherige Discord-Weg war
eingebaut, aber nie eingerichtet, und man konnte das
nirgends sehen. Eine Benachrichtigung, deren Zustand
versteckt ist, ist keine -- solange nichts passiert,
sehen "funktioniert" und "kaputt" voellig gleich aus. -->
<div id="vw-push-karte" hidden></div>
<h2 class="vw-ub-h">Was auf dich wartet</h2>
<div class="vw-ub-gitter" id="vw-ub-beimir"></div>
<h2 class="vw-ub-h">Was beim Kunden liegt</h2>
<p class="wd-klein wd-leise vw-ub-hinweis">
Getrennt aufgeführt, weil es nicht dasselbe ist: Zwölf offene
Punkte sind entspannt, wenn elf davon beim Kunden liegen — und
ein Problem, wenn alle elf bei dir liegen.
</p>
<div class="vw-ub-gitter" id="vw-ub-beikunde"></div>
<h2 class="vw-ub-h">Geld</h2>
<div class="vw-ub-gitter" id="vw-ub-geld"></div>
<h2 class="vw-ub-h">Laufende Projekte</h2>
<div id="vw-ub-projekte"></div>
<h2 class="vw-ub-h">Zuletzt geschehen</h2>
<div id="vw-ub-verlauf"></div>
<!-- Zweiter Platz fuer den Benachrichtigungs-Zustand.
Ist alles in Ordnung, steht er HIER unten statt oben:
Eine Bestaetigung, dass nichts zu tun ist, gehoert nicht
an den Anfang. Oben bleibt nur, was Handlung verlangt.
Gemessen: Mit der Zeile oben begann die erste Kennzahl
erst bei 353 Punkten -- die Grenze im Handy-Test liegt
bei 300, und der Grund dafuer ist gut: Wer die
Verwaltung oeffnet, will Zahlen sehen, nicht Hinweise. -->
<div id="vw-push-karte-unten" hidden style="margin-top:1.4rem"></div>
</div>
</div>
<div class="vw-zahlen" id="vw-zahlen" hidden></div>
<div class="vw-filter" id="vw-filter" hidden></div>
<div class="wd-feld" style="margin-bottom:1rem" hidden>
<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" hidden><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>
<!-- Auskunft und Löschung (DSGVO Art. 15/17).
Steht bewusst hier unten und aufgeklappt-verborgen: Es ist
ein seltener Vorgang, aber wenn er kommt, läuft eine Frist
von einem Monat. Ein eigener Reiter wäre zu prominent für
etwas, das man vielleicht zweimal im Jahr braucht — ganz
weglassen hieße, im Ernstfall von Hand durch ein Dutzend
Tabellen zu suchen. -->
<details class="wd-karte" id="vw-ds" style="margin-top:1.6rem">
<summary style="cursor:pointer;font-weight:700;min-height:46px;display:flex;align-items:center">
Auskunft und Löschung (DSGVO)
</summary>
<p class="wd-klein wd-leise" style="margin-top:.8rem">
Für Anfragen nach Art. 15 („Welche Daten haben Sie über mich?“)
und Art. 17 („Bitte löschen Sie meine Daten“). Beides ist
binnen eines Monats zu beantworten.
</p>
<div class="wd-feld" style="margin-top:1rem">
<label for="vw-ds-mail">E-Mail-Adresse der Person</label>
<input type="email" id="vw-ds-mail" autocomplete="off"
placeholder="[email protected]" />
</div>
<div class="wd-btn-reihe" style="margin-top:.8rem">
<button type="button" class="wd-btn wd-btn--klein" id="vw-ds-auskunft">
Auskunft erstellen
</button>
<button type="button" class="wd-btn wd-btn--zweit wd-btn--klein" id="vw-ds-vorschau">
Was würde eine Löschung betreffen?
</button>
</div>
<div id="vw-ds-ergebnis" style="margin-top:1rem"></div>
</details>
</div>
<!-- ============ PROJEKTE ============ -->
<div id="vw-projekte-bereich" hidden>
<p class="wd-klein wd-leise" style="margin-bottom:1rem">
Jedes Projekt hat eine Aufgabenliste in den vier Abschnitten des
Ablaufs. Abgehakte Punkte sieht der Kunde sofort in seinem Portal —
das ersetzt die meisten „Wie weit seid ihr?“-Nachrichten.
</p>
<div id="vw-projekte-liste"><div class="vw-laedt">Wird geladen…</div></div>
</div>
<!-- ============ ZAHLUNGEN ============ -->
<div id="vw-zahlungen-bereich" hidden>
<div id="vw-pp-stand"></div>
<h2 style="font-size:1.15rem;margin-top:1.6rem">Zahlungen</h2>
<div id="vw-zahlungen-liste"></div>
</div>
<!-- ============ POSTFACH ============ -->
<div id="vw-postfach-bereich" hidden>
<p class="wd-klein wd-leise" style="margin-bottom:1rem">
Nachrichten, die zu keinem bestimmten Projekt gehören — Fragen,
Kleinigkeiten, alles Kurze. Auch Kunden ohne laufendes Projekt
können hier schreiben.
</p>
<div class="vw-pf">
<div id="vw-pf-liste"><div class="vw-laedt">Wird geladen…</div></div>
<div id="vw-pf-verlauf" class="wd-karte">
<!-- Bewusst OHNE Richtungsangabe.
Vorher stand hier "Waehle links einen Verlauf aus".
Auf dem Handy gibt es kein links: Dort liegt die Liste
UEBER diesem Kasten, nicht daneben. Der Satz schickte
den Leser also an eine Stelle, an der nichts ist.
Richtungswoerter setzen ein Layout voraus, das es nur
auf einem breiten Bildschirm gibt -- und sie helfen
ohnehin niemandem, der die Seite vorgelesen bekommt. -->
<p class="wd-leise" style="text-align:center;padding:2rem 0">
Tippe einen Verlauf an, um ihn hier zu lesen.
</p>
</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=20260823wd20"></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;
/* WOHER der Schlüssel stammt. Entscheidend, siehe ausweisErneuern():
"ausweis" = von der Zugangswand geholt, erneuerbar
"code" = eigene Anmeldung mit dem Zugangscode, NICHT erneuerbar */
var tokenQuelle = 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 ABBRUCH_TEXT = {
keine_zahlung: "nicht gezahlt", kunde_abgesprungen: "Kunde abgesprungen",
keine_mitwirkung: "keine Mitwirkung", einvernehmlich: "einvernehmlich",
mein_ruecktritt: "mein Rücktritt", sonstiges: "sonstiges",
};
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");
/* Die HERKUNFT wird mitgespeichert, nicht nur der Schluessel.
Vorher ueberlebte nur der Schluessel selbst ein Neuladen, die
Herkunft nicht. Damit galt eine gueltige Code-Sitzung nach jedem F5
als "unbekannt": Beim naechsten 401 holte die Seite einen Ausweis,
ueberschrieb damit den funktionierenden Code-Schluessel -- und wenn
der Ausweis abgelehnt wird, landet man in der Anmeldemaske. Der
Schutz "eine Code-Sitzung wird nie ersetzt" war also genau ab dem
ersten Neuladen wirkungslos.
Gefunden am 23.08.2026 auf die Rueckmeldung hin, dass beim Wechsel
der Kategorie wieder nach dem Code gefragt wird. */
var QUELLE_SCHLUESSEL = TOKEN_SCHLUESSEL + "-quelle";
function tokenLesen() {
try { return sessionStorage.getItem(TOKEN_SCHLUESSEL); } catch (e) { return null; }
}
function quelleLesen() {
try { return sessionStorage.getItem(QUELLE_SCHLUESSEL); } catch (e) { return null; }
}
function tokenSchreiben(t, quelle) {
try {
if (t) {
sessionStorage.setItem(TOKEN_SCHLUESSEL, t);
if (quelle) sessionStorage.setItem(QUELLE_SCHLUESSEL, quelle);
} else {
sessionStorage.removeItem(TOKEN_SCHLUESSEL);
sessionStorage.removeItem(QUELLE_SCHLUESSEL);
}
} catch (e) { /* egal */ }
}
/* ---------------------------------------------------------------------
API-Aufruf MIT selbsttätiger Erneuerung des Ausweises
Warum das nötig ist (Rückmeldung 23.08.2026: "wieso werd ich auch
immer meinen zugangscode gefragt wenn ich die kategorie wechseln
tue das ist schwachsin"):
Der Ausweis für die interne Schnittstelle gilt 15 Minuten. Danach
antwortete jede Anfrage mit 401, und die Verwaltung warf zurück auf
die Codeeingabe — beim blossen Wechsel eines Reiters.
Das war doppelt falsch. Erstens ist die Zugangssitzung selbst
8 Stunden gültig; der Code war also gar nicht nötig, ein frischer
Ausweis hätte genügt. Zweitens ist ein Rauswurf mitten in der Arbeit
die härteste denkbare Reaktion auf ein Problem, das sich unsichtbar
lösen lässt.
Jetzt: Bei 401 wird EINMAL ein neuer Ausweis geholt und die Anfrage
wiederholt. Nur wenn auch das scheitert, kommt die Codeeingabe —
dann ist die Sitzung wirklich abgelaufen.
Ein Wiederholen ist hier unbedenklich, auch bei Absendungen: Eine
mit 401 abgewiesene Anfrage hat serverseitig nichts bewirkt. */
var ausweisLaeuft = null;
async function ausweisErneuern() {
/* ⚠️ NUR erneuern, wenn der Schlüssel ÜBERHAUPT vom Ausweis kommt.
Sonst richtet die Erneuerung Schaden an: Wer sich mit dem
Zugangscode angemeldet hat, besitzt eine gültige Team-Sitzung. Ein
401 aus irgendeinem Grund würde diese funktionierende Sitzung durch
einen Ausweis ERSETZEN — und taugt der nichts, ist der Zugang
danach vollständig kaputt statt nur kurz gestört.
Genau das ist am 23.08.2026 passiert: Die Zugangswand stellt
Ausweise aus, der interne Dienst weist sie ab (die beiden Server
benutzen nicht dasselbe WEBDESIGN_API_SECRET). Vorher war das
harmlos — die Verwaltung fiel auf die Codeeingabe zurück und alles
lief. Mit der Erneuerung wurde daraus ein Totalausfall: Alle zehn
Minuten ersetzte sie die funktionierende Sitzung durch einen
untauglichen Ausweis.
Eine Reparatur, die den Normalfall verschlechtert, ist keine. */
if (tokenQuelle === "code") return null;
if (!ausweisLaeuft) {
ausweisLaeuft = ausweisHolen().finally(function () { ausweisLaeuft = null; });
}
var neu = await ausweisLaeuft;
if (neu) { token = neu; tokenQuelle = "ausweis"; tokenSchreiben(neu, "ausweis"); }
return neu;
}
function rufAuf(pfad, optionen) {
var opt = Object.assign({}, optionen || {});
opt.headers = Object.assign({ Authorization: "Bearer " + token }, opt.headers || {});
return window.WD.api(pfad, opt);
}
async function api(pfad, optionen) {
try {
return await rufAuf(pfad, optionen);
} catch (fehler) {
if (fehler.status !== 401) throw fehler;
var neu = await ausweisErneuern();
if (!neu) throw fehler; // Sitzung wirklich abgelaufen
return await rufAuf(pfad, optionen);
}
}
/* ---------------------------------------------------------------------
VORSORGLICHE ERNEUERUNG
Die Wiederholung oben rettet jeden Fall — aber sie tritt erst ein,
NACHDEM eine Anfrage abgewiesen wurde. Das kostet einen unnötigen
Umlauf, und in der Zwischenzeit steht ein Ladehinweis auf dem Schirm.
Besser ist, gar nicht erst ablaufen zu lassen. Der Ausweis gilt
15 Minuten, erneuert wird alle 10 — mit Abstand, damit ein langsamer
Netzzugang nicht ins Zeitfenster hineinläuft.
Und beim Zurückkommen: War der Rechner zwischendurch zu, ist der
Ausweis mit Sicherheit abgelaufen. Dann soll der erste Klick sofort
funktionieren und nicht erst über einen Fehlschlag gehen. */
var ERNEUERN_MS = 10 * 60 * 1000;
var letzteErneuerung = Date.now();
function erneuerungPlanen() {
if (tokenQuelle === "code") return; // eine Code-Sitzung braucht das nicht
setInterval(function () {
if (!token || tokenQuelle === "code") return;
if (document.hidden) return; // im Hintergrund unnötig
letzteErneuerung = Date.now();
ausweisErneuern();
}, ERNEUERN_MS);
document.addEventListener("visibilitychange", function () {
if (document.hidden || !token || tokenQuelle === "code") return;
/* Nur wenn wirklich Zeit vergangen ist — beim schnellen Hin- und
Herwechseln zwischen zwei Fenstern wäre jedes Mal ein neuer
Ausweis reine Verschwendung. */
if (Date.now() - letzteErneuerung < 60 * 1000) return;
letzteErneuerung = Date.now();
ausweisErneuern();
});
}
/* ---------------- 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;
bereichAusAdresse();
}
/* ---------------------------------------------------------------------
?bereich=... AUS DER ADRESSE ÖFFNEN
Gebraucht an zwei Stellen, die das bisher nur VERSPROCHEN haben:
- die Verknüpfungen der installierten App ("Anfragen", "Projekte",
"Postfach" beim langen Antippen des Symbols)
- der Verweis in einer Push-Meldung, der zur betreffenden Anfrage
führen soll
Beide zeigten auf "?bereich=anfragen" — ausgewertet hat den Parameter
bisher niemand. Man landete immer auf der Übersicht, ohne dass etwas
kaputt aussah. Genau die Sorte stiller Wirkungslosigkeit, gegen die
hier an anderer Stelle schon einiges gebaut wurde.
Umgesetzt über einen ausgelösten Klick auf den vorhandenen Reiter,
nicht über nachgebaute Logik: Am Reiter hängen Signatur, Leuchtbalken,
das Nachladen der Daten und das Verschieben des Wischstreifens auf dem
Handy. Ein zweiter Weg dorthin wäre ein zweiter Weg, der irgendwann
nicht mehr mitgepflegt wird.
Läuft NACH der Anmeldung, denn vorher gibt es die Reiter nicht.
--------------------------------------------------------------------- */
function bereichAusAdresse() {
try {
var wunsch = new URLSearchParams(window.location.search).get("bereich");
if (!wunsch) return;
var knopf = document.querySelector('[data-tab="' + wunsch.replace(/[^a-z]/gi, "") + '"]');
if (knopf && !knopf.classList.contains("vw-an")) knopf.click();
} catch (e) { /* alter Browser: dann eben die Übersicht */ }
}
/* Nach aussen verfuegbar machen — gleiches Vorgehen wie bei
window.vwBalkenSetzen weiter unten.
Grund ist hier die Prüfbarkeit: Der Aufruf sitzt hinter der
Anmeldung, und ein Test, der sich erst anmelden müsste, prüft am
Ende die Anmeldung statt die Sache. Ohne diesen Zugang war meine
eigene Prüfung wirkungslos und meldete "geöffnet wurde: uebersicht"
— sie hatte die Funktion schlicht nie erreicht. */
window.vwBereichAusAdresse = bereichAusAdresse;
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;
tokenQuelle = "code";
tokenSchreiben(token, "code");
document.getElementById("vw-name").textContent = antwort.name || antwort.actor || "angemeldet";
zeigeBereich();
ladeUebersicht();
/* Der Postfach-Zaehler direkt nach der Anmeldung. Ohne das sieht man
neue Nachrichten erst, wenn man den Reiter zufaellig anklickt --
also womoeglich tagelang nicht. */
zaehlerHolen();
erneuerungPlanen();
} 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 (e) {
/* ABMELDEN MUSS AUCH DIE ZUGANGSWAND BEENDEN.
Vorher tat der Knopf scheinbar nichts (Rueckmeldung 23.08.2026:
"der abmelde button klappt auch nicht auf dem handy"). Er beendete
die Team-Sitzung, loeschte den Schluessel und lud neu -- aber das
Merkmal der Zugangswand blieb im Browser stehen. Beim Neuladen holt
sich die Seite damit sofort wieder einen Ausweis und ist nach einer
Zehntelsekunde erneut angemeldet. Auf dem Handy faellt es mehr auf,
weil man das kurze Aufblitzen dort eher sieht -- der Fehler war auf
beiden Geraeten derselbe.
Deshalb jetzt BEIDE Sitzungen beenden. Das ist auch die ehrliche
Bedeutung des Wortes: Wer sich abmeldet, will draussen sein und
nicht in einem halb offenen Zustand zurueckbleiben.
Die Reihenfolge ist wichtig: erst die Zugangswand, dann neu laden.
Umgekehrt waere die Seite schon weg, bevor der Aufruf hinausgeht. */
window.WD.knopfSperren(e.target, true, "Wird abgemeldet…");
/* Beide Aufrufe einzeln absichern: Faellt einer aus, soll der andere
trotzdem laufen. Ein halbes Abmelden waere schlimmer als keins --
man haelte sich fuer abgemeldet und waere es nicht. */
try { await api("/auth/logout", { method: "POST", body: {} }); } catch (f) { /* egal */ }
try {
await fetch("/webdesign/zugang-beenden", {
method: "POST", credentials: "same-origin",
headers: { "content-type": "application/json" }, body: "{}",
});
} catch (f) { /* egal */ }
token = null; tokenQuelle = null; tokenSchreiben(null);
try { sessionStorage.removeItem("wd-sitzung-aktiv"); } catch (f) { /* egal */ }
/* Zur Zugangswand statt neu laden. Ein Neuladen der Verwaltung
zeigte nur die Codeeingabe -- und liesse offen, ob man nun
draussen ist oder nicht. */
location.href = "/webdesign/zugang.html";
});
/* ---------------- Liste ---------------- */
/* =================================================================
ÜBERSICHT — das Cockpit
Zwei Entscheidungen tragen diese Ansicht:
1. JEDE KACHEL IST EIN KNOPF. Eine Zahl, die man nur anschauen kann,
ist Dekoration. "3 ungelesene Nachrichten" ist erst dann
nützlich, wenn ein Klick darauf im Postfach landet. Deshalb sind
es <button>-Elemente und keine <div>s — damit sie auch mit der
Tastatur erreichbar sind und ein Screenreader sie als bedienbar
ansagt.
2. DAS ALTER FÄRBT, NICHT DIE MENGE. Vier neue Anfragen sind kein
Problem. Eine Anfrage, die seit sechs Tagen liegt, ist eines.
Deshalb entscheidet das Alter des ÄLTESTEN Vorgangs über die
Warnfarbe — nicht die Anzahl.
================================================================= */
/* Alter in Tagen. Bewusst über Kalendertage statt über 24-Stunden-
Schritte: Wer Montag früh eine Anfrage bekommt, hat sie Dienstag
früh "seit gestern" liegen — auch wenn erst 23 Stunden vergangen
sind. Das entspricht dem, wie ein Kunde die Wartezeit erlebt. */
function tageSeit(iso) {
if (!iso) return null;
var d = new Date(iso);
if (isNaN(d)) return null;
var a = new Date(d.getFullYear(), d.getMonth(), d.getDate());
var h = new Date();
h = new Date(h.getFullYear(), h.getMonth(), h.getDate());
return Math.round((h - a) / 864e5);
}
function altersText(iso) {
var t = tageSeit(iso);
if (t === null) return "";
if (t <= 0) return "seit heute";
if (t === 1) return "seit gestern";
return "seit " + t + " Tagen";
}
/* Ab wann es dringend wird. Drei Werktage sind die Grenze, unterhalb
derer sich niemand vergessen fühlt — darüber schon. */
function dringlichkeit(iso, grenze) {
var t = tageSeit(iso);
if (t === null) return "";
if (t >= (grenze || 3) * 2) return " vw-ub-kachel--rot";
if (t >= (grenze || 3)) return " vw-ub-kachel--warn";
return "";
}
function kachel(o) {
var leer = !o.zahl || o.zahl === "0" || o.zahl === 0;
/* Eine Kachel mit Null ist eine gute Nachricht und darf sich
zurücknehmen — sie soll den Blick nicht von dem wegziehen, was
wirklich offen ist. */
return '<button type="button" class="vw-ub-kachel' +
(leer ? " vw-ub-kachel--leer" : (o.ton || "")) + '"' +
(o.ziel ? ' data-ziel="' + s(o.ziel) + '"' : ' disabled') +
' aria-label="' + s(o.titel + ": " + o.zahl + (o.unten ? ", " + o.unten : "")) + '">' +
'<span class="vw-ub-zahl">' + s(String(o.zahl)) + '</span>' +
'<span class="vw-ub-titel">' + s(o.titel) + '</span>' +
(o.unten ? '<span class="vw-ub-unten">' + s(o.unten) + '</span>' : "") +
'</button>';
}
var uebersichtGeladen = 0;
/* =====================================================================
BENACHRICHTIGUNG AUFS HANDY (26.08.2026)
Die Karte beantwortet EINE Frage: Würde ich es mitbekommen, wenn
jetzt eine Anfrage käme? Alles andere ist Beiwerk.
Vier Zustände, und jeder bekommt einen anderen Text — denn was zu
tun ist, unterscheidet sich völlig:
1. Der Browser kann kein Push (z.B. iPhone-Safari im Reiter,
ohne dass die Seite auf dem Home-Bildschirm liegt).
2. Push ist möglich, aber noch nicht eingeschaltet.
3. Eingeschaltet und es kommt an.
4. Der Nutzer hat es im Browser dauerhaft verboten. Dann hilft
kein Knopf mehr — das lässt sich nur in den
Browsereinstellungen zurücknehmen, und genau das muss dort
stehen, statt einen Knopf anzubieten, der nichts bewirkt.
===================================================================== */
var pushKarteBeschaeftigt = 0;
function pushMoeglich() {
return ("serviceWorker" in navigator) && ("PushManager" in window)
&& ("Notification" in window);
}
async function ladePushKarte() {
var kasten = document.getElementById("vw-push-karte");
if (!kasten) return;
var d;
try {
d = await api("/webdesign/admin/push/stand");
} catch (fehler) {
/* Der Server ist älter als diese Seite — kein Grund, die
Übersicht mit einer Fehlermeldung zu belasten. */
kasten.hidden = true;
return;
}
if (!d || !d.ok) { kasten.hidden = true; return; }
/* Ist DIESER Browser angemeldet? Das ist nicht dasselbe wie "es gibt
angemeldete Geräte": Am Rechner eingeschaltet zu haben, sagt nichts
darüber, ob das Handy meldet. */
var diesesGeraet = 0;
if (pushMoeglich()) {
try {
var reg = await navigator.serviceWorker.getRegistration("/webdesign/");
var abo = reg && await reg.pushManager.getSubscription();
diesesGeraet = !!abo;
} catch (e) { diesesGeraet = 0; }
}
zeichnePushKarte(d, diesesGeraet);
}
/* ⚠️ ALLE ZUSTAENDE SIND EINE ZEILE (geaendert 26.08.2026).
Zuerst war jeder Zustand eine ausgewachsene Karte mit
Erklaerungstext. Auf dem Handy schob das die erste Kennzahl auf 513
Punkte nach unten -- man sah beim Oeffnen zuerst einen Hinweis und
musste scrollen, um die Arbeit zu sehen. Gefunden vom Handy-Test:
"die erste Zahl steht im oberen Drittel" (Grenze 300px).
Der erste Versuch machte nur den GUTEN Fall schmal. Das half
nichts, denn im Pruefbrowser sind Benachrichtigungen blockiert --
also griff weiter der Zweig mit der grossen Karte. Eine Aenderung,
die nur den Fall behebt, den man gerade vor Augen hat, ist keine.
Jetzt: eine Zeile mit Punkt und Zustand, die Erklaerung erst beim
Aufklappen. Der Zustand ist eine Randnotiz -- die Zahlen sind die
Arbeit. */
function pushZeile(farbe, kurz, inhalt) {
return '<details class="wd-karte" style="padding:.5rem .8rem;margin-bottom:.9rem">' +
'<summary style="cursor:pointer;min-height:46px;display:flex;align-items:center;' +
'gap:.5rem;font-size:.88rem">' +
'<span style="color:' + farbe + '">●</span><span>' + kurz + '</span>' +
'</summary>' + inhalt + '</details>';
}
function zeichnePushKarte(d, diesesGeraet) {
var kasten = document.getElementById("vw-push-karte");
var erlaubnis = ("Notification" in window) ? Notification.permission : "unsupported";
if (!pushMoeglich()) {
kasten.innerHTML = pushZeile("var(--wd-rand)", "Benachrichtigung hier nicht möglich",
'<p class="wd-klein wd-leise" style="margin:.6rem 0 0">Dieser Browser kann keine ' +
'Benachrichtigungen empfangen. Auf dem iPhone geht es erst, wenn die Seite über ' +
'„Teilen → Zum Home-Bildschirm“ installiert und von dort geöffnet wird.</p>');
kasten.hidden = false;
return;
}
if (erlaubnis === "denied") {
kasten.innerHTML = pushZeile("var(--wd-lila-hell)", "Benachrichtigung blockiert",
'<p class="wd-klein" style="margin:.6rem 0 0">Für diese Seite im Browser blockiert. ' +
'Ein Knopf hilft hier nicht weiter — das lässt sich nur in den Browsereinstellungen ' +
'zurücknehmen (Schloss-Symbol neben der Adresse → Benachrichtigungen → Zulassen).</p>');
kasten.hidden = false;
return;
}
if (diesesGeraet && d.wirktMeldung) {
/* ⚠️ IM GUTEN FALL NUR EINE ZEILE (geändert 26.08.2026).
Zuerst stand hier dieselbe große Karte wie in den anderen
Fällen. Auf dem Handy schob sie die erste Kennzahl auf 513
Punkte nach unten — man sah beim Öffnen zuerst die Meldung
"alles in Ordnung" und musste scrollen, um die Arbeit zu
sehen. Ein Prüfpunkt aus dem Handy-Test hat das gefunden: Der
Kopfbereich darf nicht den halben Bildschirm fressen.
Ein Zustand, der in Ordnung ist, braucht keine Karte. Er
braucht eine Zeile, die man wegsieht — und die Bedienknöpfe
erst, wenn man sie aufklappt. */
/* Guter Zustand: nach UNTEN. Oben bliebe er im Weg. */
var unten = document.getElementById("vw-push-karte-unten");
kasten.hidden = true;
kasten.innerHTML = "";
if (!unten) return;
unten.innerHTML = pushZeile("var(--wd-aqua)",
"Benachrichtigung ist an" +
(d.geraete > 1 ? " (" + s(String(d.geraete)) + " Geräte)" : "") +
(d.geraeteMitFehlern
? ' · <span class="wd-leise">' + s(String(d.geraeteMitFehlern)) + " ohne Antwort</span>"
: ""),
'<p class="wd-klein wd-leise" style="margin:.6rem 0 0">Dieses Gerät bekommt eine ' +
'Meldung, sobald eine neue Anfrage eingeht.</p>' +
'<div class="wd-btn-reihe" style="margin-top:.6rem">' +
'<button type="button" class="wd-btn wd-btn--zweit wd-btn--klein" id="vw-push-test">Probemeldung schicken</button>' +
'<button type="button" class="wd-btn wd-btn--zweit wd-btn--klein" id="vw-push-aus">Auf diesem Gerät ausschalten</button>' +
'</div>');
unten.hidden = false;
verdrahtePushKnoepfe();
return;
} else {
kasten.innerHTML = pushZeile("var(--wd-lila-hell)", "Benachrichtigung ist aus",
'<p class="wd-klein" style="margin:.6rem 0 0">Neue Anfragen fallen im Moment nur auf, ' +
'wenn du hier nachsiehst. Mit einer Meldung aufs Handy bekommst du es sofort mit — ' +
'im Text stehen nur Nummer und Paket, keine Kundendaten.</p>' +
'<div class="wd-btn-reihe" style="margin-top:.6rem">' +
'<button type="button" class="wd-btn wd-btn--klein" id="vw-push-an">Auf diesem Gerät einschalten</button>' +
'</div>');
}
kasten.hidden = false;
verdrahtePushKnoepfe();
}
/* ⚠️ HIESS ZUERST NUR "kachel" — UND HAT DAMIT DIE UEBERSICHT
ZERSTOERT (26.08.2026, sofort behoben).
Weiter oben in derselben Datei gibt es bereits eine Funktion
kachel(o), die die Kacheln der Uebersicht baut. JavaScript kennt
keine Ueberladung: Steht spaeter eine zweite Funktion desselben
Namens im selben Gueltigkeitsbereich, gewinnt sie -- ohne Warnung,
ohne Fehler.
Danach bekam jede Uebersichtskachel mein Objekt als ersten
Parameter gereicht und zeigte "[object Object]" und "undefined"
an. Die Push-Karte funktionierte dabei einwandfrei, was die Suche
zusaetzlich in die Irre fuehrt: Der sichtbare Schaden lag weit weg
von seiner Ursache.
Deshalb traegt sie jetzt einen Namen, der sagt, wozu sie gehoert. */
function pushKachel(titel, inhalt, art) {
var farbe = art === "gut" ? "var(--wd-aqua)"
: art === "warn" ? "var(--wd-lila-hell)" : "var(--wd-rand)";
return '<div class="wd-karte" style="padding:1rem 1.1rem;margin-bottom:1.1rem;' +
'border-left:3px solid ' + farbe + '">' +
'<h3 style="font-size:1rem;margin:0 0 .4rem">' + s(titel) + '</h3>' + inhalt + '</div>';
}
function verdrahtePushKnoepfe() {
var an = document.getElementById("vw-push-an");
if (an) an.addEventListener("click", pushEinschalten);
var aus = document.getElementById("vw-push-aus");
if (aus) aus.addEventListener("click", pushAusschalten);
var test = document.getElementById("vw-push-test");
if (test) test.addEventListener("click", pushProbe);
}
/* Der Base64-Schlüssel muss als Byte-Feld übergeben werden. Ein
Zeichenkettenwert wird ohne Fehlermeldung abgelehnt — der Aufruf
schlägt einfach fehl und nennt keinen Grund. */
function schluesselZuBytes(text) {
var voll = (text + "=".repeat((4 - text.length % 4) % 4))
.replace(/-/g, "+").replace(/_/g, "/");
var roh = atob(voll);
var feld = new Uint8Array(roh.length);
for (var i = 0; i < roh.length; i++) feld[i] = roh.charCodeAt(i);
return feld;
}
async function pushEinschalten() {
if (pushKarteBeschaeftigt) return;
pushKarteBeschaeftigt = 1;
var knopf = document.getElementById("vw-push-an");
if (knopf) { knopf.disabled = true; knopf.textContent = "Einen Moment…"; }
try {
var erlaubnis = await Notification.requestPermission();
if (erlaubnis !== "granted") { await ladePushKarte(); return; }
var k = await api("/webdesign/admin/push/schluessel");
if (!k || !k.ok) throw new Error(k && k.error ? k.error : "Kein Schlüssel vom Server.");
var reg = await navigator.serviceWorker.register("/webdesign/sw.js", { scope: "/webdesign/" });
await navigator.serviceWorker.ready;
var abo = await reg.pushManager.subscribe({
/* Ohne diese Angabe verweigern alle Browser die Anmeldung: Sie
verlangen die Zusage, dass jede Nachricht auch sichtbar wird. */
userVisibleOnly: true,
applicationServerKey: schluesselZuBytes(k.schluessel),
});
await api("/webdesign/admin/push/anmelden", {
method: "POST",
body: { abo: abo.toJSON(), bezeichnung: geraeteName() },
});
} catch (fehler) {
var kasten = document.getElementById("vw-push-karte");
if (kasten) {
kasten.innerHTML = pushKachel("Einschalten hat nicht geklappt",
'<p class="wd-klein">' + s((fehler && fehler.message) || "Unbekannter Fehler") + '</p>' +
'<div class="wd-btn-reihe" style="margin-top:.7rem">' +
'<button type="button" class="wd-btn wd-btn--zweit wd-btn--klein" id="vw-push-an">Nochmal versuchen</button>' +
'</div>', "warn");
verdrahtePushKnoepfe();
}
return;
} finally {
pushKarteBeschaeftigt = 0;
}
await ladePushKarte();
}
async function pushAusschalten() {
if (pushKarteBeschaeftigt) return;
pushKarteBeschaeftigt = 1;
try {
var reg = await navigator.serviceWorker.getRegistration("/webdesign/");
var abo = reg && await reg.pushManager.getSubscription();
if (abo) {
/* Erst dem Server sagen, dann im Browser abmelden. Andersherum
wäre die Adresse schon weg, wenn der Server sie noch bräuchte
— und der Eintrag bliebe als Karteileiche zurück. */
await api("/webdesign/admin/push/abmelden", {
method: "POST", body: { endpunkt: abo.endpoint },
}).catch(function () {});
await abo.unsubscribe();
}
} catch (e) { /* nicht schlimm — die Anzeige unten korrigiert sich */ }
pushKarteBeschaeftigt = 0;
await ladePushKarte();
}
async function pushProbe() {
var knopf = document.getElementById("vw-push-test");
if (knopf) { knopf.disabled = true; knopf.textContent = "Wird geschickt…"; }
try {
var r = await api("/webdesign/admin/push/test", { method: "POST", body: {} });
if (knopf) {
var e = r && r.ergebnis;
knopf.textContent = (e && e.gesendet)
? "An " + e.gesendet + " Gerät(e) geschickt"
: "Nichts erreicht";
}
} catch (fehler) {
if (knopf) knopf.textContent = "Hat nicht geklappt";
}
setTimeout(function () {
if (knopf) { knopf.disabled = false; knopf.textContent = "Probemeldung schicken"; }
}, 3500);
}
/* Etwas Wiedererkennbares für die Geräteliste. Ohne das steht dort nur
eine 200 Zeichen lange Adresse. */
function geraeteName() {
var u = navigator.userAgent;
var browser = /Edg\//.test(u) ? "Edge" : /Chrome\//.test(u) ? "Chrome"
: /Firefox\//.test(u) ? "Firefox" : /Safari\//.test(u) ? "Safari" : "Browser";
var system = /Android/.test(u) ? "Android" : /iPhone|iPad/.test(u) ? "iPhone/iPad"
: /Windows/.test(u) ? "Windows" : /Mac OS/.test(u) ? "Mac" : "";
return browser + (system ? " auf " + system : "");
}
/* =====================================================================
AUSKUNFT UND LÖSCHUNG (DSGVO Art. 15/17)
Drei Schritte, und die Reihenfolge ist Absicht: sehen, Vorschau,
erst dann ausführen. Eine Löschung ist endgültig — sie gehört nicht
hinter einen Knopf, der sofort zuschlägt.
Die Auskunft wird als Datei zum Herunterladen angeboten, nicht nur
angezeigt. Man muss sie der Person schicken können, und zwar
vollständig; abtippen wäre der sicherste Weg, etwas zu vergessen.
===================================================================== */
function dsMail() {
var f = document.getElementById("vw-ds-mail");
return f ? f.value.trim() : "";
}
function dsMeldung(html, art) {
var z = document.getElementById("vw-ds-ergebnis");
if (!z) return;
var farbe = art === "warn" ? "var(--wd-lila-hell)"
: art === "gut" ? "var(--wd-aqua)" : "var(--wd-rand)";
z.innerHTML = '<div style="border-left:3px solid ' + farbe +
';padding:.8rem 1rem;background:rgb(255 255 255 / .03);border-radius:8px">' + html + '</div>';
}
async function dsAuskunft() {
var mail = dsMail();
if (!mail) return dsMeldung("Bitte zuerst eine E-Mail-Adresse eintragen.", "warn");
dsMeldung("Wird zusammengetragen…");
var d;
try {
d = await api("/webdesign/admin/datenschutz/auskunft", { method: "POST", body: { email: mail } });
} catch (fehler) {
return dsMeldung("Nicht möglich: " + s((fehler && fehler.message) || "unbekannter Fehler") +
(fehler && fehler.status === 404
? '<br /><span class="wd-klein wd-leise">Der Server kennt diesen Zugang noch nicht — er braucht einen Neustart mit dem neuen Stand.</span>'
: ""), "warn");
}
if (!d || !d.ok) return dsMeldung("Nicht möglich: " + s((d && d.error) || "unbekannt"), "warn");
if (!d.datensaetze) {
return dsMeldung("Zu <strong>" + s(mail) + "</strong> ist nichts gespeichert.", "gut");
}
var liste = (d.bereiche || []).map(function (b) {
return "<li>" + s(String(b.anzahl)) + "× " + s(b.was) +
(b.aufbewahren ? ' <span class="wd-klein wd-leise">(aufbewahrungspflichtig)</span>' : "") +
"</li>";
}).join("");
dsMeldung(
"<strong>" + s(String(d.datensaetze)) + " Datensätze</strong> zu " + s(mail) +
'<ul class="wd-liste" style="margin:.6rem 0">' + liste + "</ul>" +
'<div class="wd-btn-reihe">' +
'<button type="button" class="wd-btn wd-btn--zweit wd-btn--klein" id="vw-ds-laden">' +
"Als Datei herunterladen</button></div>", "gut");
var knopf = document.getElementById("vw-ds-laden");
if (knopf) {
knopf.addEventListener("click", function () {
/* Die vollständige Auskunft, wie sie herausgegeben wird. JSON,
weil Art. 20 ein "gängiges, maschinenlesbares Format"
verlangt — und weil nichts verloren geht. */
var blob = new Blob([JSON.stringify(d, null, 2)], { type: "application/json" });
var url = URL.createObjectURL(blob);
var a = document.createElement("a");
a.href = url;
a.download = "auskunft-" + mail.replace(/[^a-z0-9]/gi, "-") + "-" +
new Date().toISOString().slice(0, 10) + ".json";
a.click();
URL.revokeObjectURL(url);
});
}
}
async function dsVorschau() {
var mail = dsMail();
if (!mail) return dsMeldung("Bitte zuerst eine E-Mail-Adresse eintragen.", "warn");
dsMeldung("Wird geprüft…");
var d;
try {
d = await api("/webdesign/admin/datenschutz/vorschau", { method: "POST", body: { email: mail } });
} catch (fehler) {
return dsMeldung("Nicht möglich: " + s((fehler && fehler.message) || "unbekannt"), "warn");
}
if (!d || !d.ok) return dsMeldung("Nicht möglich: " + s((d && d.error) || "unbekannt"), "warn");
var weg = (d.loeschbar || []).map(function (x) {
return "<li>" + s(String(x.anzahl)) + "× " + s(x.was) + "</li>";
}).join("") || '<li class="wd-leise">nichts</li>';
/* Der wichtigere Teil: was NICHT gelöscht werden darf, mit Grund
und Datum. Ohne diese Angabe müsste man bei jeder Anfrage neu
nachschlagen, welche Frist gilt. */
var bleibt = (d.bleibt || []).map(function (x) {
return "<li>" + s(String(x.anzahl)) + "× " + s(x.was) +
'<br /><span class="wd-klein wd-leise">' + s(x.grund) +
" · frei ab " + s(x.freiAb) + "</span></li>";
}).join("");
dsMeldung(
"<strong>Würde gelöscht</strong>" +
'<ul class="wd-liste" style="margin:.4rem 0 .8rem">' + weg + "</ul>" +
(bleibt
? "<strong>Bleibt (gesetzliche Aufbewahrung)</strong>" +
'<ul class="wd-liste" style="margin:.4rem 0 .8rem">' + bleibt + "</ul>"
: "") +
'<p class="wd-klein">' + s(d.hinweis || "") + "</p>" +
((d.loeschbar || []).length
? '<div class="wd-feld" style="margin-top:.8rem">' +
'<label for="vw-ds-best">Zum Löschen die Adresse noch einmal eintippen</label>' +
'<input type="email" id="vw-ds-best" autocomplete="off" placeholder="' + s(mail) + '" />' +
"</div>" +
'<div class="wd-btn-reihe" style="margin-top:.6rem">' +
'<button type="button" class="wd-btn wd-btn--klein" id="vw-ds-loeschen">' +
"Jetzt löschen</button></div>"
: ""), "warn");
var lk = document.getElementById("vw-ds-loeschen");
if (lk) lk.addEventListener("click", dsLoeschen);
}
async function dsLoeschen() {
var mail = dsMail();
var best = document.getElementById("vw-ds-best");
var bestaetigung = best ? best.value.trim() : "";
if (mail.toLowerCase() !== bestaetigung.toLowerCase()) {
return dsMeldung("Die beiden Adressen stimmen nicht überein. " +
"Zum Schutz vor einem Vertipper wird sie ein zweites Mal verlangt — " +
"der Unterschied zwischen .de und .com ist ein Buchstabe, die Folge unwiederbringlich.", "warn");
}
dsMeldung("Wird gelöscht…");
var d;
try {
d = await api("/webdesign/admin/datenschutz/loeschen",
{ method: "POST", body: { email: mail, bestaetigung: bestaetigung } });
} catch (fehler) {
return dsMeldung("Nicht möglich: " + s((fehler && fehler.message) || "unbekannt"), "warn");
}
if (!d || !d.ok) return dsMeldung("Nicht möglich: " + s((d && d.error) || "unbekannt"), "warn");
dsMeldung("<strong>" + s(String(d.geloescht)) + " Datensätze gelöscht.</strong>" +
'<p class="wd-klein" style="margin-top:.5rem">' + s(d.hinweis || "") + "</p>", "gut");
}
(function dsVerdrahten() {
var a = document.getElementById("vw-ds-auskunft");
if (a) a.addEventListener("click", dsAuskunft);
var v = document.getElementById("vw-ds-vorschau");
if (v) v.addEventListener("click", dsVorschau);
})();
async function ladeUebersicht() {
var laedt = document.getElementById("vw-ub-laedt");
var inhalt = document.getElementById("vw-ub-inhalt");
/* Beim erneuten Öffnen NICHT wieder auf "Wird geladen…" zurückfallen.
Der alte Stand ist ein paar Sekunden alt und damit brauchbarer als
ein leerer Kasten — er wird still ersetzt, sobald die neuen Zahlen
da sind. */
if (!uebersichtGeladen) { laedt.hidden = false; inhalt.hidden = true; }
var d;
try {
d = await api("/webdesign/admin/uebersicht");
} catch (fehler) {
laedt.hidden = true;
if (!uebersichtGeladen) {
inhalt.hidden = false;
document.getElementById("vw-ub-beimir").innerHTML =
'<p class="wd-meldung wd-meldung--fehler">Die Übersicht konnte nicht geladen werden' +
(fehler && fehler.status ? " (Fehler " + s(String(fehler.status)) + ")" : "") +
'. Die einzelnen Reiter oben funktionieren unabhängig davon.</p>';
}
return;
}
if (!d || !d.ok) return;
/* Eine Antwort, die zwar ok meldet, aber die Teile nicht mitbringt,
darf die Seite nicht sprengen. Vorher lief sie geradewegs in
d.beiMir.anfragen und warf einen Fehler, der die restliche
Verwaltung mitriss -- aufgefallen am 23.08.2026, als eine
Ersatzantwort im Test genau das ausloeste. Ein halber Server ist
ein realistischer Fall (alte Fassung noch nicht neu gestartet),
und dann sollen die uebrigen Reiter weiter benutzbar sein. */
/* ⚠️ Die Absicht daneben galt nur für drei von fünf Teilen
(behoben 26.08.2026).
Geprüft wurden beiMir, beiKunde und geld — die beiden Listen
darunter, projekte und verlauf, wurden ungeprüft mit .length
angefasst. Fehlte eine davon, brach die Übersicht mit
"Cannot read properties of undefined" ab, und zwar NACH dem
Aufbau der oberen Kacheln: Die halbe Seite stand schon da, der
Rest fehlte kommentarlos.
Genau der Fall, den der Kommentar unten beschreibt — ein halber
Server, dessen Antwort nicht alles mitbringt. Statt die ganze
Übersicht zu verweigern, wird jetzt aufgefüllt: Was da ist, wird
gezeigt; was fehlt, bleibt leer. Eine Liste, die es nicht gibt,
ist keine leere Liste — hier aber die einzig sinnvolle Anzeige.
Gefunden vom Handy-Test, weil dessen nachgebildete Antwort diese
beiden Felder nicht enthielt. */
if (!Array.isArray(d.projekte)) d.projekte = [];
if (!Array.isArray(d.verlauf)) d.verlauf = [];
if (!d.beiMir || !d.beiKunde || !d.geld) {
laedt.hidden = true;
inhalt.hidden = false;
document.getElementById("vw-ub-beimir").innerHTML =
'<p class="wd-meldung">Die Übersicht ist gerade nicht verfügbar. ' +
'Die Reiter oben funktionieren unabhängig davon.</p>';
return;
}
uebersichtGeladen = 1;
laedt.hidden = true;
inhalt.hidden = false;
/* Bewusst OHNE await: Die Übersicht soll nicht darauf warten. Die
Abfrage fragt den Browser nach dem Anmeldestand, und das kann auf
einem gerade erst gestarteten Service Worker einen Moment dauern.
Die Karte erscheint dann eben eine Sekunde später. */
ladePushKarte();
var m = d.beiMir, k = d.beiKunde, g = d.geld;
document.getElementById("vw-ub-beimir").innerHTML =
kachel({ zahl: m.anfragen.n, titel: "offene Anfragen", ziel: "anfragen",
unten: m.anfragen.n ? "älteste " + altersText(m.anfragen.aeltestes) : "alles beantwortet",
ton: dringlichkeit(m.anfragen.aeltestes, 2) }) +
kachel({ zahl: m.nachrichten.n, titel: "ungelesene Nachrichten", ziel: "postfach",
unten: m.nachrichten.n ? "älteste " + altersText(m.nachrichten.aeltestes) : "nichts Neues",
ton: dringlichkeit(m.nachrichten.aeltestes, 2) }) +
kachel({ zahl: m.wuensche.n, titel: "Änderungswünsche zu prüfen", ziel: "projekte",
unten: m.wuensche.n ? "ältester " + altersText(m.wuensche.aeltestes) : "keine offen",
ton: dringlichkeit(m.wuensche.aeltestes, 3) }) +
kachel({ zahl: m.projekte, titel: "Projekte am Zug", ziel: "projekte",
unten: m.projekte ? "du bist dran" : "nichts liegt bei dir" }) +
/* Ein Widerruf hat eine gesetzliche Frist. Er wird deshalb IMMER
rot dargestellt, sobald einer offen ist — unabhängig vom Alter.
Hier auf einen Schwellwert zu warten wäre der falsche Ort für
Zurückhaltung. */
(m.widerrufe.n
? kachel({ zahl: m.widerrufe.n, titel: "Widerrufe unbearbeitet", ziel: "zahlungen",
unten: "gesetzliche Frist läuft", ton: " vw-ub-kachel--rot" })
: "");
document.getElementById("vw-ub-beikunde").innerHTML =
kachel({ zahl: k.projekte, titel: "Projekte warten auf Kunden", ziel: "projekte",
unten: k.projekte ? "du bist hier raus" : "—" }) +
kachel({ zahl: k.aufgaben, titel: "offene Kundenaufgaben", ziel: "projekte",
unten: k.aufgaben ? "z. B. Texte, Bilder, Freigaben" : "nichts ausstehend" }) +
kachel({ zahl: k.zahlungen.n, titel: "unbezahlte Rechnungen", ziel: "zahlungen",
unten: k.zahlungen.n ? k.zahlungenText + " offen · älteste "
+ altersText(k.zahlungen.aeltestes) : "alles beglichen",
ton: dringlichkeit(k.zahlungen.aeltestes, 14) });
document.getElementById("vw-ub-geld").innerHTML =
kachel({ zahl: g.monat.bruttoText, titel: "Umsatz diesen Monat", ziel: "zahlungen",
unten: g.monat.n + (g.monat.n === 1 ? " Zahlung" : " Zahlungen") }) +
/* Der Unterschied zwischen Umsatz und dem, was ankommt, gehört
sichtbar gemacht. Sonst rechnet man am Jahresende mit der
falschen Zahl — PayPal behält seinen Anteil lautlos ein. */
kachel({ zahl: g.gebuehrenText, titel: "davon PayPal-Gebühr", ziel: "zahlungen",
unten: "netto angekommen: " + g.monat.nettoText }) +
kachel({ zahl: k.zahlungenText, titel: "noch ausstehend", ziel: "zahlungen",
unten: k.zahlungen.n ? "aus " + k.zahlungen.n + " Rechnung" +
(k.zahlungen.n === 1 ? "" : "en") : "nichts offen" }) +
kachel({ zahl: g.gesamt.bruttoText, titel: "Umsatz gesamt", ziel: "zahlungen",
unten: g.gesamt.n + " Zahlungen bisher" });
/* ---- Projekte mit Fortschrittsbalken ---- */
var pz = document.getElementById("vw-ub-projekte");
if (!d.projekte.length) {
pz.innerHTML = '<p class="wd-klein wd-leise">Zurzeit läuft kein Projekt.</p>';
} else {
pz.innerHTML = d.projekte.map(function (p) {
var ganz = p.aufg_gesamt || 0;
var teil = p.aufg_fertig || 0;
var pro = ganz ? Math.round((teil / ganz) * 100) : 0;
var beiMir = p.wartet_auf === "dogfather";
return '<button type="button" class="vw-ub-projekt" data-ziel="projekte">' +
'<span class="vw-ub-pkopf">' +
'<span class="vw-ub-pname">' + s(p.titel || "Ohne Titel") + '</span>' +
'<span class="vw-ub-pwer' + (beiMir ? " vw-ub-pwer--ich" : "") + '">' +
(beiMir ? "du bist dran" : "wartet auf Kunden") + '</span>' +
'</span>' +
'<span class="vw-ub-pkunde">' + s(p.kunde_name || "") +
' · ' + s(p.nummer || "") +
(p.richttermin ? ' · Richttermin ' + s(p.richttermin) : "") + '</span>' +
/* Der Balken trägt die Zahl daneben im Text. Eine Farbe allein
ist keine Information — wer sie nicht unterscheiden kann,
sieht sonst gar nichts. */
'<span class="vw-ub-balken" role="img" aria-label="' +
s(teil + " von " + ganz + " Schritten erledigt") + '">' +
'<span class="vw-ub-balken-voll" style="width:' + pro + '%"></span>' +
'</span>' +
'<span class="vw-ub-punten">' + teil + ' von ' + ganz + ' Schritten · ' + pro + ' %</span>' +
'</button>';
}).join("");
}
/* ---- Verlauf ---- */
var vz = document.getElementById("vw-ub-verlauf");
if (!d.verlauf.length) {
vz.innerHTML = '<p class="wd-klein wd-leise">Noch nichts geschehen.</p>';
} else {
vz.innerHTML = '<ul class="vw-ub-verlauf">' + d.verlauf.map(function (v) {
return '<li><span class="vw-ub-vzeit">' +
s(window.WD.datumZeit ? window.WD.datumZeit(v.erstellt_am) : (v.erstellt_am || "")) +
'</span><span class="vw-ub-vwas">' + s(v.aktion || "") + '</span>' +
'<span class="vw-ub-vwer">' + s(v.bereich || "") +
(v.wer ? " · " + s(v.wer) : "") + '</span></li>';
}).join("") + '</ul>';
}
/* ---- Benachrichtigungen ---- */
var mk = document.getElementById("vw-meldungen");
var meldungen = d.meldungen || [];
if (!meldungen.length) {
mk.hidden = true;
mk.innerHTML = "";
} else {
mk.hidden = false;
mk.innerHTML = meldungen.map(function (m) {
return '<div class="vw-meld' + (m.dringend ? " vw-meld--dringend" : "") +
'" data-meldung="' + s(m.id) + '">' +
'<button type="button" class="vw-meld-inhalt"' +
(m.ziel ? ' data-ziel="' + s(m.ziel) + '"' : "") + '>' +
'<p class="vw-meld-titel">' + s(m.titel) + "</p>" +
(m.text ? '<p class="vw-meld-text">' + s(m.text) + "</p>" : "") +
"</button>" +
'<button type="button" class="vw-meld-weg" data-weg="' + s(m.id) +
'" aria-label="Benachrichtigung wegklicken">✕</button>' +
"</div>";
}).join("") +
(meldungen.length > 1
? '<div class="vw-meld-alle"><button type="button" class="wd-btn wd-btn--still wd-btn--klein" ' +
'data-weg="alle">Alle ' + meldungen.length + ' wegklicken</button></div>'
: "");
}
/* Projekte, die auf ihre Anzahlung warten. Sie stehen in keiner
anderen Zahl, weil ihre Uhr nie zu laufen begann -- ohne diesen
Hinweis vergisst man sie schlicht. */
var wartend = d.wartetAufAnzahlung || [];
if (wartend.length) {
mk.hidden = false;
mk.insertAdjacentHTML("beforeend",
'<div class="vw-meld vw-meld--dringend">' +
'<button type="button" class="vw-meld-inhalt" data-ziel="projekte">' +
'<p class="vw-meld-titel">' + wartend.length +
(wartend.length === 1 ? " Projekt wartet" : " Projekte warten") +
' seit über einer Woche auf die Anzahlung</p>' +
'<p class="vw-meld-text">' +
s(wartend.slice(0, 3).map(function (w) { return w.nummer + " · " + w.kunde_name; }).join(" · ")) +
(wartend.length > 3 ? " …" : "") +
' — die Uhr läuft dort noch nicht.</p></button></div>');
}
/* Der Zähler am Anfragen-Reiter, damit man auch aus einer anderen
Ansicht heraus sieht, dass etwas hereingekommen ist. */
var az = document.getElementById("vw-anf-zaehler");
if (az) { az.textContent = m.anfragen.n; az.hidden = !m.anfragen.n; }
}
/* Ein Klick auf eine Kachel führt in den passenden Reiter. Über
Ereignis-Delegation, weil die Kacheln bei jedem Laden neu entstehen
— einzeln angehängte Zuhörer wären nach der ersten Aktualisierung
verschwunden. */
document.addEventListener("click", async function (e) {
var weg = e.target.closest ? e.target.closest("[data-weg]") : null;
if (weg) {
var welche = weg.dataset.weg;
/* Sofort verschwinden lassen, nicht erst nach der Antwort.
Eine Benachrichtigung wegzuklicken ist folgenlos -- schlimmsten-
falls taucht sie beim naechsten Laden wieder auf. Auf den Server
zu warten, bevor sich etwas ruehrt, faehlt sich dagegen nach
jedem Klick nach Haengen an. */
if (welche === "alle") document.getElementById("vw-meldungen").hidden = true;
else {
var kasten = document.querySelector('[data-meldung="' + welche + '"]');
if (kasten) kasten.remove();
}
try {
await api("/webdesign/admin/meldungen/" + encodeURIComponent(welche) + "/gelesen",
{ method: "POST", body: {} });
} catch (f) { /* beim naechsten Laden erscheint sie wieder */ }
return;
}
var ziel = e.target.closest ? e.target.closest("[data-ziel]") : null;
if (!ziel) return;
var knopf = document.querySelector('[data-tab="' + ziel.dataset.ziel + '"]');
if (knopf) knopf.click();
});
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) {
/* ⚠️ HIER STAND EINE ENDLOSSCHLEIFE (gefunden 23.08.2026).
Der alte Code holte bei 401 einen neuen Ausweis, setzte die
Sperre ausweisVersucht VOR dem Neuversuch wieder auf false und
rief sich selbst auf. Solange der Ausweis akzeptiert wurde,
fiel das nicht auf. Weist der Server ihn dagegen ab — was hier
passierte, weil beide Server nicht dasselbe
WEBDESIGN_API_SECRET benutzen —, drehte sich das ewig:
Ausweis holen, 401, Ausweis holen, 401.
Sichtbar war davon nichts: kein Fehler, keine Anmeldung, nur
ein Ladehinweis, der zehn Minuten stehen blieb. Die
schlimmste Sorte Fehler — sie sieht aus wie Langsamkeit.
Die Erneuerung sitzt jetzt AN EINER Stelle, in api(), mit
genau einem Neuversuch. Hier wird nur noch entschieden, was
danach zu tun ist. */
token = null; tokenQuelle = null; tokenSchreiben(null); zeigeAnmeldung();
return;
}
liste.innerHTML = "";
window.WD.meldung(meldung, fehler.message || "Die Anfragen konnten nicht geladen werden.", "fehler");
}
}
function zeichneZahlen(zaehler) {
var gesamt = Object.keys(zaehler).reduce(function (n, k) { return n + zaehler[k]; }, 0);
/* Der aelteste unbearbeitete Vorgang ist die ehrlichste Kennzahl
ueberhaupt: Er sagt nicht, wie viel man geschafft hat, sondern wie
lange jemand schon auf eine Antwort wartet. Genau daran misst ein
Kunde, ob man zuverlaessig ist. */
var wartend = alleAnfragen.filter(function (a) {
return a.status === "neu" || a.status === "rueckfrage";
});
var aeltesteTage = wartend.reduce(function (max, a) {
return Math.max(max, tageSeit(a.erstellt_am));
}, 0);
var kacheln = [
{
wert: zaehler.neu || 0, name: "neu, ungelesen", symbol: "📬", filter: "neu",
achtung: (zaehler.neu || 0) > 0,
zusatz: (zaehler.neu || 0) > 0 ? "warten auf dich" : "alles gesichtet",
},
{
wert: zaehler.pruefung || 0, name: "in Prüfung", symbol: "🔍", filter: "pruefung",
zusatz: "du bist dran",
},
{
wert: zaehler.angebot || 0, name: "Angebot raus", symbol: "📄", filter: "angebot",
zusatz: "Kunde ist dran",
},
{
wert: zaehler.angenommen || 0, name: "angenommen", symbol: "✅", filter: "angenommen",
zusatz: "Projekt anlegen",
},
{
/* Diese Kachel filtert bewusst NICHT -- sie zeigt einen Zustand,
keinen Status. Deshalb ist sie auch nicht anklickbar. */
wert: aeltesteTage, name: aeltesteTage === 1 ? "Tag wartet der Älteste" : "Tage wartet der Älteste",
symbol: "⏳", filter: null,
dringend: aeltesteTage >= 7,
zusatz: aeltesteTage === 0 ? "nichts liegen geblieben"
: aeltesteTage >= 7 ? "das ist zu lang" : "noch im Rahmen",
},
{
wert: gesamt, name: "insgesamt offen", symbol: "📊", filter: "",
zusatz: "alle anzeigen",
},
];
document.getElementById("vw-zahlen").innerHTML = kacheln.map(function (k) {
var klassen = "vw-zahl";
if (k.dringend) klassen += " vw-zahl--dringend";
else if (k.achtung) klassen += " vw-zahl--achtung";
else if (!k.wert) klassen += " vw-zahl--null";
if (k.filter !== null && filterStatus === k.filter && !filterArchiv) klassen += " vw-an";
var innen =
'<span class="vw-zahl-kopf">' +
'<span class="vw-zahl-symbol" aria-hidden="true">' + k.symbol + "</span>" +
'<span class="vw-zahl-wert">' + k.wert + "</span>" +
"</span>" +
'<span class="vw-zahl-name">' + s(k.name) + "</span>" +
'<span class="vw-zahl-zusatz">' + s(k.zusatz) + "</span>";
/* Anklickbares wird ein echter Knopf, nicht ein div mit
Klickzuhoerer -- sonst ist es mit der Tastatur nicht erreichbar
und ein Screenreader kuendigt es nicht als Bedienelement an. */
return k.filter === null
? '<div class="' + klassen + '" style="cursor:default">' + innen + "</div>"
: '<button type="button" class="' + klassen + '" data-kachel="' + k.filter +
'" aria-label="' + s(k.wert + " " + k.name + " anzeigen") + '">' + innen + "</button>";
}).join("");
document.querySelectorAll("[data-kachel]").forEach(function (b) {
b.addEventListener("click", function () {
filterStatus = b.dataset.kachel;
filterArchiv = false;
ladeAnfragen();
});
});
}
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();
});
}
/* ⚠️ HIER STAND EINE ZWEITE FUNKTION tageSeit (entfernt 26.08.2026).
Sie rechnete dasselbe wie die weiter oben, unterschied sich aber in
einem Punkt: Bei fehlendem oder unlesbarem Datum gab sie 0 zurueck,
die obere gibt null.
Weil in JavaScript die spaetere Definition gewinnt, war die obere
seit ihrer Einfuehrung wirkungslos -- und mit ihr die Pruefungen
"if (t === null) return ''" in altersText und dringlichkeit. Ohne
Datum stand dort seither "seit heute" statt gar nichts.
Gefunden hat das keine Fehlermeldung, sondern pruef-namenskollision.mjs,
entstanden am selben Tag aus genau demselben Fehler an anderer
Stelle. Zwei Funktionen desselben Namens sind erlaubtes JavaScript;
kein Browser und kein Syntaxpruefer schlaegt an.
Die beiden verbliebenen Aufrufer weiter unten vertragen null
genauso wie 0 -- geprueft: Math.max(x, null) ergibt x, und
null >= 7 ist falsch, gleich wie bei 0. */
function zeitText(tage) {
if (tage <= 0) return "heute";
if (tage === 1) return "gestern";
if (tage < 7) return "vor " + tage + " Tagen";
if (tage < 14) return "vor einer Woche";
if (tage < 31) return "vor " + Math.floor(tage / 7) + " Wochen";
return "vor " + Math.floor(tage / 30) + " Monaten";
}
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) {
/* Auch hier fuehren statt nur feststellen: Wer keine Anfragen hat,
soll wissen, WO sie herkommen -- und den Link gleich griffbereit
haben. */
liste.innerHTML = alleAnfragen.length
? '<div class="vw-leer-hinweis">Keine Anfrage passt zu diesem Filter.</div>'
: filterArchiv
? '<div class="vw-leer-hinweis">Im Archiv liegt noch nichts. Archivierte Anfragen bleiben vollstaendig erhalten und lassen sich jederzeit zurueckholen.</div>'
: '<div class="wd-karte vw-leer-gross" style="cursor:default;display:block">' +
'<div class="vw-leer-gross-symbol" aria-hidden="true">📬</div>' +
"<h3>Noch keine Anfragen</h3>" +
'<p class="wd-leise">Sobald jemand das Formular ausfuellt, landet die Anfrage hier — mit allem, was er angegeben hat, und du bekommst eine Benachrichtigung.</p>' +
'<div class="wd-btn-reihe" style="justify-content:center;margin-top:1rem">' +
'<a class="wd-btn wd-btn--zweit wd-btn--klein" href="/webdesign/anfrage.html" target="_blank" rel="noopener">Formular ansehen</a>' +
'<a class="wd-btn wd-btn--still wd-btn--klein" href="/webdesign/" target="_blank" rel="noopener">Zur Seite</a>' +
"</div></div>";
return;
}
liste.innerHTML = gefiltert.map(function (a) {
var tage = tageSeit(a.erstellt_am);
/* "vor 3 Tagen" sagt mehr als ein Datum. Beim Datum muss man selbst
rechnen, wie lange jemand schon wartet -- und genau das ist die
Frage, die zaehlt. Ab sieben Tagen ohne Bearbeitung wird es rot:
eine Anfrage, die eine Woche liegt, ist ein verlorener Kunde. */
var liegtLange = tage >= 7 && (a.status === "neu" || a.status === "rueckfrage");
return '<button type="button" class="vw-karte vw-karte--' + s(a.status) + '" 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>" +
'<span class="vw-karte-info">' + s(a.email) + "</span>" +
'<span class="vw-karte-marken">' +
'<span class="vw-mini">' + s(PAKET_TEXT[a.paket] || a.paket) + "</span>" +
(a.budget ? '<span class="vw-mini vw-mini--geld">' + s(BUDGET_TEXT[a.budget] || a.budget) + "</span>" : "") +
(a.termin ? '<span class="vw-mini">Wunsch: ' + s(a.termin) + "</span>" : "") +
(a.sprache && a.sprache !== "de" ? '<span class="vw-mini">' + s(a.sprache.toUpperCase()) + "</span>" : "") +
(a.kunde_id ? '<span class="vw-mini">ist Kunde</span>' : "") +
"</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' + (liegtLange ? " vw-mini--alt" : "") + '">' +
s(zeitText(tage)) + "</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;
/* Auch das offene Projekt zuruecksetzen. Bliebe es stehen, wuerde ein
spaeterer Klick auf "+ Punkt" ihn im zuletzt geoeffneten Projekt
anlegen -- also im falschen. */
offenesProjekt = 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>";
}
/* Baut einen Kasten aus Paaren [Bezeichnung, Wert] und trennt dabei
Ausgefuelltes von Leerem: gefuellte Angaben als vollwertige Zeilen,
alle fehlenden zusammen als eine leise Zeile darunter.
Der Unterschied in der Praxis: Aus sechs Zeilen "keine Angabe"
untereinander wird "Ohne Angabe: Telefon, Zielgruppe, Termin" — eine
Zeile statt sechs, und man liest auf einen Blick, was noch fehlt,
statt es sich aus Luecken zusammenzusuchen. */
function abschnitt(titel, paare, anhang) {
var gefuellt = paare.filter(function (p) { return p[1]; });
var fehlend = paare.filter(function (p) { return !p[1]; }).map(function (p) { return p[0]; });
var innen = gefuellt.map(function (p) { return paar(p[0], p[1]); }).join("");
/* Anhang (Knoepfe, Zusatzfragen) VOR die Fehlt-Zeile. Die gehoert an
den Fuss des Kastens — ein Knopf darunter sah aus, als beziehe er
sich auf die fehlenden Angaben. */
innen += anhang || "";
if (fehlend.length) {
innen += '<div class="vw-ohne"><b>Ohne Angabe:</b> ' + s(fehlend.join(", ")) + "</div>";
}
return '<div class="vw-abschnitt' + (gefuellt.length ? "" : " vw-abschnitt--leer") + '">' +
"<h3>" + s(titel) + "</h3>" + innen + "</div>";
}
function blickFeld(was, wert) {
var leer = !wert;
return '<div class="vw-blick-feld"><span class="vw-blick-was">' + s(was) + "</span>" +
'<span class="vw-blick-wert' + (leer ? " vw-leer" : "") + '">' +
(leer ? "offen" : s(wert)) + "</span></div>";
}
/* "vor 3 Tagen" statt eines Datums. Beim Sichten der Anfragen ist die
Frage nie "welcher Tag war das", sondern "wie lange liegt das schon
hier" — und die beantwortet ein Datum erst nach Kopfrechnen. */
function alter(iso) {
var ms = Date.now() - new Date(iso).getTime();
var tage = Math.floor(ms / 86400000);
if (isNaN(tage)) return "";
if (tage <= 0) return "heute";
if (tage === 1) return "gestern";
if (tage < 7) return "vor " + tage + " Tagen";
if (tage < 14) return "vor 1 Woche";
if (tage < 60) return "vor " + Math.floor(tage / 7) + " Wochen";
return "vor " + Math.floor(tage / 30) + " Monaten";
}
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>" +
/* Auf einen Blick — die vier Fragen, die man immer zuerst hat. */
'<div class="vw-blick">' +
blickFeld("Paket", PAKET_TEXT[a.paket] || a.paket) +
blickFeld("Budget", BUDGET_TEXT[a.budget] || a.budget) +
blickFeld("Wunschtermin", a.termin) +
blickFeld("Eingegangen", alter(a.erstellt_am)) +
"</div>" +
'<div class="vw-detail-spalten">' +
/* ----- LINKS: was der Kunde geschickt hat (lesen) ----- */
'<div class="vw-dt-links">' +
abschnitt("Kontakt", [
["E-Mail", a.email],
["Telefon", a.telefon],
["Bevorzugt", a.kontaktweg === "telefon" ? "Telefon" : "E-Mail"],
["Sprache", (a.sprache || "").toUpperCase()],
],
'<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>') +
abschnitt("Vorhaben", [
["Ziel", a.ziel],
["Zielgruppe", a.zielgruppe],
]) +
/* "details" sind die Zusatzfragen aus dem Formular — sie kommen
schon fertig gebaut und immer gefuellt, deshalb als Anhang. */
abschnitt("Umfang", [
["Bereiche", a.seiten],
["Funktionen", funktionen],
], details) +
abschnitt("Vorhandene Inhalte", [
["Texte", VORHANDEN_TEXT[a.hat_texte] || a.hat_texte],
["Bilder", VORHANDEN_TEXT[a.hat_bilder] || a.hat_bilder],
["Logo", VORHANDEN_TEXT[a.hat_logo] || a.hat_logo],
["Domain", VORHANDEN_TEXT[a.hat_domain] || a.hat_domain],
]) +
(a.bemerkung ? '<div class="vw-abschnitt"><h3>Bemerkung</h3>' + paar("", a.bemerkung) + "</div>" : "") +
"</div>" +
/* ----- RECHTS: was du damit machst (handeln) ----- */
'<div class="vw-dt-rechts">' +
/* Der wichtigste Knopf steht oben. Bei einer neuen Anfrage ist die
Uebernahme das, was man tun WILL — sie stand vorher unter den
Notizen und damit ausserhalb des Bildes. */
(a.kunde_id
? '<div class="vw-abschnitt"><h3>Kunde</h3>' +
'<p class="wd-klein wd-leise">Aus dieser Anfrage wurde bereits ein Kundenzugang erstellt. Du findest ihn oben unter „Kunden“.</p></div>'
: '<div class="vw-abschnitt"><h3>Angebot schicken</h3>' +
'<div class="vw-uebernahme" id="vw-angebot">' +
/* Das Angebot steht VOR dem direkten Annehmen.
Der übliche Weg ist: Angebot raus, Kunde sagt zu, Projekt
entsteht. Direkt anzunehmen ist der Sonderfall (man hat
schon telefoniert). Stünde der Sonderfall oben, wäre er
der naheliegende Griff — und man verschenkte den Beleg,
den eine Zusage im Portal erzeugt. */
'<p class="wd-klein wd-leise" style="margin-top:0">Der Leistungsumfang kommt ' +
'aus der Standardliste des Pakets — du wählst nur Paket und Preis. Der Kunde ' +
'sagt im Portal mit einem Klick zu; die Anzahlung entsteht dabei von selbst.</p>' +
'<div class="wd-grid wd-grid--2" style="gap:.7rem">' +
'<div class="wd-feld">' +
'<label for="vw-ag-paket">Paket</label>' +
'<select id="vw-ag-paket">' +
'<option value="onepager">Onepager</option>' +
'<option value="website">Website</option>' +
'<option value="shop">Onlineshop</option>' +
'<option value="verwaltung">Verwaltungsbereich</option>' +
'<option value="betreuung">Laufende Betreuung</option>' +
'<option value="unklar">Noch unklar</option>' +
'</select>' +
'</div>' +
'<div class="wd-feld">' +
'<label for="vw-ag-preis">Preis in Euro</label>' +
'<input type="number" id="vw-ag-preis" min="1" step="1" inputmode="numeric" />' +
'<span class="wd-feld-hilfe" id="vw-ag-anzahlung">…</span>' +
'</div>' +
'</div>' +
'<details class="vw-ag-details">' +
'<summary>Leistungsumfang ansehen und ändern</summary>' +
'<div class="wd-feld" style="margin-top:.6rem">' +
'<textarea id="vw-ag-leistungen" rows="12"></textarea>' +
'<span class="wd-feld-hilfe">Kommt aus der Standardliste. Was du hier ' +
'änderst, wird beim Absenden festgeschrieben.</span>' +
'</div>' +
'</details>' +
'<p class="wd-klein wd-leise" id="vw-ag-gueltig"></p>' +
'<button type="button" class="wd-btn wd-btn--haupt" id="vw-ag-senden" ' +
'style="width:100%">Angebot schicken</button>' +
'</div></div>' +
'<div class="vw-abschnitt"><h3>Oder direkt entscheiden</h3>' +
'<div class="vw-uebernahme" id="vw-annahme">' +
"<h4>Annehmen — und alles läuft</h4>" +
/* Was genau passiert, steht DA, bevor man drückt. Ein Knopf,
der fünf Dinge auf einmal tut, muss sie benennen: Sonst
traut man sich nicht, ihn zu drücken, oder man drückt ihn
und ist überrascht. */
"<p>Ein Klick legt an: Kundenzugang, Projekt, die komplette " +
"Aufgabenliste für das Paket und den Liefertermin. Der Kunde " +
"kann ab diesem Moment alles in seinem Portal verfolgen.</p>" +
'<dl class="vw-uebernahme-vorschau" id="vw-an-vorschau">' +
"<dt>Kunde</dt><dd>" + s(a.name) + "</dd>" +
"<dt>E-Mail</dt><dd>" + s(a.email || "—") + "</dd>" +
"<dt>Projekt</dt><dd id=\"vw-an-titel\">wird geladen…</dd>" +
"<dt>Arbeitsschritte</dt><dd id=\"vw-an-schritte\">…</dd>" +
"</dl>" +
'<div class="wd-grid wd-grid--2" style="gap:.7rem">' +
'<div class="wd-feld">' +
'<label for="vw-an-termin">Liefertermin</label>' +
'<input type="date" id="vw-an-termin" />' +
'<span class="wd-feld-hilfe" id="vw-an-termin-hilfe">wird berechnet…</span>' +
"</div>" +
'<div class="wd-feld">' +
'<label for="vw-ue-preis">Gesamtpreis in Euro <span class="wd-leise">optional</span></label>' +
'<input type="number" id="vw-ue-preis" min="0" step="1" inputmode="numeric" placeholder="z. B. 1500" />' +
'<span class="wd-feld-hilfe" id="vw-ue-anzahlung">Die 30 % Anzahlung wird daraus berechnet.</span>' +
"</div>" +
"</div>" +
'<button type="button" class="wd-btn wd-btn--haupt" id="vw-uebernehmen" style="width:100%;margin-top:.3rem">' +
"Annehmen und starten</button>" +
/* Ablehnen steht bewusst darunter und ist zurückhaltend
gestaltet -- es ist die seltenere und die endgültigere
Entscheidung. Ein gleich lauter Knopf daneben lädt zum
Verwechseln ein. */
'<div id="vw-ablehnen-bereich" style="margin-top:1rem;padding-top:.9rem;border-top:1px solid var(--wd-rand)">' +
'<button type="button" class="wd-btn wd-btn--still wd-btn--klein" id="vw-ablehnen-auf">' +
"Stattdessen ablehnen</button>" +
'<div id="vw-ablehnen-form" hidden style="margin-top:.8rem">' +
'<div class="wd-feld">' +
'<label for="vw-ab-grund">Grund</label>' +
'<select id="vw-ab-grund">' +
'<option value="kein_zeitfenster">Kein freies Zeitfenster</option>' +
'<option value="passt_nicht">Passt fachlich nicht</option>' +
'<option value="budget">Budget passt nicht zum Aufwand</option>' +
'<option value="zu_gross">Zu groß für allein</option>' +
'<option value="kein_kontakt">Kein Kontakt zustande gekommen</option>' +
'<option value="sonstiges">Sonstiges</option>' +
"</select>" +
'<span class="wd-feld-hilfe">Der Text unten wird daraus erzeugt — in der Sprache der Anfrage (' +
s((a.sprache || "de").toUpperCase()) + ").</span>" +
"</div>" +
/* Der Text ist LESBAR, bevor er rausgeht. Eine Absage
ist die letzte Nachricht, die dieser Mensch von hier
bekommt -- sie ungelesen abzuschicken wäre unhöflich
und im Zweifel peinlich. */
'<div class="wd-feld">' +
'<label for="vw-ab-text">Das bekommt der Kunde zu lesen</label>' +
'<textarea id="vw-ab-text" readonly rows="9" style="min-height:0"></textarea>' +
"</div>" +
'<div style="display:flex;flex-wrap:wrap;gap:.5rem">' +
'<button type="button" class="wd-btn wd-btn--klein" id="vw-ab-senden">Ablehnen und Absage schicken</button>' +
'<button type="button" class="wd-btn wd-btn--still wd-btn--klein" id="vw-ab-still">Nur intern ablehnen</button>' +
"</div>" +
"</div>" +
"</div>" +
"</div></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>" +
"</div>" + /* Ende rechte Spalte */
"</div>"; /* Ende Spaltenraster */
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.");
}
});
/* =================================================================
ANNEHMEN UND ABLEHNEN
Vorher waren das zwei getrennte Aufrufe hintereinander: erst den
Kunden anlegen, dann das Projekt. Der Kommentar an dieser Stelle
gab selbst zu, was daran faul war -- schlug der zweite fehl,
stand der Kunde schon in der Datenbank, und man durfte es nicht
noch einmal versuchen, weil die E-Mail dann doppelt gewesen
waere. Man musste von Hand aufraeumen.
Jetzt macht das EIN Aufruf, und zwar ganz oder gar nicht. Er legt
zusaetzlich die komplette Aufgabenliste des Pakets an und
berechnet den Liefertermin.
================================================================= */
/* Ein reines Datum (JJJJ-MM-TT) menschlich schreiben.
window.WD.datum erwartet einen Zeitstempel; ein Liefertermin ist
aber ein Tag ohne Uhrzeit. Der erste Entwurf rief hier ein
datumKurz() auf, das es gar nicht gab -- das haette beim ersten
Annehmen mit Termin einen Fehler geworfen, ausgerechnet NACHDEM
alles angelegt war. */
function datumKurz(iso) {
if (!iso) return "";
var t = String(iso).slice(0, 10).split("-");
return t.length === 3 ? t[2] + "." + t[1] + "." + t[0] : String(iso);
}
/* ---- Angebot ----
Die Vorschau füllt alles vor. Wechselt man das Paket, wird neu
geladen: Preis, Laufzeit UND Leistungstext hängen daran. Ein
stehengebliebener Text vom vorigen Paket wäre der schlimmste Fall --
er ginge unbemerkt so hinaus, und im Angebot stünde etwas, das zum
Preis nicht passt. */
var agKasten = blatt.querySelector("#vw-angebot");
if (agKasten) {
var agPaket = blatt.querySelector("#vw-ag-paket");
var agPreis = blatt.querySelector("#vw-ag-preis");
var agLeist = blatt.querySelector("#vw-ag-leistungen");
var agGueltig = blatt.querySelector("#vw-ag-gueltig");
var agAnz = blatt.querySelector("#vw-ag-anzahlung");
var agGueltigBis = null;
var agAnzahlungZeigen = function () {
var wert = Number(agPreis.value);
agAnz.textContent = Number.isFinite(wert) && wert > 0
? "30 % Anzahlung: " + (Math.round(wert * 30) / 100).toFixed(2).replace(".", ",") + " €"
: "Die 30 % Anzahlung wird daraus berechnet.";
};
var agLaden = async function (paket) {
try {
var v = await api("/webdesign/admin/anfragen/" + encodeURIComponent(a.id)
+ "/angebot-vorschau" + (paket ? "?paket=" + encodeURIComponent(paket) : ""));
if (!v || !v.ok) return;
agPaket.value = v.paket;
agPreis.value = v.preisEuro;
agLeist.value = v.leistungen || "";
agGueltigBis = v.gueltigBis;
agGueltig.textContent = "Läuft " + v.werktage + " Werktage · gültig bis "
+ datumKurz(v.gueltigBis);
agAnzahlungZeigen();
if (v.schonUnterwegs) {
agGueltig.textContent = "Achtung: " + v.schonUnterwegs.nummer
+ " ist bereits unterwegs. Zieh es zurück, bevor du ein neues schickst.";
}
} catch (f) {
agGueltig.textContent = "Vorschlag konnte nicht geladen werden: " + f.message;
}
};
agLaden(null);
agPaket.addEventListener("change", function () { agLaden(agPaket.value); });
agPreis.addEventListener("input", agAnzahlungZeigen);
blatt.querySelector("#vw-ag-senden").addEventListener("click", async function (e) {
var preis = Number(agPreis.value);
if (!Number.isFinite(preis) || preis <= 0) {
window.WD.meldung(meldung, "Bitte einen Preis eintragen.", "fehler");
return;
}
window.WD.knopfSperren(e.target, true, "Wird geschickt…");
try {
var r = await api("/webdesign/admin/anfragen/" + encodeURIComponent(a.id) + "/angebot", {
method: "POST",
body: { paket: agPaket.value, preisEuro: preis,
leistungen: agLeist.value, gueltigBis: agGueltigBis },
});
schliesseDetail();
await ladeAnfragen();
window.WD.meldung(meldung,
"Angebot " + r.nummer + " ist raus: " + r.preisText + " · Anzahlung "
+ r.anzahlungText + " · gültig bis " + datumKurz(r.gueltigBis)
+ ". " + (r.hinweis || ""), "ok");
} catch (f) {
window.WD.knopfSperren(e.target, false);
window.WD.meldung(meldung, "Angebot konnte nicht raus: " + f.message, "fehler");
}
});
}
var annahmeKasten = blatt.querySelector("#vw-annahme");
if (annahmeKasten) {
var terminFeld = blatt.querySelector("#vw-an-termin");
var terminHilfe = blatt.querySelector("#vw-an-termin-hilfe");
/* Die Vorschau holen, damit die Felder gefuellt sind, BEVOR
jemand drueckt. Schlaegt sie fehl, bleibt der Knopf trotzdem
benutzbar -- der Server setzt dann seinen eigenen Vorschlag
ein. Eine kaputte Vorschau darf das Annehmen nicht blockieren. */
api("/webdesign/admin/anfragen/" + encodeURIComponent(a.id) + "/annahme-vorschau")
.then(function (v) {
if (!v || !v.ok) return;
var t = blatt.querySelector("#vw-an-titel");
if (t) t.textContent = v.titelVorschlag;
var sch = blatt.querySelector("#vw-an-schritte");
if (sch) sch.textContent = v.schritte + " Punkte aus der Standardliste";
if (v.laufend) {
/* Eine laufende Betreuung hat kein Ende. Ein Datumsfeld
dafuer waere eine Falle: Was man einträgt, wäre eine
Zusage, die niemand gemeint hat. */
terminFeld.value = "";
terminFeld.disabled = true;
terminHilfe.textContent = "Laufende Betreuung — kein Liefertermin.";
} else {
terminFeld.value = v.terminVorschlag || "";
terminHilfe.textContent = v.werktage + " Werktage ab heute · Wochenenden und "
+ "Feiertage sind schon abgezogen. Änderbar.";
}
if (!v.kundeNeu && v.kunde) {
var kd = blatt.querySelector("#vw-an-vorschau");
if (kd) {
var dt = document.createElement("dt"); dt.textContent = "Hinweis";
var dd = document.createElement("dd");
dd.textContent = "Diesen Kunden gibt es schon — er bekommt das Projekt "
+ "in seinen bestehenden Zugang, kein zweiter Eintrag.";
kd.appendChild(dt); kd.appendChild(dd);
}
}
})
.catch(function () { terminHilfe.textContent = "Vorschlag konnte nicht geladen werden — der Server setzt einen ein."; });
}
var preisFeld2 = blatt.querySelector("#vw-ue-preis");
if (preisFeld2) {
/* Die 30 % mitrechnen, waehrend getippt wird. Wer den Gesamtpreis
eingibt, will wissen, was er gleich als Anzahlung stellt --
nachrechnen im Kopf ist eine Fehlerquelle. */
preisFeld2.addEventListener("input", function () {
var wert = Number(preisFeld2.value);
var ziel = blatt.querySelector("#vw-ue-anzahlung");
if (!ziel) return;
ziel.textContent = Number.isFinite(wert) && wert > 0
? "30 % Anzahlung: " + (Math.round(wert * 100 * 0.3) / 100).toFixed(2).replace(".", ",") + " €"
: "Die 30 % Anzahlung wird daraus berechnet.";
});
}
var ueKnopf = blatt.querySelector("#vw-uebernehmen");
if (ueKnopf) {
ueKnopf.addEventListener("click", async function (e) {
window.WD.knopfSperren(e.target, true, "Wird angelegt…");
try {
var preis = Number(preisFeld2 && preisFeld2.value);
var tf = blatt.querySelector("#vw-an-termin");
var antwort = await api(
"/webdesign/admin/anfragen/" + encodeURIComponent(a.id) + "/annehmen",
{ method: "POST", body: {
termin: (tf && !tf.disabled && tf.value) ? tf.value : undefined,
preisEuro: Number.isFinite(preis) && preis > 0 ? preis : undefined,
} });
schliesseDetail();
await ladeAnfragen();
if (antwort.einladungsLink) {
zeigeLink(antwort.einladungsLink);
} else {
/* Kein Link heisst: Der Kunde hatte schon einen Zugang. Das
muss man SAGEN -- sonst sucht man den Link und glaubt,
etwas sei schiefgegangen. */
window.WD.meldung(meldung,
"Angenommen. " + antwort.projektNummer + " ist angelegt, "
+ antwort.schritte + " Arbeitsschritte stehen bereit"
+ (antwort.termin ? ", Liefertermin " + datumKurz(antwort.termin) : "")
+ ". " + antwort.hinweis, "ok");
}
} catch (fehler) {
window.WD.meldung(meldung,
"Annehmen fehlgeschlagen: " + fehler.message
+ " Es wurde nichts angelegt — du kannst es gefahrlos noch einmal versuchen.", "fehler");
} finally {
window.WD.knopfSperren(e.target, false);
}
});
}
/* ---- Ablehnen ---- */
var abAuf = blatt.querySelector("#vw-ablehnen-auf");
if (abAuf) {
var abForm = blatt.querySelector("#vw-ablehnen-form");
var abGrund = blatt.querySelector("#vw-ab-grund");
var abText = blatt.querySelector("#vw-ab-text");
async function absageHolen() {
abText.value = "wird erzeugt…";
try {
var v = await api("/webdesign/admin/anfragen/" + encodeURIComponent(a.id)
+ "/absage-vorschau?grund=" + encodeURIComponent(abGrund.value));
abText.value = v.text || "";
} catch (f) {
abText.value = "Der Text konnte nicht geladen werden: " + f.message;
}
}
abAuf.addEventListener("click", function () {
abForm.hidden = !abForm.hidden;
abAuf.textContent = abForm.hidden ? "Stattdessen ablehnen" : "Doch nicht ablehnen";
if (!abForm.hidden) absageHolen();
});
abGrund.addEventListener("change", absageHolen);
async function ablehnen(senden, knopf) {
window.WD.knopfSperren(knopf, true, "Wird gespeichert…");
try {
var r = await api("/webdesign/admin/anfragen/" + encodeURIComponent(a.id) + "/ablehnen",
{ method: "POST", body: { grund: abGrund.value, senden: senden } });
schliesseDetail();
await ladeAnfragen();
window.WD.meldung(meldung,
senden
? (r.imPostfach
? "Abgelehnt. Die Absage liegt im Postfach des Kunden."
: "Abgelehnt. " + (r.hinweis || ""))
: "Abgelehnt — nur intern vermerkt, es ging nichts hinaus.",
"ok");
} catch (fehler) {
window.WD.meldung(meldung, "Ablehnen fehlgeschlagen: " + fehler.message, "fehler");
} finally {
window.WD.knopfSperren(knopf, false);
}
}
blatt.querySelector("#vw-ab-senden")
.addEventListener("click", function (e) { ablehnen(true, e.target); });
blatt.querySelector("#vw-ab-still")
.addEventListener("click", function (e) { ablehnen(false, e.target); });
}
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) {
/* Der Schließen-Knopf hatte hier ein onclick-Attribut. Das ist die
einzige Stelle im Webdesign-Bereich, die noch eines hatte -- und
genau solche Attribute zwingen eine Inhaltsrichtlinie dazu,
'unsafe-inline' zu erlauben. Damit wäre ihr Hauptzweck dahin:
Sie soll ja verhindern, dass eingeschleuster Code im Seitentext
ausgeführt wird, und kann inline nicht von inline unterscheiden.
Der Knopf tut jetzt dasselbe über einen angehängten Zuhörer. */
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 vw-blatt-zu" type="button">Schließen</button></div>';
var zuKnopf = blatt.querySelector(".vw-blatt-zu");
if (zuKnopf) {
zuKnopf.addEventListener("click", function () {
var d = zuKnopf.closest(".vw-detail");
if (d) d.classList.remove("vw-auf");
});
}
}
}
/* =================================================================
KUNDEN
================================================================= */
/* Reiter -> Titel und zugehoeriger Kasten.
Steht bewusst AUSSERHALB des Klick-Zuhoerers. Solange die Zuordnung
nur beim Klicken gelesen wurde, konnte die Ueberschrift beim Laden
gar nicht stimmen: Am 23.08.2026 zeigte die frisch geoeffnete
Verwaltung das Cockpit, waehrend darueber noch "Projektanfragen"
stand -- der Titel wurde erst beim ersten Klick richtig. Jetzt gibt
es eine Quelle, aus der sich beides speist. */
var ANSICHTEN = {
uebersicht:{ titel: "Übersicht", kasten: "vw-uebersicht-bereich" },
anfragen: { titel: "Projektanfragen", kasten: null },
projekte: { titel: "Projekte", kasten: "vw-projekte-bereich" },
kunden: { titel: "Kunden", kasten: "vw-kunden-bereich" },
zahlungen:{ titel: "Zahlungen", kasten: "vw-zahlungen-bereich" },
postfach: { titel: "Postfach", kasten: "vw-postfach-bereich" },
};
/* =====================================================================
SUCHE ÜBER ALLES ("Befehlspalette")
Bisher gab es ein Suchfeld, und es durchsuchte nur die Anfragen. Wer
einen Namen im Kopf hatte, musste raten, in welchem Reiter er
nachsehen muss. Jetzt: Strg+K von überall, auf dem Handy der
Lupenknopf oben.
VIER DINGE, DIE MAN LEICHT FALSCH MACHT
1. BEI JEDEM TASTENDRUCK SUCHEN. Wer "Musterbau" tippt, löst neun
Abfragen aus, von denen acht schon veraltet sind, bevor sie
ankommen. Deshalb eine kurze Wartezeit -- lang genug, um Tippen
zu erkennen, kurz genug, um nicht zu bremsen.
2. DAS WETTRENNEN DER ANTWORTEN. Auch mit Wartezeit können zwei
Abfragen unterwegs sein. Kommt die ältere später an, überschreibt
sie die neuere -- man tippt weiter und sieht plötzlich wieder die
Treffer von vorhin. Deshalb bekommt jede Abfrage eine laufende
Nummer, und nur die jeweils neueste darf zeichnen.
3. DEN FOKUS IN DIE LISTE VERSCHIEBEN. Naheliegend, aber falsch: Dann
kann man nicht mehr weitertippen. Der Fokus bleibt im Eingabefeld,
die Auswahl wandert über aria-activedescendant -- so schreibt es
das ARIA-Muster vor, und so verhält es sich auch richtig.
4. NUR DEN GUTEN FALL BAUEN. Leer, lädt und Fehler sind eigene
Zustände. Ein Kasten, der bei einem Serverfehler einfach leer
bleibt, sieht aus wie "nichts gefunden" -- und man sucht dann an
der falschen Stelle nach dem Problem.
===================================================================== */
var palette = document.getElementById("vw-palette");
var palFeld = document.getElementById("vw-pal-feld");
var palListe = document.getElementById("vw-pal-liste");
var palZahl = document.getElementById("vw-pal-zahl");
var palTreffer = [];
var palAuswahl = -1;
var palLauf = 0; // laufende Nummer gegen das Wettrennen
var palZeitgeber = null;
var palVorherFokus = null; // wohin der Fokus zurückkehrt
var ART_TEXT = {
anfrage: "Anfrage", kunde: "Kunde", projekt: "Projekt", zahlung: "Zahlung",
};
function paletteAuf() {
if (!palette.hidden) return;
/* Merken, wo der Fokus war. Beim Schliessen muss er dorthin zurueck --
sonst steht man nach Esc am Seitenanfang und muss sich neu
orientieren. */
palVorherFokus = document.activeElement;
palette.hidden = false;
palFeld.value = "";
palListeLeeren();
palZahl.textContent = "";
palFeld.focus();
document.body.style.overflow = "hidden"; // Hintergrund nicht mitscrollen
}
function paletteZu() {
if (palette.hidden) return;
palette.hidden = true;
document.body.style.overflow = "";
palFeld.setAttribute("aria-expanded", "false");
palFeld.removeAttribute("aria-activedescendant");
if (palVorherFokus && palVorherFokus.focus) {
try { palVorherFokus.focus(); } catch (e) { /* Element ist weg, egal */ }
}
}
function palListeLeeren() {
palListe.innerHTML = "";
palTreffer = [];
palAuswahl = -1;
palFeld.setAttribute("aria-expanded", "false");
palFeld.removeAttribute("aria-activedescendant");
}
function palHinweis(text, art) {
palListe.innerHTML = '<li class="vw-pal-hinweis' + (art ? " vw-pal-hinweis--" + art : "") +
'" role="presentation">' + s(text) + "</li>";
palTreffer = [];
palAuswahl = -1;
palFeld.setAttribute("aria-expanded", "false");
palFeld.removeAttribute("aria-activedescendant");
}
function palZeichnen(d) {
palTreffer = d.treffer || [];
palAuswahl = -1;
if (d.zuKurz) {
palHinweis("Mindestens zwei Zeichen eingeben.");
palZahl.textContent = "";
return;
}
if (!palTreffer.length) {
palHinweis("Nichts gefunden zu „" + d.begriff + "“.");
palZahl.textContent = "Keine Treffer";
return;
}
palListe.innerHTML = palTreffer.map(function (t, i) {
return '<li class="vw-pal-treffer" role="option" id="vw-pal-o' + i + '"' +
' aria-selected="false" data-nr="' + i + '">' +
'<span class="vw-pal-art vw-pal-art--' + s(t.art) + '">' +
s(ART_TEXT[t.art] || t.art) + "</span>" +
'<span class="vw-pal-mitte">' +
'<span class="vw-pal-titel">' + s(t.titel || "—") + "</span>" +
'<span class="vw-pal-unten">' + s(t.unten || "") + "</span>" +
"</span>" +
(t.marke ? '<span class="vw-pal-marke">' + s(t.marke) + "</span>" : "") +
"</li>";
}).join("");
palFeld.setAttribute("aria-expanded", "true");
palZahl.textContent = palTreffer.length + (palTreffer.length === 1 ? " Treffer" : " Treffer")
+ (d.begrenzt ? " (je Bereich höchstens " + d.grenzeJeBereich + ")" : "");
palWaehlen(0);
}
function palWaehlen(nr) {
var knoten = palListe.querySelectorAll('[role="option"]');
if (!knoten.length) return;
if (nr < 0) nr = knoten.length - 1;
if (nr >= knoten.length) nr = 0;
palAuswahl = nr;
knoten.forEach(function (k, i) {
k.setAttribute("aria-selected", i === nr ? "true" : "false");
k.classList.toggle("vw-pal-an", i === nr);
});
/* aria-activedescendant statt echtem Fokus: Der Fokus bleibt im
Eingabefeld, damit man weitertippen kann -- angesagt wird
trotzdem die ausgewaehlte Zeile. */
palFeld.setAttribute("aria-activedescendant", knoten[nr].id);
knoten[nr].scrollIntoView({ block: "nearest" });
}
function palOeffnen(nr) {
var t = palTreffer[nr];
if (!t) return;
paletteZu();
var knopf = document.querySelector('[data-tab="' + t.ziel + '"]');
if (knopf) knopf.click();
/* Bei einer Anfrage direkt die Detailansicht oeffnen. Nur in den
Reiter zu springen waere die halbe Strecke -- man muesste den
Treffer in der Liste noch einmal suchen, den man gerade
ausgewaehlt hat. */
if (t.art === "anfrage" && typeof zeigeDetail === "function") {
setTimeout(function () { zeigeDetail(t.id); }, 260);
}
}
async function palSuchen(begriff) {
var meine = ++palLauf;
if (!begriff || begriff.trim().length < 2) {
palHinweis("Mindestens zwei Zeichen eingeben.");
palZahl.textContent = "";
return;
}
palHinweis("Wird gesucht…", "laedt");
try {
var d = await api("/webdesign/admin/suche?q=" + encodeURIComponent(begriff));
if (meine !== palLauf) return; // eine neuere Abfrage ist schon unterwegs
palZeichnen(d);
} catch (fehler) {
if (meine !== palLauf) return;
/* Ein Fehler darf NICHT wie "nichts gefunden" aussehen. */
palHinweis("Die Suche ist fehlgeschlagen: " + (fehler.message || "unbekannt"), "fehler");
palZahl.textContent = "";
}
}
if (palette) {
palFeld.addEventListener("input", function () {
clearTimeout(palZeitgeber);
var wert = palFeld.value;
/* 180 ms: kurz genug, dass es sofort wirkt, lang genug, dass beim
Durchtippen eines Namens nicht jede Taste eine Abfrage ausloest. */
palZeitgeber = setTimeout(function () { palSuchen(wert); }, 180);
});
palFeld.addEventListener("keydown", function (e) {
if (e.key === "ArrowDown") { e.preventDefault(); palWaehlen(palAuswahl + 1); }
else if (e.key === "ArrowUp") { e.preventDefault(); palWaehlen(palAuswahl - 1); }
else if (e.key === "Enter") { e.preventDefault(); palOeffnen(palAuswahl); }
else if (e.key === "Escape") { e.preventDefault(); paletteZu(); }
else if (e.key === "Home" && palTreffer.length) { e.preventDefault(); palWaehlen(0); }
else if (e.key === "End" && palTreffer.length) { e.preventDefault(); palWaehlen(palTreffer.length - 1); }
});
palListe.addEventListener("click", function (e) {
var zeile = e.target.closest ? e.target.closest("[data-nr]") : null;
if (zeile) palOeffnen(Number(zeile.dataset.nr));
});
/* Mit der Maus darueberfahren waehlt mit aus -- sonst zeigt die
Tastaturauswahl auf eine andere Zeile als die, die man anklickt. */
palListe.addEventListener("mousemove", function (e) {
var zeile = e.target.closest ? e.target.closest("[data-nr]") : null;
if (zeile && Number(zeile.dataset.nr) !== palAuswahl) palWaehlen(Number(zeile.dataset.nr));
});
document.querySelectorAll("[data-palette-zu]").forEach(function (k) {
k.addEventListener("click", paletteZu);
});
/* Strg+K bzw. Cmd+K von ueberall.
Nicht abfangen, waehrend in einem anderen Feld getippt wird? Doch --
gerade dann ist es nuetzlich. Die Tastenkombination ist in Browsern
mit der Adressleiste belegt, deshalb preventDefault; das ist bei
dieser Kombination die uebliche Erwartung in Verwaltungsoberflaechen. */
document.addEventListener("keydown", function (e) {
if ((e.ctrlKey || e.metaKey) && (e.key === "k" || e.key === "K")) {
e.preventDefault();
palette.hidden ? paletteAuf() : paletteZu();
}
/* Esc schliesst auch, wenn der Fokus schon woanders liegt. */
if (e.key === "Escape" && !palette.hidden) paletteZu();
});
var lupe = document.getElementById("vw-suche-auf");
if (lupe) lupe.addEventListener("click", paletteAuf);
}
var aktiverTab = "uebersicht";
document.getElementById("vw-titel").textContent = ANSICHTEN[aktiverTab].titel;
/* ---------------------------------------------------------------------
Die Bereichs-Signatur umschalten.
Das Kennzeichen sitzt am Wurzelelement, nicht am Reiter: So kann das
Stilblatt allein daraus Motiv UND Leitfarbe ableiten, und es gibt
genau eine Stelle, an der sich der Bereich aendert.
Der Umweg ueber "animation neu ausloesen" ist noetig, weil eine
CSS-Animation nicht von selbst neu startet, wenn sich nur eine
Variable aendert. Ohne ihn springt das Bild hart um, statt sanft
zu wechseln.
--------------------------------------------------------------------- */
function signaturSetzen(bereich) {
if (!ANSICHTEN[bereich]) return; // unbekannt: lieber nichts tun
document.documentElement.setAttribute("data-vw-bereich", bereich);
var schmuck = document.querySelector(".vw-buehne");
if (!schmuck) return;
/* Wer Bewegung reduziert haben will, bekommt keinen Neustart -- die
Animation ist fuer ihn ohnehin abgeschaltet. */
try {
if (window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
} catch (e) { /* aeltere Browser: dann eben mit Animation */ }
schmuck.style.animation = "none";
void schmuck.offsetWidth; // erzwingt das Neuberechnen
schmuck.style.animation = "";
}
signaturSetzen(aktiverTab);
balkenSetzen();
/* ---------------------------------------------------------------------
Der Leuchtbalken unter der Reiterreihe.
Er wird am echten Reiter ausgemessen. Feste Werte waeren hier
zwangslaeufig falsch: Die Reiter sind unterschiedlich breit, sie
verschieben sich beim Sprachwechsel, und auf schmalen Schirmen
brechen sie in eine zweite Zeile um -- deshalb wandert auch die
Hoehe mit, nicht nur die Seite.
--------------------------------------------------------------------- */
function balkenSetzen() {
var leiste = document.querySelector("#vw-bereich .vw-tabs");
if (!leiste) return;
var aktiv = leiste.querySelector(".vw-filter-knopf.vw-an");
if (!aktiv) { leiste.style.setProperty("--vw-balken-da", "0"); return; }
var l = leiste.getBoundingClientRect();
var a = aktiv.getBoundingClientRect();
/* Relativ zur Leiste rechnen, nicht zum Fenster: Sonst wandert der
Balken beim Scrollen mit dem Fenster statt mit dem Reiter. */
leiste.style.setProperty("--vw-balken-x", Math.round(a.left - l.left) + "px");
leiste.style.setProperty("--vw-balken-b", Math.round(a.width) + "px");
leiste.style.setProperty("--vw-balken-y", Math.round(a.bottom - l.top + 4) + "px");
leiste.style.setProperty("--vw-balken-da", "1");
}
/* Neu vermessen, sobald sich an der Leiste etwas aendert.
Ein blosser "resize"-Horcher reicht nicht. Der haeufigste Fall ist
naemlich gar keine Fenstergroessenaenderung, sondern das
Sichtbarwerden: Beim Start ist der Arbeitsbereich noch versteckt,
die Leiste also 0 Punkte breit -- und ein verstecktes Element
loest kein resize aus. Genau das ist beim Bauen passiert: Der
Balken stand auf Breite 0 und blieb unsichtbar.
Ein ResizeObserver deckt beides ab -- Sichtbarwerden, Umbrechen der
Reiter auf schmalen Schirmen und den Sprachwechsel, bei dem die
Beschriftungen unterschiedlich lang werden. */
var balkenWartet = false;
function balkenNeuMessen() {
if (balkenWartet) return;
balkenWartet = true;
requestAnimationFrame(function () { balkenWartet = false; balkenSetzen(); });
}
window.addEventListener("resize", balkenNeuMessen, { passive: true });
/* Nach aussen verfuegbar machen.
Der Balken haengt sonst allein am Klick-Listener. Wechselt der
Bereich auf einem anderen Weg -- etwa direkt nach dem Anmelden,
wenn der Arbeitsbereich zum ersten Mal sichtbar wird -- wird nie
nachgemessen, und er bleibt auf Breite 0 stehen. */
window.vwBalkenSetzen = balkenSetzen;
(function balkenBeobachten() {
var leiste = document.querySelector("#vw-bereich .vw-tabs");
if (!leiste || typeof ResizeObserver === "undefined") return;
try {
new ResizeObserver(balkenNeuMessen).observe(leiste);
} catch (e) { /* aeltere Browser: dann eben nur bei resize */ }
})();
/* ---------------------------------------------------------------------
Die Kacheln neigen sich zum Zeiger.
Das Grundsystem setzt bereits --wd-lichtx/--wd-lichty in
Bildpunkten, gemessen ab der Kachelecke. Fuer eine Neigung braucht
es aber einen Wert, der von der Kachelgroesse unabhaengig ist:
-1 am linken Rand, +1 am rechten. Sonst wuerde eine breite Kachel
staerker kippen als eine schmale, obwohl der Zeiger relativ an
derselben Stelle steht.
Beim Verlassen wird auf 0 zurueckgesetzt -- sonst bliebe die
Kachel schraeg stehen, nachdem der Zeiger laengst weg ist.
--------------------------------------------------------------------- */
/* Die ECHTEN Kachelklassen der Verwaltung.
Beim ersten Anlauf stand hier nur ".wd-karte" -- die kommt im
Verwaltungsbereich aber fast nicht vor. Die Listen bestehen aus
.vw-karte, die Meldungen der Uebersicht aus .vw-meld. Saemtliche
Effekte lagen dadurch auf Elementen, die es hier gar nicht gibt:
Neigung, Glanz und Licht waren gebaut, gemessen -- und trotzdem
nirgends zu sehen. */
var KACHELN = "#vw-bereich .wd-karte, #vw-bereich .vw-karte, #vw-bereich .vw-meld";
(function kachelnNeigen() {
try {
if (!window.matchMedia("(hover: hover) and (pointer: fine)").matches) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
} catch (e) { return; }
var offen = false, letzte = null, zx = 0, zy = 0;
document.addEventListener("pointermove", function (e) {
var karte = e.target && e.target.closest
? e.target.closest(KACHELN) : null;
if (!karte) return;
letzte = karte; zx = e.clientX; zy = e.clientY;
if (offen) return;
offen = true;
requestAnimationFrame(function () {
offen = false;
if (!letzte) return;
var r = letzte.getBoundingClientRect();
if (!r.width || !r.height) return;
/* -1 bis +1, Nullpunkt in der Kachelmitte. */
var nx = ((zx - r.left) / r.width - 0.5) * 2;
var ny = ((zy - r.top) / r.height - 0.5) * 2;
letzte.style.setProperty("--vw-nx", nx.toFixed(3));
letzte.style.setProperty("--vw-ny", ny.toFixed(3));
/* Die Lichtposition gleich mitsetzen.
Der Verfolger im Grundsystem (wd-core.js) sucht ausdruecklich
nur ".wd-karte". Fuer .vw-karte und .vw-meld waere das Licht
also nie gewandert -- der Lichtkegel haette bei seinem
Startwert oben mittig geklebt. */
letzte.style.setProperty("--wd-lichtx", (zx - r.left) + "px");
letzte.style.setProperty("--wd-lichty", (zy - r.top) + "px");
});
}, { passive: true });
/* pointerout statt mouseleave: Es meldet zuverlaessig auch dann,
wenn der Zeiger die Kachel ueber ein Kindelement verlaesst. */
document.addEventListener("pointerout", function (e) {
var karte = e.target && e.target.closest
? e.target.closest(KACHELN) : null;
if (!karte) return;
/* Nur zuruecksetzen, wenn der Zeiger die Kachel WIRKLICH verlaesst
und nicht bloss von einem Kind zum naechsten wandert. */
if (e.relatedTarget && karte.contains(e.relatedTarget)) return;
karte.style.setProperty("--vw-nx", "0");
karte.style.setProperty("--vw-ny", "0");
if (letzte === karte) letzte = null;
}, { passive: true });
})();
/* ---------------------------------------------------------------------
Das Licht folgt dem Zeiger auch auf den Reitern.
Die Verfolgung im Grundsystem greift ausdruecklich nur auf Karten.
Hier wird sie fuer die Reiter ergaenzt -- gedrosselt ueber
requestAnimationFrame, aus demselben Grund wie dort.
--------------------------------------------------------------------- */
(function reiterLicht() {
try {
if (!window.matchMedia("(hover: hover) and (pointer: fine)").matches) return;
} catch (e) { return; }
var offen = false, letzter = null, zx = 0, zy = 0;
document.addEventListener("pointermove", function (e) {
var knopf = e.target && e.target.closest
? e.target.closest("#vw-bereich .vw-filter-knopf") : null;
if (!knopf) return;
letzter = knopf; zx = e.clientX; zy = e.clientY;
if (offen) return;
offen = true;
requestAnimationFrame(function () {
offen = false;
if (!letzter) return;
var r = letzter.getBoundingClientRect();
letzter.style.setProperty("--wd-lichtx", (zx - r.left) + "px");
letzter.style.setProperty("--wd-lichty", (zy - r.top) + "px");
});
}, { passive: true });
})();
/* ---------------------------------------------------------------------
Die Buehne bewegt sich leicht gegen den Mauszeiger.
Nur auf Geraeten mit echtem Zeiger: Auf einem Telefon gibt es kein
Schweben, dort waere der Effekt entweder wirkungslos oder wuerde bei
jeder Beruehrung zucken.
Gedrosselt ueber requestAnimationFrame -- ohne das rechnet der
Browser bei jeder einzelnen Zeigerbewegung neu, und das merkt man
ausgerechnet beim Scrollen durch lange Listen.
--------------------------------------------------------------------- */
(function buehneAtmet() {
var feinerZeiger = false;
try {
feinerZeiger = window.matchMedia &&
window.matchMedia("(hover: hover) and (pointer: fine)").matches;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
} catch (e) { return; }
if (!feinerZeiger) return;
var buehne = document.querySelector(".vw-buehne");
if (!buehne) return;
var lampe = document.querySelector(".vw-lampe");
var lampeAn = false;
var lampeZuletzt = 0;
var offen = false, letzteX = 0, letzteY = 0;
document.addEventListener("pointermove", function (e) {
letzteX = e.clientX;
letzteY = e.clientY;
if (offen) return;
offen = true;
requestAnimationFrame(function () {
offen = false;
var zeit = Date.now();
/* -0,5 bis +0,5 -- der Nullpunkt liegt in der Bildschirmmitte. */
var px = (letzteX / window.innerWidth) - 0.5;
var py = (letzteY / window.innerHeight) - 0.5;
buehne.style.setProperty("--vw-px", px.toFixed(3));
buehne.style.setProperty("--vw-py", py.toFixed(3));
/* Die Zeigerlampe braucht echte Bildpunkte, nicht den
Anteilswert: Ein radialer Verlauf mit Prozentangaben
verschiebt sich mit der Elementgroesse und laege dann nicht
mehr unter dem Zeiger. */
/* Die Lampe wird bewusst SELTENER nachgefuehrt als die Buehne.
Jede ihrer Bewegungen zwingt alle Glaskacheln darueber, ihre
Unschaerfe neu zu berechnen. Zwoelfmal je Sekunde reicht
voellig: Den Rest glaettet der Uebergang im Stilblatt, und
fuer das Auge bleibt die Bewegung fluessig. */
if (lampe && zeit - lampeZuletzt > 80) {
lampeZuletzt = zeit;
/* Versatz aus der Bildschirmmitte, nicht die Position selbst:
Die Lampe wird verschoben, nicht neu gezeichnet. */
lampe.style.setProperty("--vw-lx",
Math.round(letzteX - window.innerWidth / 2) + "px");
lampe.style.setProperty("--vw-ly",
Math.round(letzteY - window.innerHeight / 2) + "px");
if (!lampeAn) { lampe.classList.add("vw-lampe-an"); lampeAn = true; }
}
});
}, { passive: true });
})();
document.querySelectorAll("[data-tab]").forEach(function (b) {
b.addEventListener("click", function () {
aktiverTab = b.dataset.tab;
signaturSetzen(aktiverTab);
document.querySelectorAll("[data-tab]").forEach(function (x) {
x.classList.toggle("vw-an", x.dataset.tab === aktiverTab);
});
/* Erst NACH dem Umsetzen der Klasse messen -- vorher zeigt der
Balken noch auf den alten Reiter. */
balkenSetzen();
/* Auf dem Handy sind die Reiter ein Wischstreifen. Wer ueber eine
Kachel nach "Postfach" springt, landet sonst in einer Ansicht,
deren Reiter ausserhalb des Streifens liegt -- man sieht dann
nirgends, wo man ist. Deshalb den aktiven Reiter heranholen.
Nur waagerecht, damit die Seite nicht zusaetzlich springt. */
var aktiv = document.querySelector('[data-tab="' + aktiverTab + '"]');
if (aktiv && aktiv.scrollIntoView) {
try {
aktiv.scrollIntoView({ block: "nearest", inline: "center", behavior: "smooth" });
} catch (e) {
aktiv.scrollIntoView(); // aeltere Browser kennen die Angaben nicht
}
}
/* Vorher war das eine Ja/Nein-Umschaltung zwischen zwei Bereichen.
Mit vier Reitern geht das nicht mehr: Jede neue Ansicht haette
eine weitere Zeile "hidden = ..." gebraucht, und beim naechsten
Reiter vergisst man eine — dann liegen zwei Ansichten
uebereinander. Deshalb jetzt eine Zuordnung: Reiter -> Titel und
zugehoeriger Kasten. Alles andere wird ausgeblendet, ohne dass
man es einzeln aufzaehlen muss. */
var istAnfragen = aktiverTab === "anfragen";
document.getElementById("vw-titel").textContent = ANSICHTEN[aktiverTab].titel;
/* Die Werkzeuge oben (Zahlen, Filter, Suche) gehoeren zur
Anfragenliste und zu keiner anderen Ansicht. */
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;
Object.keys(ANSICHTEN).forEach(function (name) {
var id = ANSICHTEN[name].kasten;
if (id) document.getElementById(id).hidden = name !== aktiverTab;
});
if (aktiverTab === "uebersicht") ladeUebersicht();
if (aktiverTab === "anfragen") ladeAnfragen();
if (aktiverTab === "kunden") ladeKunden();
if (aktiverTab === "projekte") ladeProjekte();
if (aktiverTab === "zahlungen") ladeZahlungen();
if (aktiverTab === "postfach") ladePostfach();
});
});
/* =================================================================
PROJEKTE UND AUFGABEN
Der Kern des Wunsches vom 22.08.2026: "wenn sie drauf drücken sollen
sie auch sehen was ich schon gemacht habe von dem was im plan war."
Hier ist die Seite, auf der ich abhake — was hier passiert, sieht der
Kunde unmittelbar in seinem Portal.
================================================================= */
var KATEGORIEN = ["start", "gestaltung", "umsetzung", "abschluss"];
var KAT_TEXT = {
start: "Start", gestaltung: "Gestaltung",
umsetzung: "Umsetzung", abschluss: "Abschluss",
};
var offenesProjekt = null;
/* Die Phasen des Projekts — dieselbe Reihenfolge wie die Leiste, die
der Kunde in seinem Portal sieht. "pausiert" und "abgebrochen"
stehen bewusst am Ende und ausserhalb der Reihe: Sie sind keine
Phase, sondern ein Zustand daneben. */
var PHASEN = ["briefing", "angebot", "design", "entwicklung", "tests", "abnahme", "uebergeben"];
var PHASEN_SONDER = ["pausiert", "abgebrochen"];
var PHASE_TEXT = {
briefing: "Briefing", angebot: "Angebot", design: "Design",
entwicklung: "Entwicklung", tests: "Tests", abnahme: "Abnahme",
uebergeben: "Übergeben", pausiert: "Pausiert", abgebrochen: "Abgebrochen",
};
var AE_TEXT = {
eingereicht: "Eingereicht", geprueft: "Wird geprüft", angeboten: "Angebot liegt vor",
angenommen: "Angenommen", abgelehnt: "Abgelehnt", umgesetzt: "Umgesetzt",
};
/* Der Balken in vier Abschnitten. Ein einzelner Balken sagt "60 %",
vier Abschnitte sagen zusaetzlich WO diese 60 % liegen. */
function balken(f) {
return '<div class="vw-fortschritt">' + KATEGORIEN.map(function (k) {
var d = (f.jeKategorie && f.jeKategorie[k]) || { gesamt: 0, erledigt: 0 };
var breite = d.gesamt ? Math.round((d.erledigt / d.gesamt) * 100) : 0;
return '<span class="vw-fs-teil" data-k="' + k + '" title="' + KAT_TEXT[k] + ": " +
d.erledigt + " von " + d.gesamt + '"><i style="width:' + breite + '%"></i></span>';
}).join("") + "</div>";
}
async function ladeProjekte() {
var ziel = document.getElementById("vw-projekte-liste");
ziel.innerHTML = '<div class="vw-laedt">Wird geladen…</div>';
try {
var d = await api("/webdesign/admin/projekte");
if (!d.projekte.length) {
ziel.innerHTML = '<div class="vw-leer-hinweis">Noch keine Projekte. ' +
"Sie entstehen aus einer Anfrage über „Anfrage übernehmen“ oder " +
"unter „Kunden“ über „+ Projekt“.</div>";
return;
}
ziel.innerHTML = d.projekte.map(function (p) {
var gesamt = p.aufg_gesamt || 0;
var fertig = p.aufg_erledigt || 0;
var prozent = gesamt ? Math.round((fertig / gesamt) * 100) : 0;
/* Der Balken braucht die Aufteilung je Kategorie, die die Liste
aus Sparsamkeit nicht mitliefert. Deshalb hier ein einzelner
Balken über alles — die genaue Aufteilung erscheint beim
Oeffnen. Ein falscher Vierfarbbalken waere schlimmer als ein
ehrlicher einfarbiger. */
return '<button type="button" class="vw-karte" data-projekt="' + s(p.id) + '">' +
'<span class="vw-karte-nr">' + s(p.nummer) + "</span>" +
'<span class="vw-karte-mitte">' +
'<span class="vw-karte-name">' + s(p.titel) + "</span>" +
'<span class="vw-karte-info">' + s(p.kunde_name) +
(p.kunde_firma ? " · " + s(p.kunde_firma) : "") + "</span>" +
'<span class="vw-fortschritt"><span class="vw-fs-teil" data-k="umsetzung" ' +
'style="flex:1"><i style="width:' + prozent + '%"></i></span></span>' +
'<span class="vw-fs-text"><b>' + fertig + " von " + gesamt + "</b> erledigt" +
(gesamt ? " · " + prozent + " %" : " — noch keine Aufgabenliste") + "</span>" +
"</span>" +
'<span class="vw-karte-rechts">' +
(p.aufg_kunde
? '<span class="vw-wartet">wartet auf Kunde: ' + p.aufg_kunde + "</span>"
: '<span class="vw-status vw-status--angenommen">alles bei dir</span>') +
(p.ungelesen ? '<span class="vw-abzeichen">' + p.ungelesen + "</span>" : "") +
'<span class="vw-karte-zeit">' + s(PAKET_TEXT[p.paket] || p.paket) + "</span>" +
"</span></button>";
}).join("");
ziel.querySelectorAll("[data-projekt]").forEach(function (b) {
b.addEventListener("click", function () { oeffneAufgaben(b.dataset.projekt); });
});
} catch (fehler) {
if (fehler.status === 401) { token = null; tokenSchreiben(null); zeigeAnmeldung(); return; }
ziel.innerHTML = "";
window.WD.meldung(meldung, fehler.message, "fehler");
}
}
/* Eine einzelne Aufgabenzeile. */
function aufgabeZeile(a) {
var klasse = "vw-aufg";
if (a.status === "erledigt") klasse += " vw-aufg--erledigt";
else if (a.status === "in_arbeit") klasse += " vw-aufg--arbeit";
else if (a.status === "entfaellt") klasse += " vw-aufg--weg";
/* Der goldene Hintergrund nur, wenn der Punkt NOCH offen ist. Ein
erledigter Kundenpunkt braucht keine Aufmerksamkeit mehr. */
if (a.wer_dran === "kunde" && a.status !== "erledigt" && a.status !== "entfaellt") {
klasse += " vw-aufg--kunde";
}
var zeichen = a.status === "erledigt" ? "✓" : a.status === "in_arbeit" ? "•" : a.status === "entfaellt" ? "–" : "";
var naechster = a.status === "offen" ? "in_arbeit" : a.status === "in_arbeit" ? "erledigt" : "offen";
return '<div class="' + klasse + '" data-aufgabe="' + s(a.id) + '">' +
'<button type="button" class="vw-aufg-haken" data-weiter="' + naechster + '" ' +
'title="' + (a.status === "erledigt" ? "Wieder öffnen" : a.status === "in_arbeit" ? "Als erledigt setzen" : "In Arbeit setzen") + '" ' +
'aria-label="Zustand ändern: ' + s(a.titel) + '">' + zeichen + "</button>" +
'<span class="vw-aufg-titel">' + s(a.titel) +
(a.nicht_enthalten ? '<span class="vw-aufg-nicht">nicht enthalten</span>' : "") + "</span>" +
'<span class="vw-aufg-rechts">' +
(a.wer_dran === "kunde" && a.status !== "erledigt" ? '<span class="vw-aufg-wer">Kunde</span>' : "") +
'<button type="button" class="vw-mini" data-entfaellt="' + (a.status === "entfaellt" ? "0" : "1") +
'" title="' + (a.status === "entfaellt" ? "Doch wieder aufnehmen" : "Entfällt — zählt dann nicht mehr mit") + '">' +
(a.status === "entfaellt" ? "↺" : "∅") + "</button>" +
'<button type="button" class="vw-mini" data-loeschen="1" title="Punkt löschen">✕</button>' +
"</span>" +
(a.beschreibung || a.hinweis
? '<span class="vw-aufg-text">' + s(a.beschreibung || "") +
(a.hinweis ? " " + s(a.hinweis) : "") + "</span>"
: "") +
"</div>";
}
async function oeffneAufgaben(projektId) {
offenesProjekt = projektId;
detail.classList.add("vw-auf");
document.body.style.overflow = "hidden";
blatt.innerHTML = '<div class="vw-laedt">Wird geladen…</div>';
try {
var d = await api("/webdesign/admin/projekte/" + encodeURIComponent(projektId) + "/alles");
zeichneAufgaben(d);
} catch (fehler) {
if (fehler.status === 401) { schliesseDetail(); token = null; tokenSchreiben(null); zeigeAnmeldung(); return; }
blatt.innerHTML = '<div class="vw-leer-hinweis">' + s(fehler.message) + "</div>";
}
}
function zeichneAufgaben(d) {
var p = d.projekt, f = d.fortschritt;
var gruppen = KATEGORIEN.map(function (k) {
var drin = d.aufgaben.filter(function (a) { return a.kategorie === k; });
if (!drin.length) return "";
var z = f.jeKategorie[k] || { gesamt: 0, erledigt: 0 };
return '<div class="vw-aufg-gruppe" data-k="' + k + '">' +
'<div class="vw-aufg-kopf">' + KAT_TEXT[k] +
'<span class="vw-aufg-zahl">' + z.erledigt + " / " + z.gesamt + "</span></div>" +
drin.map(aufgabeZeile).join("") +
'<button type="button" class="vw-mini" data-neu="' + k + '" ' +
'style="margin-top:.2rem;min-width:auto;padding:.25rem .6rem">+ Punkt in „' +
KAT_TEXT[k] + '“</button>' +
"</div>";
}).join("");
blatt.innerHTML =
'<div class="vw-detail-kopf">' +
'<div><div class="vw-karte-nr" style="font-size:.9rem">' + s(p.nummer) + "</div>" +
'<h2 style="margin:.2rem 0 .3rem;font-size:1.4rem">' + s(p.titel) + "</h2>" +
'<span class="wd-klein wd-leise">' + s(p.kunde_name) + " · " +
s(PAKET_TEXT[p.paket] || p.paket) + "</span></div>" +
'<button class="vw-zu" type="button" aria-label="Schließen">✕</button>' +
"</div>" +
/* Die Zusammenfassung steht oben, weil sie die Frage beantwortet,
mit der man diese Ansicht oeffnet. */
'<div class="vw-blick">' +
'<div class="vw-blick-feld"><span class="vw-blick-was">Erledigt</span>' +
'<span class="vw-blick-wert">' + f.erledigt + " von " + f.gesamt + "</span></div>" +
'<div class="vw-blick-feld"><span class="vw-blick-was">Fortschritt</span>' +
'<span class="vw-blick-wert">' + f.prozent + " %</span></div>" +
'<div class="vw-blick-feld"><span class="vw-blick-was">In Arbeit</span>' +
'<span class="vw-blick-wert' + (f.inArbeit ? "" : " vw-leer") + '">' +
(f.inArbeit || "nichts") + "</span></div>" +
'<div class="vw-blick-feld"><span class="vw-blick-was">Wartet auf Kunde</span>' +
'<span class="vw-blick-wert' + (f.beimKunden ? "" : " vw-leer") + '">' +
(f.beimKunden || "nichts") + "</span></div>" +
"</div>" +
balken(f) +
/* Gleiche Aufteilung wie in der Anfrageansicht: links die Arbeit,
rechts das Steuern. Wer zwischen beiden Ansichten wechselt, muss
nicht umdenken. */
'<div class="vw-detail-spalten">' +
'<div class="vw-dt-links">' +
(d.aufgaben.length ? gruppen :
'<div class="vw-leer-hinweis">Für dieses Projekt gibt es noch keine Aufgabenliste.' +
'<div style="margin-top:1rem"><button type="button" class="wd-btn wd-btn--haupt wd-btn--klein" ' +
'id="vw-vorlage">Standardliste für „' + s(PAKET_TEXT[p.paket] || p.paket) + '“ einsetzen</button></div></div>') +
(d.aufgaben.length
? '<p class="wd-klein wd-leise" style="margin-top:1rem;border-top:1px solid var(--wd-rand);padding-top:.8rem">' +
"Klick auf das Kästchen schaltet weiter: offen → in Arbeit → erledigt. " +
"Der Kunde sieht jede Änderung sofort in seinem Portal. " +
'<button type="button" class="vw-mini" id="vw-vorlage" style="min-width:auto;padding:.25rem .6rem">' +
"Fehlende Standardpunkte ergänzen</button></p>"
: "") +
"</div>" +
'<div class="vw-dt-rechts">' +
/* ----- STAND -----
Das Wichtigste an dieser ganzen Erweiterung. Phase und "nächster
Schritt" stehen im Portal des Kunden GANZ OBEN — und liessen
sich bis hierher nirgends ändern. Ein Projekt blieb dadurch für
immer im Briefing stehen, egal wie weit es wirklich war. */
'<div class="vw-abschnitt"><h3>Stand</h3>' +
'<p class="wd-klein wd-leise" style="margin-top:-.2rem">' +
"Das sieht der Kunde ganz oben in seinem Portal." +
"</p>" +
'<div class="vw-status-wahl" style="margin-bottom:.7rem">' +
PHASEN.map(function (k, i) {
return '<button type="button" class="vw-filter-knopf' + (p.status === k ? " vw-an" : "") +
'" data-phase="' + k + '">' + (i + 1) + ". " + s(PHASE_TEXT[k]) + "</button>";
}).join("") +
"</div>" +
'<div class="vw-status-wahl" style="margin-bottom:.9rem">' +
PHASEN_SONDER.map(function (k) {
return '<button type="button" class="vw-filter-knopf' + (p.status === k ? " vw-an" : "") +
'" data-phase="' + k + '">' + s(PHASE_TEXT[k]) + "</button>";
}).join("") +
"</div>" +
'<div class="wd-feld" style="margin-bottom:.6rem">' +
'<label for="vw-p-schritt">Nächster Schritt</label>' +
'<input type="text" id="vw-p-schritt" maxlength="300" value="' + s(p.naechster_schritt || "") +
'" placeholder="z. B. Entwurf ansehen und Rückmeldung geben" />' +
'<span class="wd-feld-hilfe">Ein Satz in der Sprache des Kunden — nicht „QA-Runde 2“.</span>' +
"</div>" +
'<div class="wd-feld" style="margin-bottom:.6rem">' +
"<label>Wer ist am Zug?</label>" +
'<div class="vw-status-wahl">' +
'<button type="button" class="vw-filter-knopf' + (p.wartet_auf === "dogfather" ? " vw-an" : "") +
'" data-wartet="dogfather">Ich</button>' +
'<button type="button" class="vw-filter-knopf' + (p.wartet_auf === "kunde" ? " vw-an" : "") +
'" data-wartet="kunde">Der Kunde</button>' +
"</div>" +
"</div>" +
'<div class="wd-feld" style="margin-bottom:.8rem">' +
'<label for="vw-p-termin">Richttermin</label>' +
'<input type="text" id="vw-p-termin" maxlength="40" value="' + s(p.richttermin || "") +
'" placeholder="z. B. Mitte Oktober" />' +
"</div>" +
'<button type="button" class="wd-btn wd-btn--haupt wd-btn--klein" id="vw-p-speichern">Stand speichern</button>' +
"</div>" +
/* ----- ZAHLUNGSSTAND ----- */
'<div class="vw-abschnitt"><h3>Zahlungsstand</h3>' +
'<label class="vw-pf-schalter" style="margin-bottom:.5rem">' +
'<input type="checkbox" id="vw-p-anz"' + (p.anzahlung_bezahlt ? " checked" : "") + " />" +
"<span>Anzahlung ist eingegangen</span></label>" +
'<label class="vw-pf-schalter" style="margin-bottom:.5rem">' +
'<input type="checkbox" id="vw-p-rest"' + (p.rest_bezahlt ? " checked" : "") + " />" +
"<span>Restbetrag ist eingegangen</span></label>" +
'<label class="vw-pf-schalter">' +
'<input type="checkbox" id="vw-p-portfolio"' + (p.portfolio_freigabe ? " checked" : "") + " />" +
"<span>Darf ins Portfolio</span></label>" +
'<p class="wd-klein wd-leise" style="margin-top:.5rem">Wird sofort gespeichert.</p>' +
"</div>" +
/* ----- ÄNDERUNGSWÜNSCHE -----
Der Kunde konnte Ideen einreichen, seit es das Portal gibt. Sie
zu beziffern ging serverseitig auch — nur gab es keine
Oberfläche. Der Kreis war also offen: Er schickt etwas los und
hört nie wieder davon. */
'<div class="vw-abschnitt"><h3>Änderungswünsche (' + d.aenderungen.length + ")</h3>" +
(d.aenderungen.length
? d.aenderungen.map(function (a) {
var offen = a.status === "eingereicht" || a.status === "geprueft";
return '<div class="vw-notiz" data-ae="' + s(a.id) + '">' +
'<div class="vw-notiz-kopf">' + window.WD.datum(a.erstellt_am) +
' · <span class="wd-tag' + (offen ? " wd-tag--gold" : a.status === "angenommen" ? " wd-tag--ok" : "") +
'">' + s(AE_TEXT[a.status] || a.status) + "</span></div>" +
"<p><strong>" + s(a.titel) + "</strong></p>" +
"<p>" + s(a.beschreibung || "") + "</p>" +
(a.zusatzpreis_cent != null
? '<p class="wd-klein wd-leise">Beziffert: ' +
(a.zusatzpreis_cent / 100).toFixed(2).replace(".", ",") + " €" +
(a.zusatzdauer_text ? " · " + s(a.zusatzdauer_text) : "") + "</p>"
: "") +
(offen
? '<div style="margin-top:.6rem">' +
'<div class="wd-feld" style="margin-bottom:.4rem">' +
'<input type="number" class="vw-ae-preis" min="0" step="1" inputmode="numeric" ' +
'placeholder="Preis in Euro" />' +
"</div>" +
'<div class="wd-feld" style="margin-bottom:.4rem">' +
'<input type="text" class="vw-ae-dauer" maxlength="200" placeholder="Zusätzliche Dauer, z. B. 3 Tage" />' +
"</div>" +
'<div class="wd-feld" style="margin-bottom:.5rem">' +
'<textarea class="vw-ae-antwort" rows="2" placeholder="Kurze Erklärung für den Kunden"></textarea>' +
"</div>" +
'<button type="button" class="wd-btn wd-btn--zweit wd-btn--klein" data-beziffern="' +
s(a.id) + '">Angebot an den Kunden schicken</button></div>'
: "") +
"</div>";
}).join("")
: '<p class="wd-klein wd-leise">Noch keine Wünsche eingereicht.</p>') +
"</div>" +
/* ----- NACHRICHTEN ZUM PROJEKT -----
Getrennt vom allgemeinen Postfach, weil sie hier zum Projekt
gehören und im Portal auch dort erscheinen. Sie hier NICHT zu
haben hiess: Der Kunde schreibt im Projekt, und ich kann ihm nur
woanders antworten. */
'<div class="vw-abschnitt"><h3>Nachrichten zum Projekt (' + d.nachrichten.length + ")</h3>" +
(d.nachrichten.length
? '<div class="vw-pf-verlauf" style="max-height:34vh" id="vw-p-blasen">' +
d.nachrichten.map(function (n) {
var vomKunden = n.autor === "kunde";
return '<div class="vw-blase vw-blase--' + (vomKunden ? "kunde" : "wir") + '">' +
'<div class="vw-blase-kopf">' +
(vomKunden ? s(p.kunde_name) : "Du") + " · " + window.WD.datum(n.erstellt_am) +
"</div>" + s(n.text) + "</div>";
}).join("") + "</div>"
: '<p class="wd-klein wd-leise">Noch keine Nachrichten.</p>') +
'<div class="wd-feld" style="margin:.7rem 0 .5rem">' +
'<textarea id="vw-p-nachricht" rows="2" placeholder="Antwort zum Projekt…"></textarea>' +
"</div>" +
'<button type="button" class="wd-btn wd-btn--zweit wd-btn--klein" id="vw-p-senden">Senden</button>' +
"</div>" +
/* ----- ABBRECHEN -----
Steht ganz unten und zurückhaltend gestaltet: Es ist die
seltenste und die endgültigste Handlung an einem Projekt. Ein
gleich lauter Knopf zwischen den anderen lädt zum Verwechseln
ein. */
(p.status === "abgebrochen"
? '<div class="vw-abschnitt vw-abbruch-info"><h3>Abgebrochen</h3>' +
'<p class="wd-klein">' +
(p.abbruch_am ? s(window.WD.datum(p.abbruch_am)) : "") +
(p.abbruch_grund ? " · Grund: " + s(ABBRUCH_TEXT[p.abbruch_grund] || p.abbruch_grund) : "") +
(p.abbruch_wer ? " · durch " + (p.abbruch_wer === "kunde" ? "den Kunden" : "dich") : "") +
"</p>" +
(p.abbruch_erstattung_cent
? '<p class="wd-klein">Zu erstatten war: <strong>' +
(p.abbruch_erstattung_cent / 100).toFixed(2).replace(".", ",") + " €</strong></p>"
: '<p class="wd-klein wd-leise">Es war nichts zu erstatten.</p>') +
"</div>"
: '<div class="vw-abschnitt"><h3>Auftrag abbrechen</h3>' +
'<p class="wd-klein wd-leise">Wenn der Kunde nicht zahlt, sich nicht mehr meldet ' +
'oder selbst abspringt. Die Seite rechnet dir vor, wie viel Arbeit erledigt ist, ' +
'und schlägt daraus einen Erstattungsbetrag vor.</p>' +
'<button type="button" class="wd-btn wd-btn--still wd-btn--klein" id="vw-abbruch-auf">' +
"Abbruch vorbereiten</button>" +
'<div id="vw-abbruch-form" hidden style="margin-top:.9rem">' +
'<div id="vw-abbruch-rechnung" class="vw-abbruch-rechnung">wird berechnet…</div>' +
'<div class="wd-feld"><label for="vw-ab-grund2">Grund</label>' +
'<select id="vw-ab-grund2">' +
'<option value="keine_zahlung">Der Kunde hat nicht gezahlt</option>' +
'<option value="kunde_abgesprungen">Der Kunde ist abgesprungen</option>' +
'<option value="keine_mitwirkung">Keine Mitwirkung (Texte, Bilder, Rückmeldung)</option>' +
'<option value="einvernehmlich">Einvernehmlich beendet</option>' +
'<option value="mein_ruecktritt">Ich trete zurück</option>' +
'<option value="sonstiges">Sonstiges</option>' +
"</select></div>" +
'<div class="wd-feld"><label for="vw-ab-wer">Wer bricht ab?</label>' +
'<select id="vw-ab-wer">' +
'<option value="kunde">Der Kunde</option>' +
'<option value="dogfather">Ich</option>' +
"</select></div>" +
'<div class="wd-feld"><label for="vw-ab-erstattung">Zu erstatten (Euro)</label>' +
'<input type="number" id="vw-ab-erstattung" min="0" step="0.01" inputmode="decimal" />' +
'<span class="wd-feld-hilfe">Vorschlag aus dem Fortschritt — änderbar.</span></div>' +
'<div class="wd-feld"><label for="vw-ab-notiz">Notiz ' +
'<span class="wd-leise">optional, nur für dich</span></label>' +
'<textarea id="vw-ab-notiz" rows="2"></textarea></div>' +
'<button type="button" class="wd-btn wd-btn--klein" id="vw-abbruch-los">' +
"Auftrag jetzt abbrechen</button>" +
"</div></div>") +
"</div></div>";
blatt.querySelector(".vw-zu").addEventListener("click", schliesseDetail);
/* ---- Abbrechen ---- */
var abbAuf = blatt.querySelector("#vw-abbruch-auf");
if (abbAuf) {
var abbForm = blatt.querySelector("#vw-abbruch-form");
abbAuf.addEventListener("click", async function () {
abbForm.hidden = !abbForm.hidden;
abbAuf.textContent = abbForm.hidden ? "Abbruch vorbereiten" : "Doch nicht abbrechen";
if (abbForm.hidden) return;
/* Erst beim Aufklappen rechnen lassen. Die Zahlen sollen den
Stand von JETZT zeigen, nicht den vom Öffnen der Projektseite
— dazwischen kann man Punkte abgehakt haben. */
try {
var v = await api("/webdesign/admin/projekte/" + encodeURIComponent(p.id) + "/abbruch-vorschau");
var r = v.rechnung || {};
blatt.querySelector("#vw-abbruch-rechnung").innerHTML =
"<p><strong>" + r.fertig + " von " + r.gesamt + " Schritten erledigt (" +
r.prozent + " %)</strong></p>" +
"<p>Bezahlt: " + s(r.bezahltText || "0,00 €") + " · davon verdient: " +
s(r.behaltenText || "0,00 €") + "</p>" +
'<p class="vw-abbruch-vorschlag">Vorschlag zur Erstattung: <strong>' +
s(r.erstattungText || "0,00 €") + "</strong></p>";
blatt.querySelector("#vw-ab-erstattung").value =
((r.erstattungCent || 0) / 100).toFixed(2);
} catch (f) {
/* Ohne Vorschau trotzdem abbrechen können: Der Betrag lässt
sich von Hand eintragen. Ein ausgefallener Rechendienst darf
kein Projekt in der Liste festhalten. */
blatt.querySelector("#vw-abbruch-rechnung").innerHTML =
'<p class="wd-klein">Die Vorschau konnte nicht geladen werden: ' + s(f.message) +
" Du kannst den Betrag von Hand eintragen.</p>";
}
});
blatt.querySelector("#vw-abbruch-los").addEventListener("click", async function (ev) {
/* Nachfragen: Danach ist das Projekt abgebrochen, und offene
Rechnungen sind storniert. */
if (!confirm("Auftrag " + p.nummer + " wirklich abbrechen? Offene Rechnungen werden storniert.")) return;
window.WD.knopfSperren(ev.target, true, "Wird abgebrochen…");
try {
var a = await api("/webdesign/admin/projekte/" + encodeURIComponent(p.id) + "/abbrechen", {
method: "POST",
body: {
grund: blatt.querySelector("#vw-ab-grund2").value,
wer: blatt.querySelector("#vw-ab-wer").value,
erstattungEuro: Number(blatt.querySelector("#vw-ab-erstattung").value) || 0,
notiz: blatt.querySelector("#vw-ab-notiz").value,
},
});
schliesseDetail();
await ladeProjekte();
window.WD.meldung(meldung, a.hinweis || "Abgebrochen.", "ok");
} catch (f) {
window.WD.knopfSperren(ev.target, false);
window.WD.meldung(meldung, "Abbrechen fehlgeschlagen: " + f.message, "fehler");
}
});
}
/* Zustand weiterschalten. */
blatt.querySelectorAll("[data-weiter]").forEach(function (b) {
b.addEventListener("click", function () {
aufgabeAendern(b.closest("[data-aufgabe]").dataset.aufgabe, { status: b.dataset.weiter });
});
});
blatt.querySelectorAll("[data-entfaellt]").forEach(function (b) {
b.addEventListener("click", function () {
aufgabeAendern(b.closest("[data-aufgabe]").dataset.aufgabe,
{ status: b.dataset.entfaellt === "1" ? "entfaellt" : "offen" });
});
});
blatt.querySelectorAll("[data-loeschen]").forEach(function (b) {
b.addEventListener("click", async function () {
var id = b.closest("[data-aufgabe]").dataset.aufgabe;
/* Nachfragen, weil Loeschen hier wirklich loescht (kein Archiv).
Begruendung dafuer steht im Server-Code. */
if (!confirm("Diesen Punkt wirklich löschen? Das lässt sich nicht rückgängig machen.")) return;
try {
await api("/webdesign/admin/aufgaben/" + encodeURIComponent(id) + "/loeschen", { method: "POST", body: {} });
oeffneAufgaben(offenesProjekt);
} catch (f2) { window.WD.meldung(meldung, f2.message, "fehler"); }
});
});
blatt.querySelectorAll("[data-neu]").forEach(function (b) {
b.addEventListener("click", async function () {
var titel = prompt("Was soll als Punkt in „" + KAT_TEXT[b.dataset.neu] + "“ stehen?");
if (!titel || !titel.trim()) return;
var wer = confirm("Muss der KUNDE das erledigen?\n\nOK = Kunde · Abbrechen = du") ? "kunde" : "dogfather";
try {
await api("/webdesign/admin/projekte/" + encodeURIComponent(offenesProjekt) + "/aufgaben",
{ method: "POST", body: { titel: titel.trim(), kategorie: b.dataset.neu, werDran: wer } });
oeffneAufgaben(offenesProjekt);
} catch (f2) { window.WD.meldung(meldung, f2.message, "fehler"); }
});
});
/* ---- Stand ändern ----
Phase und "wer ist am Zug" werden SOFORT gespeichert, ohne
Speichern-Knopf: Es ist ein Klick auf genau einen Wert, da wäre
ein zweiter Klick reine Zeremonie. Die Textfelder brauchen den
Knopf, weil man beim Tippen zwischendurch nicht speichern will. */
function standAendern(felder, danach) {
return api("/webdesign/admin/projekte/" + encodeURIComponent(offenesProjekt),
{ method: "POST", body: felder })
.then(function () { if (danach) danach(); })
.catch(function (f) { window.WD.meldung(meldung, f.message, "fehler"); });
}
blatt.querySelectorAll("[data-phase]").forEach(function (b) {
b.addEventListener("click", function () {
blatt.querySelectorAll("[data-phase]").forEach(function (x) {
x.classList.toggle("vw-an", x === b);
});
standAendern({ status: b.dataset.phase });
});
});
blatt.querySelectorAll("[data-wartet]").forEach(function (b) {
b.addEventListener("click", function () {
blatt.querySelectorAll("[data-wartet]").forEach(function (x) {
x.classList.toggle("vw-an", x === b);
});
standAendern({ wartetAuf: b.dataset.wartet });
});
});
var speichern = blatt.querySelector("#vw-p-speichern");
speichern.addEventListener("click", function () {
window.WD.knopfSperren(speichern, true, "…");
standAendern({
naechsterSchritt: blatt.querySelector("#vw-p-schritt").value,
richttermin: blatt.querySelector("#vw-p-termin").value,
}).then(function () {
window.WD.knopfSperren(speichern, false);
window.WD.meldung(meldung, "Gespeichert — der Kunde sieht es sofort.", "ok");
});
});
[["#vw-p-anz", "anzahlungBezahlt"], ["#vw-p-rest", "restBezahlt"],
["#vw-p-portfolio", "portfolioFreigabe"]].forEach(function (paar2) {
var el = blatt.querySelector(paar2[0]);
el.addEventListener("change", function () {
var felder = {};
felder[paar2[1]] = el.checked;
standAendern(felder);
});
});
/* ---- Änderungswunsch beziffern ---- */
blatt.querySelectorAll("[data-beziffern]").forEach(function (b) {
b.addEventListener("click", async function () {
var kasten = b.closest("[data-ae]");
var preis = kasten.querySelector(".vw-ae-preis").value;
if (!preis || Number(preis) < 0) {
window.WD.meldung(meldung,
"Bitte einen Preis angeben — ohne Preis kann der Kunde nicht entscheiden.", "fehler");
kasten.querySelector(".vw-ae-preis").focus();
return;
}
window.WD.knopfSperren(b, true, "…");
try {
await api("/webdesign/admin/aenderungen/" + encodeURIComponent(b.dataset.beziffern) + "/beziffern",
{ method: "POST", body: {
preisEuro: Number(preis),
dauer: kasten.querySelector(".vw-ae-dauer").value,
antwort: kasten.querySelector(".vw-ae-antwort").value,
} });
window.WD.meldung(meldung, "Angebot ist beim Kunden. Er entscheidet in seinem Portal.", "ok");
oeffneAufgaben(offenesProjekt);
} catch (f2) {
window.WD.knopfSperren(b, false);
window.WD.meldung(meldung, f2.message, "fehler");
}
});
});
/* ---- Nachricht zum Projekt ---- */
var blasen = blatt.querySelector("#vw-p-blasen");
if (blasen) blasen.scrollTop = blasen.scrollHeight;
var pSenden = blatt.querySelector("#vw-p-senden");
pSenden.addEventListener("click", async function () {
var feld = blatt.querySelector("#vw-p-nachricht");
var inhalt = feld.value.trim();
if (!inhalt) { feld.focus(); return; }
window.WD.knopfSperren(pSenden, true, "…");
try {
await api("/webdesign/admin/projekt-nachricht",
{ method: "POST", body: { projektId: offenesProjekt, text: inhalt } });
oeffneAufgaben(offenesProjekt);
} catch (f2) {
window.WD.knopfSperren(pSenden, false);
window.WD.meldung(meldung, f2.message, "fehler");
}
});
var vorlage = blatt.querySelector("#vw-vorlage");
if (vorlage) {
vorlage.addEventListener("click", async function () {
window.WD.knopfSperren(vorlage, true, "…");
try {
var r = await api("/webdesign/admin/projekte/" + encodeURIComponent(offenesProjekt) + "/aufgaben/vorlage",
{ method: "POST", body: {} });
window.WD.meldung(meldung,
r.neu + " Punkte eingesetzt" + (r.uebersprungen ? ", " + r.uebersprungen + " waren schon da." : "."), "ok");
oeffneAufgaben(offenesProjekt);
} catch (f2) {
window.WD.knopfSperren(vorlage, false);
window.WD.meldung(meldung, f2.message, "fehler");
}
});
}
}
async function aufgabeAendern(id, felder) {
/* Die Zeile wird sofort umgestellt, ohne auf den Server zu warten.
Bei einer Liste, in der man zehn Punkte hintereinander abhakt,
waere ein Neuaufbau nach jedem Klick zaeh — und man verliert die
Stelle, an der man gerade war. Geht es schief, wird die ganze
Liste neu geholt und der Zustand ist wieder ehrlich. */
var zeile = blatt.querySelector('[data-aufgabe="' + id + '"]');
if (zeile) zeile.style.opacity = ".5";
try {
await api("/webdesign/admin/aufgaben/" + encodeURIComponent(id), { method: "POST", body: felder });
oeffneAufgaben(offenesProjekt);
} catch (fehler) {
if (zeile) zeile.style.opacity = "";
window.WD.meldung(meldung, fehler.message, "fehler");
oeffneAufgaben(offenesProjekt);
}
}
/* =================================================================
ZAHLUNGEN UND PAYPAL-EINRICHTUNG
Filipe soll die Zugangsdaten HIER eintragen koennen und nicht per
SSH in einer Datei. Die Werte gehen verschluesselt in die Datenbank
und wirken sofort -- ohne Dienstneustart.
Was hier NIE passiert: einen hinterlegten Wert wieder anzeigen. Der
Server gibt ihn gar nicht erst heraus. Sichtbar ist nur, ob etwas da
ist, woher es stammt und wie lang es ist.
================================================================= */
var PP_FELDER = [
{ k: "PAYPAL_ENV", titel: "Betriebsart", art: "wahl",
hilfe: "live = echtes Geld. sandbox = Testbetrieb, es fliesst nichts." },
{ k: "PAYPAL_CLIENT_ID", titel: "Client ID", art: "text",
hilfe: "developer.paypal.com → Apps & Credentials → deine App" },
{ k: "PAYPAL_CLIENT_SECRET", titel: "Secret", art: "geheim",
hilfe: "Dort daneben auf „Show“ klicken. Wird nach dem Speichern nie wieder angezeigt." },
/* Die Webhook-Kennung hat KEINE Vorsilbe -- sie sieht aus wie
0NH55953DH663215D. Das "WH-" steht auf den Ereignis-Kennungen
(WH-3F562076HD293871E-...), nicht auf der Webhook-Kennung selbst.
In der ersten Fassung stand hier "Beginnt mit WH-"; Filipe hat zu
Recht nachgefragt, weil seine anders aussah. */
{ k: "PAYPAL_WD_WEBHOOK_ID", titel: "Webhook-Kennung", art: "text",
hilfe: "Steht bei developer.paypal.com unter deiner App bei „Webhooks“. Etwa 17 Zeichen, "
+ "z. B. 0NH55953DH663215D — ohne Vorsilbe." },
{ k: "PAYPAL_WD_PLAN_BASIS", titel: "Plan für die Betreuung", art: "text",
hilfe: "Nur nötig für monatliche Betreuungs-Abos. Wird NICHT im Entwicklerbereich "
+ "angelegt, sondern auf paypal.com unter „Abonnements“ (paypal.com/billing/plans). "
+ "Beginnt mit P-." },
];
/* Zeigt einen Fehler SICHTBAR an, statt den Ladehinweis stehen zu
lassen.
Beim ersten Einsatz blieb hier "Wird geladen…" stehen und es
passierte nichts weiter -- Filipe konnte nicht erkennen, ob die
Anfrage noch laeuft, fehlgeschlagen ist oder das Skript gar nicht
angesprungen ist. Ein Ladehinweis ohne Ende ist die schlechteste
aller Rueckmeldungen: Er sieht aus wie Arbeit und ist doch nur
Stillstand.
Deshalb: eigener Text je Zustand, die Statusnummer im Klartext (die
kann er mir nennen, ohne die Entwicklerwerkzeuge zu oeffnen) und ein
Knopf zum erneuten Versuch. */
function ladefehlerZeigen(ziel, fehler, nochmal) {
var nummer = fehler && fehler.status ? " (Status " + fehler.status + ")" : "";
ziel.innerHTML =
'<div class="wd-hinweis"><p class="wd-hinweis-titel">Konnte nicht geladen werden' +
s(nummer) + "</p>" +
'<p class="wd-klein">' + s((fehler && fehler.message) || "Unbekannter Fehler") + "</p>" +
'<div class="wd-btn-reihe" style="margin-top:.7rem">' +
'<button type="button" class="wd-btn wd-btn--zweit wd-btn--klein" id="vw-nochmal">Nochmal versuchen</button>' +
"</div></div>";
var k = document.getElementById("vw-nochmal");
if (k) k.addEventListener("click", nochmal);
}
async function ladeZahlungen() {
var ziel = document.getElementById("vw-pp-stand");
/* Anderer Wortlaut als im festen HTML: Steht spaeter noch der alte
Text da, weiss man, dass diese Funktion nie angelaufen ist. */
ziel.innerHTML = '<div class="vw-laedt">Einstellungen werden abgerufen…</div>';
try {
var d = await api("/webdesign/admin/einstellungen");
zeichnePayPal(d);
} catch (fehler) {
if (fehler.status === 401) { token = null; tokenSchreiben(null); zeigeAnmeldung(); return; }
ladefehlerZeigen(ziel, fehler, ladeZahlungen);
return;
}
ladeZahlungsliste();
}
function zeichnePayPal(d) {
var e = d.einrichtung || {};
var st = d.stand || {};
var kopf = e.einmal
? (e.live
? '<div class="wd-hinweis" style="margin-bottom:1rem;border-left-color:var(--wd-ok)">' +
'<p class="wd-hinweis-titel" style="color:var(--wd-ok)">PayPal ist eingerichtet — Echtbetrieb</p>' +
'<p class="wd-klein">Zahlungen gehen direkt auf dein Konto.</p></div>'
: '<div class="wd-hinweis" style="margin-bottom:1rem"><p class="wd-hinweis-titel">' +
'Eingerichtet, aber im TESTMODUS</p><p class="wd-klein">Es fließt kein echtes Geld. ' +
'Stell die Betriebsart auf <strong>Echtbetrieb</strong>, sobald du bereit bist.</p></div>')
/* ⚠️ "Es fehlt: —." war ein Satz ohne Aussage (behoben 26.08.2026).
Kam die Liste der fehlenden Angaben leer zurueck -- etwa weil
der Server sie (noch) nicht mitschickt --, stand hier woertlich
"Es fehlt: —." Man las eine Fehlermeldung, die nicht sagt, was
fehlt, und suchte dann bei den Zugangsdaten statt bei der
Antwort des Servers.
Jetzt zwei getrennte Saetze: einer, wenn bekannt ist WAS fehlt,
und einer fuer den Fall, dass nur bekannt ist DASS etwas fehlt. */
: '<div class="wd-hinweis" style="margin-bottom:1rem"><p class="wd-hinweis-titel">' +
'Noch nicht eingerichtet</p><p class="wd-klein">' +
((e.fehlt || []).length
? 'Es fehlt: <strong>' + s((e.fehlt || []).join(", ")) + '</strong>. '
: 'Die Zugangsdaten sind noch nicht vollständig hinterlegt. ') +
'Solange sagt das Kundenportal freundlich Bescheid und nennt den Weg über das Postfach.</p></div>';
var felder = PP_FELDER.map(function (f) {
var z = st[f.k] || {};
/* Die Marke sagt, welche Quelle GERADE GILT -- nicht nur, dass
irgendwo etwas steht. Vorher stand bei einem Serverwert nur
"hinterlegt (auf dem Server)", und man konnte nicht erkennen,
dass die eigene Eingabe im Formular davon ueberstimmt wurde. */
var lage = z.gesetzt
? '<span class="wd-tag wd-tag--ok">' +
(z.quelle === "server" ? "aus der Serverdatei" : "hier eingetragen") +
(z.laenge ? " · " + z.laenge + " Zeichen" : "") + "</span>" +
(z.auchAufServer
? ' <span class="wd-tag">Serverwert wird nicht benutzt</span>'
: "")
: '<span class="wd-tag wd-tag--gold">fehlt</span>';
if (f.art === "wahl") {
/* Der tatsaechlich wirksame Wert, nicht der zuletzt geklickte.
Sonst leuchtet "Echtbetrieb" auf, waehrend in Wahrheit der
Testmodus laeuft -- genau das ist am 23.08.2026 passiert. */
var jetztWert = z.wert || "sandbox";
return '<div class="wd-feld" style="margin-bottom:1rem">' +
"<label>" + s(f.titel) + " " + lage + "</label>" +
'<div class="vw-status-wahl" style="margin:.3rem 0">' +
'<button type="button" class="vw-filter-knopf' + (jetztWert === "sandbox" ? " vw-an" : "") +
'" data-pp-env="sandbox">Testmodus</button>' +
'<button type="button" class="vw-filter-knopf' + (jetztWert === "live" ? " vw-an" : "") +
'" data-pp-env="live">Echtbetrieb</button>' +
"</div>" +
'<span class="wd-feld-hilfe">' + s(f.hilfe) + "</span></div>";
}
/* Der Platzhalter sagt ausdruecklich, dass Leerlassen NICHTS
aendert. Ohne diesen Hinweis loescht man beim Speichern eines
einzelnen Feldes versehentlich die anderen -- oder glaubt es
zumindest und traut sich deshalb nicht. */
return '<div class="wd-feld" style="margin-bottom:1rem">' +
'<label for="pp-' + f.k + '">' + s(f.titel) + " " + lage + "</label>" +
'<input type="' + (f.art === "geheim" ? "password" : "text") + '" id="pp-' + f.k +
'" autocomplete="off" spellcheck="false" placeholder="' +
(z.gesetzt ? "leer lassen → unverändert" : "hier einfügen") + '" />' +
'<span class="wd-feld-hilfe">' + s(f.hilfe) + "</span></div>";
}).join("");
document.getElementById("vw-pp-stand").innerHTML =
kopf +
'<div class="wd-karte">' +
'<h2 style="font-size:1.15rem;margin-top:0">PayPal einrichten</h2>' +
'<p class="wd-klein wd-leise">Die Werte werden verschlüsselt gespeichert und wirken sofort — ' +
"kein Neustart nötig. Ein gespeicherter Wert wird nie wieder angezeigt; " +
"wer ihn verliert, erzeugt bei PayPal einen neuen.</p>" +
'<div class="wd-hinweis" style="margin:.9rem 0">' +
'<p class="wd-hinweis-titel">Diese Adresse trägst du bei PayPal als Webhook ein</p>' +
'<code style="display:block;word-break:break-all;font-size:.86rem;margin-top:.3rem">' +
s(d.webhookUrl || "") + "</code></div>" +
felder +
'<div class="wd-btn-reihe">' +
'<button type="button" class="wd-btn wd-btn--haupt" id="pp-speichern">Speichern</button>' +
'<button type="button" class="wd-btn wd-btn--zweit" id="pp-pruefen">Verbindung testen</button>' +
"</div>" +
'<p class="wd-klein wd-leise" style="margin-top:.7rem">' +
"„Verbindung testen“ meldet sich probeweise bei PayPal an. " +
"Es fließt dabei kein Geld — aber erst das beweist, dass die Werte stimmen.</p>" +
"</div>";
var gewaehlteArt = null;
document.querySelectorAll("[data-pp-env]").forEach(function (b) {
b.addEventListener("click", function () {
gewaehlteArt = b.dataset.ppEnv;
document.querySelectorAll("[data-pp-env]").forEach(function (x) {
x.classList.toggle("vw-an", x === b);
});
});
});
document.getElementById("pp-speichern").addEventListener("click", async function () {
var knopf = this;
var werte = {};
PP_FELDER.forEach(function (f) {
if (f.art === "wahl") { if (gewaehlteArt) werte[f.k] = gewaehlteArt; return; }
var el = document.getElementById("pp-" + f.k);
/* Leeres Feld = NICHT anfassen. Nur so kann man ein einzelnes
Feld nachtragen, ohne die anderen zu verlieren. */
if (el && el.value.trim()) werte[f.k] = el.value.trim();
});
if (!Object.keys(werte).length) {
window.WD.meldung(meldung, "Nichts eingetragen — es wurde nichts geändert.", "fehler");
return;
}
window.WD.knopfSperren(knopf, true, "…");
try {
var r = await api("/webdesign/admin/einstellungen", { method: "POST", body: { werte: werte } });
/* Felder sofort leeren: Ein Secret soll nicht im Formular stehen
bleiben, wenn jemand anders auf den Bildschirm schaut. */
PP_FELDER.forEach(function (f) {
var el = document.getElementById("pp-" + f.k);
if (el) el.value = "";
});
window.WD.meldung(meldung, "Gespeichert: " + (r.geaendert || []).join(", "), "ok");
ladeZahlungen();
} catch (f2) {
window.WD.knopfSperren(knopf, false);
window.WD.meldung(meldung, f2.message, "fehler");
}
});
document.getElementById("pp-pruefen").addEventListener("click", async function () {
var knopf = this;
window.WD.knopfSperren(knopf, true, "Wird getestet…");
try {
var r = await api("/webdesign/admin/einstellungen/pruefen", { method: "POST", body: {} });
window.WD.meldung(meldung, r.meldung, r.live ? "ok" : "fehler");
} catch (f2) {
window.WD.meldung(meldung, f2.message, "fehler");
} finally { window.WD.knopfSperren(knopf, false); }
});
}
async function ladeZahlungsliste() {
var ziel = document.getElementById("vw-zahlungen-liste");
try {
var d = await api("/webdesign/admin/zahlungen");
if (!d.zahlungen.length) {
ziel.innerHTML = '<div class="vw-leer-hinweis" style="padding:2rem 1rem">' +
"Noch keine Zahlungen. Sie entstehen im Projekt.</div>";
return;
}
ziel.innerHTML = d.zahlungen.map(function (z) {
var marke = z.status === "bezahlt" ? "angenommen"
: z.status === "offen" ? "neu"
: z.status === "fehlge" ? "abgelehnt" : "pruefung";
return '<div class="vw-karte" style="cursor:default">' +
'<span class="vw-karte-nr">' + s(z.nummer) + "</span>" +
'<span class="vw-karte-mitte">' +
'<span class="vw-karte-name">' + s(z.betragText) + " · " + s(z.zweck_text || "") + "</span>" +
'<span class="vw-karte-info">' + s(z.kunde_name || "") +
(z.projekt_titel ? " · " + s(z.projekt_titel) : "") + "</span>" +
"</span>" +
'<span class="vw-karte-rechts">' +
'<span class="vw-status vw-status--' + marke + '">' + s(z.status) + "</span>" +
'<span class="vw-karte-zeit">' + window.WD.datum(z.erstellt_am) + "</span>" +
"</span></div>";
}).join("");
} catch (fehler) {
/* Frueher wurde hier stillschweigend geleert. Ein leerer Kasten
ohne Erklaerung ist aber nicht weniger verwirrend als ein
haengender Ladehinweis. */
ladefehlerZeigen(ziel, fehler, ladeZahlungsliste);
}
}
/* =================================================================
POSTFACH
"auf dieser seite sollen die kunden mir auch nachrichten hinterlassen
können, für noch kleinigkeiten oder sachen die ich brauch."
(22.08.2026)
Aufbau links Verläufe, rechts das Gespräch — wie in jedem
Nachrichtenprogramm. Nicht aus Nachahmung, sondern weil man ein
Gespräch abarbeitet und nicht eine Zeile: Man will sehen, was vorher
besprochen wurde, während man antwortet.
================================================================= */
var offenerVerlauf = null;
async function ladePostfach() {
var ziel = document.getElementById("vw-pf-liste");
ziel.innerHTML = '<div class="vw-laedt">Wird geladen…</div>';
try {
var d = await api("/webdesign/admin/postfach");
zaehlerSetzen(d.ungelesen);
if (!d.verlaeufe.length) {
ziel.innerHTML = '<div class="vw-leer-hinweis" style="padding:2rem 1rem">' +
"Noch keine Nachrichten. Sobald ein Kunde schreibt, steht der Verlauf hier.</div>";
return;
}
ziel.innerHTML = d.verlaeufe.map(function (v) {
return '<button type="button" class="vw-pf-eintrag' +
(v.ungelesen ? " vw-pf-eintrag--neu" : "") +
(v.kunde_id === offenerVerlauf ? " vw-an" : "") +
'" data-verlauf="' + s(v.kunde_id) + '">' +
'<span class="vw-pf-name">' + s(v.name) +
(v.ungelesen ? '<span class="vw-abzeichen">' + v.ungelesen + "</span>" : "") + "</span>" +
'<span class="vw-pf-vorschau">' +
(v.letzter_autor === "kunde" ? "" : "Du: ") + s(v.letzter_text || "") + "</span>" +
'<span class="vw-pf-zeit">' + alter(v.letzte) + "</span>" +
"</button>";
}).join("");
ziel.querySelectorAll("[data-verlauf]").forEach(function (b) {
b.addEventListener("click", function () { oeffneVerlauf(b.dataset.verlauf); });
});
} catch (fehler) {
if (fehler.status === 401) { token = null; tokenSchreiben(null); zeigeAnmeldung(); return; }
ziel.innerHTML = "";
window.WD.meldung(meldung, fehler.message, "fehler");
}
}
async function oeffneVerlauf(kundeId) {
offenerVerlauf = kundeId;
var kasten = document.getElementById("vw-pf-verlauf");
kasten.innerHTML = '<div class="vw-laedt">Wird geladen…</div>';
document.querySelectorAll("[data-verlauf]").forEach(function (b) {
b.classList.toggle("vw-an", b.dataset.verlauf === kundeId);
});
try {
var d = await api("/webdesign/admin/postfach/" + encodeURIComponent(kundeId));
var blasen = d.nachrichten.map(function (n) {
var art = n.intern ? "intern" : n.autor === "kunde" ? "kunde" : "wir";
var wer = n.intern ? "Interne Notiz — der Kunde sieht das nicht"
: n.autor === "kunde" ? s(d.kunde.name) : (n.autor === "vanvan" ? "VanVan" : "Dogfather");
return '<div class="vw-blase vw-blase--' + art + '">' +
'<div class="vw-blase-kopf">' + wer + " · " + window.WD.datum(n.erstellt_am) +
(n.projekt_titel ? " · zu „" + s(n.projekt_titel) + "“" : "") + "</div>" +
s(n.text) + "</div>";
}).join("");
kasten.innerHTML =
'<div style="display:flex;align-items:baseline;gap:.6rem;margin-bottom:.7rem">' +
'<h2 style="font-size:1.1rem;margin:0">' + s(d.kunde.name) + "</h2>" +
'<span class="wd-klein wd-leise">' + s(d.kunde.email) + "</span></div>" +
'<div class="vw-pf-verlauf" id="vw-blasen">' +
(blasen || '<p class="wd-leise" style="text-align:center">Noch nichts geschrieben.</p>') +
"</div>" +
'<div class="vw-pf-schreiben" id="vw-schreibfeld">' +
'<div class="wd-feld" style="margin-bottom:.6rem">' +
'<textarea id="vw-pf-text" rows="3" placeholder="Antwort schreiben…"></textarea>' +
"</div>" +
(d.projekte.length
? '<div class="wd-feld" style="margin-bottom:.6rem">' +
'<label for="vw-pf-projekt" class="wd-klein">Bezug (optional)</label>' +
'<select id="vw-pf-projekt"><option value="">— kein bestimmtes Projekt —</option>' +
d.projekte.map(function (p) {
return '<option value="' + s(p.id) + '">' + s(p.titel) + "</option>";
}).join("") + "</select></div>"
: "") +
'<div class="wd-btn-reihe" style="align-items:center;justify-content:space-between">' +
'<label class="vw-pf-schalter"><input type="checkbox" id="vw-pf-intern" />' +
"<span>Nur interne Notiz</span></label>" +
'<button type="button" class="wd-btn wd-btn--haupt wd-btn--klein" id="vw-pf-senden">Senden</button>' +
"</div>" +
"</div>";
/* Ans Ende springen — beim Oeffnen will man das Neueste sehen,
nicht den Anfang von vor drei Monaten. */
var lauf = document.getElementById("vw-blasen");
lauf.scrollTop = lauf.scrollHeight;
/* Der Schalter faerbt das Schreibfeld um. Ein Haekchen allein
uebersieht man — und eine interne Bemerkung, die beim Kunden
landet, ist der peinlichste Fehler, den dieses System machen
kann. Deshalb muss sich die Umgebung sichtbar aendern. */
var schalter = document.getElementById("vw-pf-intern");
var feld = document.getElementById("vw-schreibfeld");
var eingabe = document.getElementById("vw-pf-text");
schalter.addEventListener("change", function () {
feld.classList.toggle("vw-pf-schreiben--intern", schalter.checked);
eingabe.placeholder = schalter.checked
? "Interne Notiz — nur für dich und VanVan sichtbar…"
: "Antwort schreiben…";
});
var senden = document.getElementById("vw-pf-senden");
async function abschicken() {
var text = eingabe.value.trim();
if (!text) return;
var wahl = document.getElementById("vw-pf-projekt");
window.WD.knopfSperren(senden, true, "…");
try {
await api("/webdesign/admin/postfach/" + encodeURIComponent(kundeId), {
method: "POST",
body: {
text: text,
intern: schalter.checked === true,
projektId: wahl ? wahl.value : null,
},
});
eingabe.value = "";
schalter.checked = false;
feld.classList.remove("vw-pf-schreiben--intern");
await oeffneVerlauf(kundeId);
await ladePostfach();
} catch (f2) {
window.WD.meldung(meldung, f2.message, "fehler");
} finally { window.WD.knopfSperren(senden, false); }
}
senden.addEventListener("click", abschicken);
/* Strg+Enter sendet. Enter allein NICHT — in einem mehrzeiligen
Feld will man Absaetze machen koennen, ohne dass die halbe
Nachricht rausgeht. */
eingabe.addEventListener("keydown", function (e) {
if ((e.ctrlKey || e.metaKey) && e.key === "Enter") { e.preventDefault(); abschicken(); }
});
/* Das Oeffnen hat serverseitig als gelesen markiert — der Zaehler
oben muss das sofort nachvollziehen, sonst zeigt er eine Zahl,
die nicht mehr stimmt. */
zaehlerHolen();
} catch (fehler) {
kasten.innerHTML = '<div class="vw-leer-hinweis">' + s(fehler.message) + "</div>";
}
}
function zaehlerSetzen(n) {
var z = document.getElementById("vw-pf-zaehler");
z.hidden = !n;
z.textContent = n || "";
}
async function zaehlerHolen() {
try {
var d = await api("/webdesign/admin/postfach-zaehler");
zaehlerSetzen(d.ungelesen);
} catch (e) { /* Ein fehlender Zaehler ist kein Grund, etwas zu melden. */ }
}
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--zweit wd-btn--klein" data-projekt-fuer="' + s(k.id) +
'" data-projekt-name="' + s(k.name) + '">+ Projekt</button> ' +
'<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-projekt-fuer]").forEach(function (b) {
b.addEventListener("click", function () {
zeigeProjektFormular(b.dataset.projektFuer, b.dataset.projektName);
});
});
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-top:.6rem">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);
}
}
/* -----------------------------------------------------------------
PROJEKT ANLEGEN
Bewusst als Einblendung direkt bei der Kundenzeile statt als eigene
Seite: man legt ein Projekt IMMER fuer einen bestimmten Kunden an,
nie im luftleeren Raum. Der Kundenname steht deshalb gross im
Formular -- so kann man es nicht versehentlich dem Falschen anhaengen.
----------------------------------------------------------------- */
function zeigeProjektFormular(kundeId, kundeName) {
var box = document.getElementById("vw-link-box");
box.hidden = false;
box.innerHTML =
'<div class="wd-karte" style="cursor:default;display:block">' +
'<h3 style="margin:0 0 .2rem">Neues Projekt</h3>' +
'<p class="wd-klein" style="color:var(--wd-blau);margin:0 0 1rem">für ' + s(kundeName) + '</p>' +
'<div class="wd-grid wd-grid--2">' +
'<div class="wd-feld" id="vw-feld-ptitel">' +
'<label for="vw-ptitel">Projekttitel<span class="wd-pflicht">*</span></label>' +
'<input type="text" id="vw-ptitel" placeholder="z. B. Website Handwerksbetrieb Meier" />' +
'<p class="wd-feld-fehler"></p>' +
'</div>' +
'<div class="wd-feld">' +
'<label for="vw-ppaket">Paket</label>' +
'<select id="vw-ppaket">' +
'<option value="onepager">Onepager — ab 300 €</option>' +
'<option value="website" selected>Professionelle Website — ab 500 €</option>' +
'<option value="shop">Shop &amp; umfangreiche Website — ab 1.000 €</option>' +
'<option value="verwaltung">Buchhaltungs- &amp; Steuerverwaltung — ab 1.500 €</option>' +
'<option value="betreuung">Laufende Betreuung — ab 49 €/Monat</option>' +
'</select>' +
'</div>' +
'<div class="wd-feld">' +
'<label for="vw-ppreis">Gesamtpreis in Euro</label>' +
'<input type="number" id="vw-ppreis" min="0" step="1" placeholder="1500" inputmode="numeric" />' +
'<span class="wd-feld-hilfe" id="vw-panzahlung">Die 30 % Anzahlung wird daraus automatisch berechnet.</span>' +
'</div>' +
'<div class="wd-feld">' +
'<label for="vw-ptermin">Richttermin <span class="wd-leise">optional</span></label>' +
'<input type="text" id="vw-ptermin" placeholder="z. B. Mitte Oktober" />' +
'</div>' +
'</div>' +
'<div class="wd-feld">' +
'<label for="vw-pschritt">Nächster Schritt <span class="wd-leise">sieht der Kunde sofort</span></label>' +
'<input type="text" id="vw-pschritt" placeholder="z. B. Briefing-Termin vereinbaren" />' +
'</div>' +
'<div class="wd-btn-reihe">' +
'<button type="button" class="wd-btn wd-btn--haupt" id="vw-projekt-speichern">Projekt anlegen</button>' +
'<button type="button" class="wd-btn wd-btn--still" id="vw-projekt-abbrechen">Abbrechen</button>' +
'</div>' +
'</div>';
box.scrollIntoView({ behavior: "smooth", block: "center" });
setTimeout(function () { document.getElementById("vw-ptitel").focus(); }, 100);
/* Die Anzahlung live mitrechnen. Sie wird zwar serverseitig berechnet,
aber wer sie beim Eintippen sieht, merkt eine falsche Null sofort --
und nicht erst, wenn der Kunde den Betrag ueberweisen soll. */
var preisFeld = document.getElementById("vw-ppreis");
preisFeld.addEventListener("input", function () {
var wert = Number(preisFeld.value);
document.getElementById("vw-panzahlung").textContent =
Number.isFinite(wert) && wert > 0
? "30 % Anzahlung: " + (Math.round(wert * 100 * 0.3) / 100).toFixed(2).replace(".", ",") + " €"
: "Die 30 % Anzahlung wird daraus automatisch berechnet.";
});
document.getElementById("vw-projekt-abbrechen").addEventListener("click", function () {
box.hidden = true; box.innerHTML = "";
});
document.getElementById("vw-projekt-speichern").addEventListener("click", async function (e) {
var titel = document.getElementById("vw-ptitel").value.trim();
var feld = document.getElementById("vw-feld-ptitel");
feld.classList.remove("wd-feld--fehler");
if (!titel) {
feld.classList.add("wd-feld--fehler");
feld.querySelector(".wd-feld-fehler").textContent = "Bitte einen Projekttitel angeben.";
return;
}
window.WD.knopfSperren(e.target, true, "Wird angelegt…");
try {
var r = await api("/webdesign/admin/projekte", {
method: "POST",
body: {
kundeId: kundeId,
titel: titel,
paket: document.getElementById("vw-ppaket").value,
preisEuro: Number(document.getElementById("vw-ppreis").value) || undefined,
richttermin: document.getElementById("vw-ptermin").value.trim(),
naechsterSchritt: document.getElementById("vw-pschritt").value.trim(),
},
});
box.innerHTML = '<div class="vw-link-anzeige">' +
'<p class="wd-hinweis-titel">Projekt ' + s(r.nummer) + ' angelegt</p>' +
'<p class="wd-klein" style="margin:0">' +
(r.anzahlungText ? "Anzahlung 30 %: <strong>" + s(r.anzahlungText) + "</strong>. " : "") +
'Der Kunde sieht es ab sofort in seinem Projektraum.</p></div>';
ladeKunden();
} catch (f) {
window.WD.knopfSperren(e.target, false);
window.WD.meldung(meldung, f.message, "fehler");
}
});
}
/* ---------------- 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. */
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) {
/* Die Herkunft steht jetzt daneben und ueberlebt ein Neuladen.
Vorher wurde nach jedem Neuladen "code" ANGENOMMEN. Das war als
Vorsicht gemeint, hatte aber eine unangenehme Kehrseite: Ein
AUSWEIS gilt nur 15 Minuten. Als "code" behandelt wurde er nie
erneuert -- nach einer Viertelstunde kam der erste 401, und man
landete in der Codeeingabe, obwohl alles in Ordnung war. Genau
das war die Rueckmeldung vom 23.08.2026 ("wenn ich von Uebersicht
auf andere Kategorien gehe, werd ich wieder nach dem Code
gefragt").
Steht dort nichts -- etwa aus einer aelteren Sitzung, die den
Eintrag noch nicht kannte --, bleibt es bei der vorsichtigen
Annahme "code": Eine funktionierende Sitzung durch einen
untauglichen Ausweis zu ersetzen waere der schlimmere Fehler. */
tokenQuelle = quelleLesen() || "code";
zeigeBereich(); ladeUebersicht(); zaehlerHolen();
return;
}
var ausweis = await ausweisHolen();
if (ausweis) {
token = ausweis;
tokenQuelle = "ausweis";
tokenSchreiben(token, "ausweis");
document.getElementById("vw-name").textContent =
(document.cookie.match(/dogiwd_rolle=([^;]+)/) || [])[1] === "vanvan" ? "VanVan" : "Dogfather";
zeigeBereich();
ladeUebersicht();
/* Auch auf diesem Weg. Der Ausweis ist der ÜBLICHE Einstieg (man
kommt von der Zugangswand und ist sofort drin) -- ohne diese
Zeile bliebe der Zähler ausgerechnet im Normalfall leer. */
zaehlerHolen();
erneuerungPlanen();
return;
}
zeigeAnmeldung();
}
if (window.WD) start();
else document.addEventListener("wd-bereit", start);
})();
</script>
</body>
</html>