Das cryo-kristall-Motiv (Zahlungen) ist als einziges der sechs HOCHKANT (702x941) statt quer (1672x941). Auf der breiten 16:9-Bühne zeigt "cover" davon nur einen horizontalen Streifen -- der mittige (Standard center center) traf die unruhigen Kristallsplitter und wirkte flach und beschnitten. Jetzt zeigt Zahlungen den oberen Streifen (background-position center 15%): die eleganten geschwungenen Kristallbänder rechts als Blickfang, links ruhiger dunkler Raum für die Karten -- eine klare Komposition wie bei den anderen Motiven. Nur für Zahlungen überschrieben, die übrigen fünf bleiben mittig. Nebenbei: Der Wert kommt aus --vw-blick, das für alle Bereiche längst definiert, bisher aber nirgends ausgelesen wurde (toter Code). Die neue .vw-buehne-Ausnahme wendet ihn für Zahlungen erstmals an. Service-Worker-Cache v63 -> v64 (beide Stellen), damit Geräte mit der App den neuen Ausschnitt bekommen. Co-Authored-By: Claude Opus 5 <[email protected]>
6267 lines
288 KiB
HTML
6267 lines
288 KiB
HTML
<!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/favicon.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/wd-admin-192.jpg" />
|
||
<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 & umfangreiche Website — ab 1.000 €</option>' +
|
||
'<option value="verwaltung">Buchhaltungs- & 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>
|