Die Reiter standen neben dem Titel. Das las sich wie eine einzige lange
Zeile, in der der Titel nur der erste von sechs Knöpfen zu sein schien --
man sah nicht auf einen Blick, in welchem Bereich man war. Jetzt zwei
Zeilen: oben WO man ist, darunter WOHIN man kann.
Die Verwaltung öffnete bisher mit der Anfragenliste. Eine Liste ist eine
Ablage: Sie zeigt, WAS es gibt, nicht was zu TUN ist. Neu ist ein
Cockpit, das in drei Stufen antwortet -- was auf mich wartet, was beim
Kunden liegt, wie es ums Geld steht -- und dann laufende Projekte mit
Fortschritt sowie den Verlauf.
Zwei Grundsätze machen die Zahlen brauchbar: Getrennt nach 'wartet auf
mich' und 'wartet auf den Kunden' (zwölf offene Punkte sind entspannt,
wenn elf beim Kunden liegen). Und das ALTER färbt, nicht die Menge --
vier neue Anfragen sind kein Problem, eine seit sechs Tagen liegende
schon. Ein offener Widerruf ist immer rot, weil eine gesetzliche Frist
läuft.
Jede Kachel ist ein echter <button> und führt in den passenden Reiter.
Farbe ist nie der einzige Träger: Neben jedem farbigen Zustand steht der
Text ('älteste seit 8 Tagen').
Drei Fehler dabei gefunden und behoben:
- Der Titel wurde nur beim Klicken gesetzt. Frisch geladen zeigte die
Seite das Cockpit, während darüber noch 'Projektanfragen' stand. Die
Zuordnung Reiter->Titel liegt jetzt ausserhalb des Klick-Zuhörers.
- '.wd h2' überstimmte '.vw-ub-h': 44px Überschrift über 33px Zahl, die
Seite las sich wie ein Plakat. Dieselbe Spezifitätsfalle wie früher
bei '.wd a'.
- Eine Antwort mit ok:true aber ohne Inhalt riss die ganze Verwaltung
mit. Wird jetzt abgefangen -- ein halber Server ist ein realistischer
Fall.
Geprüft: 22 Serverprüfungen gegen eine echte Datenbank (inkl. vier
Fällen, die belegen, dass die Rechteprüfung wirklich greift), 56 im
Browser auf Computer und Handy, 69 im bestehenden Verwaltungstest,
0 Fundstellen im Design-/WCAG-Test, 5 im Farbsystemtest.
Co-Authored-By: Claude Opus 5 <[email protected]>
3280 lines
154 KiB
HTML
3280 lines
154 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="de">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||
<title>Verwaltung — Dogfather Webdesign</title>
|
||
<meta name="robots" content="noindex, nofollow, noarchive" />
|
||
<link rel="icon" type="image/png" href="/assets/img/favicon.png" />
|
||
<meta name="theme-color" content="#05070b" />
|
||
<link rel="manifest" href="/webdesign/app.webmanifest" />
|
||
<link rel="apple-touch-icon" href="/assets/img/wd-app-180.png" />
|
||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||
<meta name="apple-mobile-web-app-title" content="DF Verwaltung" />
|
||
<link rel="stylesheet" href="/assets/css/main.css?v=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) {
|
||
/* Auf dem Handy darf der Name weichen -- der Platz gehört dem Titel. */
|
||
.vw-leiste { grid-template-columns: 1fr; }
|
||
.vw-leiste .vw-wer { grid-column: 1; grid-row: 3; justify-content: flex-start; }
|
||
.vw-leiste .vw-tabs { grid-row: 2; }
|
||
}
|
||
.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;
|
||
}
|
||
|
||
.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; }
|
||
}
|
||
|
||
/* ---------- 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; }
|
||
.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">
|
||
<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. -->
|
||
<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">
|
||
<p class="wd-leise" style="text-align:center;padding:2rem 0">
|
||
Wähle links einen Verlauf aus.
|
||
</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");
|
||
|
||
function tokenLesen() {
|
||
try { return sessionStorage.getItem(TOKEN_SCHLUESSEL); } catch (e) { return null; }
|
||
}
|
||
function tokenSchreiben(t) {
|
||
try { if (t) sessionStorage.setItem(TOKEN_SCHLUESSEL, t); else sessionStorage.removeItem(TOKEN_SCHLUESSEL); } catch (e) { /* egal */ }
|
||
}
|
||
|
||
/* ---------------------------------------------------------------------
|
||
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); }
|
||
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);
|
||
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 () {
|
||
try { await api("/auth/logout", { method: "POST", body: {} }); } catch (e) { /* egal */ }
|
||
token = null; tokenSchreiben(null);
|
||
location.reload();
|
||
});
|
||
|
||
/* ---------------- 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>Weiter geht es</h3>' +
|
||
'<div class="vw-uebernahme">' +
|
||
"<h4>Anfrage übernehmen</h4>" +
|
||
"<p>Legt in einem Schritt den Kundenzugang UND das Projekt an — mit den Angaben aus dieser Anfrage. Du bekommst danach sofort den Einladungslink.</p>" +
|
||
'<dl class="vw-uebernahme-vorschau">' +
|
||
"<dt>Kunde</dt><dd>" + s(a.name) + "</dd>" +
|
||
"<dt>E-Mail</dt><dd>" + s(a.email) + "</dd>" +
|
||
"<dt>Projekt</dt><dd>" + s(PAKET_TEXT[a.paket] || a.paket) + " für " + s(a.name) + "</dd>" +
|
||
"<dt>Sprache</dt><dd>" + s((a.sprache || "de").toUpperCase()) + "</dd>" +
|
||
"</dl>" +
|
||
'<div class="wd-feld" style="margin-bottom:.8rem">' +
|
||
'<label for="vw-ue-preis">Gesamtpreis in Euro <span class="wd-leise">optional, später änderbar</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>" +
|
||
'<button type="button" class="wd-btn wd-btn--haupt" id="vw-uebernehmen">Kunde und Projekt anlegen</button>' +
|
||
"</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.");
|
||
}
|
||
});
|
||
|
||
var preisFeld = blatt.querySelector("#vw-ue-preis");
|
||
if (preisFeld) {
|
||
preisFeld.addEventListener("input", function () {
|
||
var wert = Number(preisFeld.value);
|
||
blatt.querySelector("#vw-ue-anzahlung").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 {
|
||
/* Zwei Schritte, bewusst nacheinander und nicht parallel: das
|
||
Projekt braucht die Kunden-Kennung. Schlaegt der zweite
|
||
fehl, existiert der Kunde trotzdem schon -- deshalb sagt die
|
||
Fehlermeldung unten ausdruecklich, dass er angelegt wurde,
|
||
damit niemand es blind noch einmal versucht und dann an der
|
||
doppelten E-Mail scheitert. */
|
||
var kunde = await api("/webdesign/admin/kunden", {
|
||
method: "POST",
|
||
body: {
|
||
name: a.name, email: a.email, telefon: a.telefon || "",
|
||
sprache: a.sprache || "de", freischalten: true,
|
||
},
|
||
});
|
||
|
||
var preis = Number(preisFeld && preisFeld.value);
|
||
try {
|
||
await api("/webdesign/admin/projekte", {
|
||
method: "POST",
|
||
body: {
|
||
kundeId: kunde.id,
|
||
anfrageId: a.id,
|
||
titel: (PAKET_TEXT[a.paket] || a.paket) + " für " + a.name,
|
||
paket: a.paket === "unklar" ? "website" : a.paket,
|
||
preisEuro: Number.isFinite(preis) && preis > 0 ? preis : undefined,
|
||
richttermin: a.termin || "",
|
||
naechsterSchritt: "Briefing-Termin vereinbaren",
|
||
},
|
||
});
|
||
} catch (projektFehler) {
|
||
window.WD.meldung(meldung,
|
||
"Der Kundenzugang wurde angelegt, das Projekt aber nicht: " +
|
||
projektFehler.message + " Du kannst das Projekt unter „Kunden“ nachtragen.", "fehler");
|
||
}
|
||
|
||
schliesseDetail();
|
||
aktiverTab = "kunden";
|
||
document.querySelector('[data-tab="kunden"]').click();
|
||
zeigeLink(kunde.einladungsLink);
|
||
} catch (fehler) {
|
||
window.WD.knopfSperren(e.target, false);
|
||
window.WD.meldung(meldung, fehler.message, "fehler");
|
||
}
|
||
});
|
||
}
|
||
|
||
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" },
|
||
};
|
||
|
||
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);
|
||
});
|
||
/* 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) {
|
||
/* Herkunft nach einem Neuladen unbekannt. Vorsichtshalber als
|
||
"code" behandeln: Lieber einmal die Codeeingabe zeigen, als eine
|
||
funktionierende Sitzung durch einen untauglichen Ausweis zu
|
||
ersetzen. */
|
||
tokenQuelle = "code";
|
||
zeigeBereich(); ladeUebersicht(); zaehlerHolen();
|
||
return;
|
||
}
|
||
|
||
var ausweis = await ausweisHolen();
|
||
if (ausweis) {
|
||
token = ausweis;
|
||
tokenQuelle = "ausweis";
|
||
tokenSchreiben(token);
|
||
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>
|