Verwaltung: Aufgabenlisten abhaken und Postfach

Schritt 3 von 4 zu den zwei Wuenschen vom 22.08.2026. Die Datenbank und
die Server-Routen standen schon; das hier ist die Seite, auf der ICH
arbeite. Was hier passiert, sieht der Kunde unmittelbar in seinem Portal
(Schritt 4).

ZWEI NEUE REITER

"Projekte" -- bisher gab es Projekte nur als Zahl neben dem Kunden
("3 Proj."), man konnte kein einzelnes oeffnen. Man denkt aber in
Projekten, nicht in Kunden. Neuer Endpunkt projekteListe liefert die
flache Liste samt Aufgabenzahlen; die einzeln nachzufragen haette bei
zwanzig Projekten einundzwanzig Anfragen bedeutet.

"Postfach" -- mit Abzeichen fuer Ungelesenes. Steht nichts an, ist das
Abzeichen GANZ weg statt eine 0 zu zeigen: Eine Null, die man taeglich
sieht, wird zu Rauschen, und irgendwann uebersieht man auch die 3.

Die Reiterumschaltung war vorher ein Ja/Nein zwischen zwei Ansichten. Mit
vier Reitern haette jede neue Ansicht eine weitere "hidden = ..."-Zeile
gebraucht -- und beim naechsten Reiter vergisst man eine, dann liegen zwei
Ansichten uebereinander. Jetzt eine Zuordnung Reiter -> Kasten, die sich
selbst aufraeumt.

AUFGABENLISTE

Vier Abschnitte in den vier Prozessfarben -- dieselben wie im Portal und
auf der Ablaufseite. Das ist der Punkt: Eine Farbe bedeutet ueberall
dasselbe, sonst waere sie Deko.

Ein Klick aufs Kaestchen schaltet weiter: offen -> in Arbeit -> erledigt.
Drei Zustaende ueber einen Knopf statt eines Auswahlfeldes -- beim
Abarbeiten einer Liste zaehlt jeder gesparte Klick.

Punkte, die auf den KUNDEN warten, sind golden hinterlegt und tragen eine
Marke. Ohne diese Unterscheidung liest man die Liste als reinen
Fortschrittsbericht und uebersieht, dass man selbst gar nicht am Zug ist.
Der goldene Grund verschwindet, sobald der Punkt erledigt ist -- er
braucht dann keine Aufmerksamkeit mehr.

Abhaken faerbt die Zeile sofort um, ohne auf den Server zu warten. Bei
zehn Punkten hintereinander waere ein Neuaufbau nach jedem Klick zaeh, und
man verliert die Stelle. Geht es schief, wird die Liste neu geholt und der
Zustand ist wieder ehrlich.

POSTFACH

Links Verlaeufe, rechts das Gespraech. Nicht aus Nachahmung, sondern weil
man ein Gespraech abarbeitet und nicht eine Zeile: Man will sehen, was
vorher besprochen wurde, waehrend man antwortet. Kunde links, eigene
Nachrichten rechts -- man erkennt den Absender an der Seite, bevor man den
Namen liest.

Der Schalter "Nur interne Notiz" faerbt das ganze Schreibfeld um. Ein
Haekchen allein uebersieht man, und eine interne Bemerkung, die beim
Kunden landet, ist der peinlichste Fehler, den dieses System machen kann.
Interne Notizen im Verlauf sind gestrichelt umrandet und golden -- sie
duerfen nicht wie etwas Gesendetes aussehen.

Strg+Enter sendet, Enter allein nicht: In einem mehrzeiligen Feld will man
Absaetze machen koennen, ohne dass die halbe Nachricht rausgeht.

EIN FEHLER, DEN DIE TESTS NICHT GEFUNDEN HABEN

Der erste Lauf meldete 37 von 37 gruen -- und jede Aufgabenzeile war
sichtbar falsch herum gebaut: Kaestchen, dann die kleinen Knoepfe, Titel
ganz rechts. Aufgefallen ist es erst am Screenshot.

Ursache: Das Raster platziert zuerst alle Elemente mit FESTGELEGTER Zeile
und erst danach die freien. Kaestchen und Knopfgruppe hatten beide
"grid-row: 1 / span 2" und wurden deshalb zusammen nach vorne gesetzt; der
Titel landete als letzter in der dritten Spalte. Jetzt bekommt jedes Kind
Zeile UND Spalte ausdruecklich.

Die eigentliche Lehre steckt im Test: Alle 37 Pruefungen betrafen
Bestandteile (Farben, Anzahl, Durchgestrichenes), keine einzige die
ANORDNUNG. Wer eine Anordnung baut, muss die Anordnung pruefen. Drei neue
Pruefungen vergleichen jetzt die tatsaechlichen Bildschirmpositionen --
und sie wurden gegengeprueft: Mit dem alten Zustand schlagen sie fehl,
mit dem neuen nicht. Ein Test, der nie fehlschlaegt, ist wertlos.

GEPRUEFT: 43 Pruefungen, Computer und Handy, alle bestanden. Darunter:
alle Touch-Ziele mindestens 24 px, interne Notiz sieht anders aus als eine
gesendete, Verlauf springt ans Ende, keine Fehler im Protokoll.

