Rueckmeldung: 'der abmelde button klappt auch nicht auf dem handy'. Die Ursache war nicht das Handy -- der Fehler war auf beiden Geraeten derselbe, auf dem Handy sieht man das kurze Aufblitzen nur eher. Der Knopf beendete die Team-Sitzung, loeschte den Schluessel und lud neu. Das Merkmal der Zugangswand blieb dabei im Browser stehen. Beim Neuladen holt sich die Seite damit sofort wieder einen Ausweis -- man war nach einer Zehntelsekunde erneut angemeldet, und der Knopf schien nichts zu tun. Jetzt werden BEIDE Sitzungen beendet (der Endpunkt dafuer gab es laengst, die Verwaltung rief ihn nur nie auf), und man landet an der Zugangswand statt in einer Codeeingabe. Das ist auch die ehrliche Bedeutung des Wortes: Wer sich abmeldet, will draussen sein. Beide Aufrufe sind einzeln abgesichert -- faellt einer aus, laeuft der andere trotzdem. Ein halbes Abmelden waere schlimmer als keins: Man hielte sich fuer abgemeldet und waere es nicht. Geprueft mit 16 Pruefungen auf Computer und Handy, die messen, was tatsaechlich hinausgeht statt ob sich etwas auf dem Schirm bewegt. Dabei ein Artefakt im eigenen Test gefunden und behoben: Das Vorbereitungsskript lief bei jeder Navigation und setzte den Schluessel auf der Zugangswand gleich wieder -- zwei Pruefungen massen also sich selbst. Co-Authored-By: Claude Opus 5 <[email protected]>
4022 lines
189 KiB
HTML
4022 lines
189 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" />
|
||
<link rel="manifest" href="/webdesign/app.webmanifest" />
|
||
<link rel="apple-touch-icon" href="/assets/img/wd-app-180.png" />
|
||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||
<meta name="apple-mobile-web-app-title" content="DF Verwaltung" />
|
||
<link rel="stylesheet" href="/assets/css/main.css?v=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; }
|
||
|
||
/* ---------- 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;
|
||
grid-template-columns: 1fr 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;
|
||
}
|
||
.vw-leiste .vw-wer { grid-column: 2; grid-row: 1; }
|
||
/* Die Reiter über die volle Breite darunter. */
|
||
.vw-leiste .vw-tabs {
|
||
grid-column: 1 / -1; grid-row: 2;
|
||
padding-top: .2rem;
|
||
}
|
||
@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`. */
|
||
.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;
|
||
}
|
||
@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">
|
||
<section class="wd-abschnitt" style="padding-top:1.6rem">
|
||
<div class="wd-container">
|
||
|
||
<!-- ============ ANMELDUNG ============ -->
|
||
<div id="vw-anmeldung" class="wd-karte vw-login" hidden>
|
||
<h1 style="font-size:1.4rem">Verwaltung</h1>
|
||
<p class="wd-klein wd-leise">
|
||
Normalerweise landest du hier gar nicht: Wer durch die Zugangswand
|
||
gekommen ist, wird direkt hereingelassen. Diese Maske erscheint nur,
|
||
wenn das nicht möglich war — dann brauchst du deinen persönlichen
|
||
Zugangscode aus dem internen Bereich (derselbe wie fürs Postfach).
|
||
</p>
|
||
<form id="vw-login-form" novalidate>
|
||
<div class="wd-feld" id="vw-feld-code">
|
||
<label for="vw-code">Zugangscode</label>
|
||
<input type="password" id="vw-code" autocomplete="current-password"
|
||
autocapitalize="off" spellcheck="false" />
|
||
<p class="wd-feld-fehler"></p>
|
||
</div>
|
||
<button type="submit" class="wd-btn wd-btn--haupt wd-btn--breit">Anmelden</button>
|
||
</form>
|
||
</div>
|
||
|
||
<!-- ============ ARBEITSBEREICH ============ -->
|
||
<div id="vw-bereich" hidden>
|
||
<div class="vw-leiste">
|
||
<h1 id="vw-titel">Ü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>
|
||
|
||
<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>
|
||
</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>
|
||
</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 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;
|
||
}
|
||
|
||
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;
|
||
|
||
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. */
|
||
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;
|
||
|
||
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>';
|
||
}
|
||
|
||
/* 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", function (e) {
|
||
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();
|
||
});
|
||
}
|
||
|
||
|
||
/* Ganze Tage seit einem Zeitpunkt. Bewusst ueber den Kalendertag
|
||
gerechnet, nicht ueber 24-Stunden-Bloecke: eine Anfrage von gestern
|
||
23:50 Uhr ist umgangssprachlich "gestern", nicht "vor 0 Tagen". */
|
||
function tageSeit(zeit) {
|
||
if (!zeit) return 0;
|
||
var dann = new Date(zeit);
|
||
if (isNaN(dann.getTime())) return 0;
|
||
var a = new Date(dann.getFullYear(), dann.getMonth(), dann.getDate());
|
||
var heute = new Date();
|
||
var b = new Date(heute.getFullYear(), heute.getMonth(), heute.getDate());
|
||
return Math.round((b - a) / 86400000);
|
||
}
|
||
|
||
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>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);
|
||
}
|
||
|
||
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) {
|
||
blatt.innerHTML = '<div class="vw-leer-hinweis">' + s(fehler.message || "Konnte nicht geladen werden.") +
|
||
'<br /><br /><button class="wd-btn wd-btn--still wd-btn--klein" type="button" onclick="this.closest(\'.vw-detail\').classList.remove(\'vw-auf\')">Schließen</button></div>';
|
||
}
|
||
}
|
||
|
||
|
||
/* =================================================================
|
||
KUNDEN
|
||
================================================================= */
|
||
/* 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;
|
||
|
||
document.querySelectorAll("[data-tab]").forEach(function (b) {
|
||
b.addEventListener("click", function () {
|
||
aktiverTab = b.dataset.tab;
|
||
document.querySelectorAll("[data-tab]").forEach(function (x) {
|
||
x.classList.toggle("vw-an", x.dataset.tab === aktiverTab);
|
||
});
|
||
/* 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>" +
|
||
|
||
"</div></div>";
|
||
|
||
blatt.querySelector(".vw-zu").addEventListener("click", schliesseDetail);
|
||
|
||
/* 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>')
|
||
: '<div class="wd-hinweis" style="margin-bottom:1rem"><p class="wd-hinweis-titel">' +
|
||
'Noch nicht eingerichtet</p><p class="wd-klein">Es fehlt: <strong>' +
|
||
s((e.fehlt || []).join(", ") || "—") + '</strong>. ' +
|
||
'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>
|