Versionsstempel und Cache-Name auf v4.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-08-22 23:30:11 +02:00
co-authored by Claude Opus 5
parent 9186d4cc1a
commit b7c333fca3
15 changed files with 1050 additions and 51 deletions
+700 -7
View File
@@ -11,8 +11,8 @@
<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=20260822wd3" />
<link rel="stylesheet" href="/assets/css/webdesign.css?v=20260822wd3" />
<link rel="stylesheet" href="/assets/css/main.css?v=20260822wd4" />
<link rel="stylesheet" href="/assets/css/webdesign.css?v=20260822wd4" />
<style>
/* =====================================================================
Verwaltungsbereich — bewusst NUR AUF DEUTSCH.
@@ -401,6 +401,205 @@
.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: rgba(232,200,119,.13); border: 1px solid rgba(232,200,119,.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: .74rem; 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: rgba(127,208,232,.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: 7px; 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: rgba(232,200,119,.055); }
.vw-aufg--kunde:hover { background: rgba(232,200,119,.1); }
.vw-aufg-wer {
font-size: .68rem; letter-spacing: .05em; text-transform: uppercase;
padding: .12rem .4rem; border-radius: 999px; white-space: nowrap;
background: rgba(232,200,119,.15); color: var(--wd-gold);
}
.vw-aufg-nicht {
display: inline-block; margin-left: .35rem;
font-size: .68rem; letter-spacing: .05em; text-transform: uppercase;
padding: .12rem .4rem; border-radius: 999px;
background: rgba(255,157,157,.12); color: var(--wd-fehler);
}
.vw-mini {
padding: .2rem .45rem; border-radius: 7px; 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: 12px; border: 1px solid var(--wd-rand);
background: var(--wd-glas); color: inherit;
}
.vw-pf-eintrag:hover { border-color: var(--wd-rand-stark); background: rgba(127,208,232,.05); }
.vw-pf-eintrag.vw-an { border-color: var(--wd-blau); background: rgba(127,208,232,.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: .74rem; 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: rgba(127,208,232,.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: rgba(232,200,119,.08);
border: 1px dashed rgba(232,200,119,.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: rgba(232,200,119,.45); background: rgba(232,200,119,.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">
@@ -437,7 +636,10 @@
<h1 id="vw-titel">Projektanfragen</h1>
<div class="vw-tabs">
<button type="button" class="vw-filter-knopf vw-an" data-tab="anfragen">Anfragen</button>
<button type="button" class="vw-filter-knopf" data-tab="projekte">Projekte</button>
<button type="button" class="vw-filter-knopf" data-tab="kunden">Kunden</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>
@@ -512,6 +714,33 @@
<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>
<!-- ============ 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>
@@ -524,7 +753,7 @@
<div class="vw-detail-blatt" id="vw-detail-blatt"></div>
</div>
<script src="/assets/js/wd-core.js?v=20260822wd3"></script>
<script src="/assets/js/wd-core.js?v=20260822wd4"></script>
<script>
/* =====================================================================
Verwaltung: Anfragen ansehen, Status ändern, Notizen, Archivieren.
@@ -614,6 +843,10 @@
document.getElementById("vw-name").textContent = antwort.name || antwort.actor || "angemeldet";
zeigeBereich();
ladeAnfragen();
/* 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();
} catch (fehler) {
feld.classList.add("wd-feld--fehler");
feld.querySelector(".wd-feld-fehler").textContent = fehler.message || "Anmeldung fehlgeschlagen.";
@@ -864,6 +1097,10 @@
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);
@@ -1175,17 +1412,469 @@
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 ANSICHTEN = {
anfragen: { titel: "Projektanfragen", kasten: null },
projekte: { titel: "Projekte", kasten: "vw-projekte-bereich" },
kunden: { titel: "Kunden", kasten: "vw-kunden-bereich" },
postfach: { titel: "Postfach", kasten: "vw-postfach-bereich" },
};
var istAnfragen = aktiverTab === "anfragen";
document.getElementById("vw-titel").textContent = istAnfragen ? "Projektanfragen" : "Kunden";
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;
document.getElementById("vw-kunden-bereich").hidden = istAnfragen;
if (!istAnfragen) ladeKunden();
Object.keys(ANSICHTEN).forEach(function (name) {
var id = ANSICHTEN[name].kasten;
if (id) document.getElementById(id).hidden = name !== aktiverTab;
});
if (aktiverTab === "kunden") ladeKunden();
if (aktiverTab === "projekte") ladeProjekte();
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;
/* 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) + "/aufgaben");
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) +
(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>"
: "");
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"); }
});
});
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);
}
}
/* =================================================================
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>';
@@ -1482,7 +2171,7 @@
async function start() {
token = tokenLesen();
if (token) { zeigeBereich(); ladeAnfragen(); return; }
if (token) { zeigeBereich(); ladeAnfragen(); zaehlerHolen(); return; }
var ausweis = await ausweisHolen();
if (ausweis) {
@@ -1492,6 +2181,10 @@
(document.cookie.match(/dogiwd_rolle=([^;]+)/) || [])[1] === "vanvan" ? "VanVan" : "Dogfather";
zeigeBereich();
ladeAnfragen();
/* 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();
return;
}
zeigeAnmeldung();