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:
@@ -0,0 +1,256 @@
|
||||
/* Prüft die neuen Bereiche der Verwaltung: Projekte mit Aufgabenliste
|
||||
und das Postfach. Wieder mit abgefangenen Netzanfragen, aber mit
|
||||
Daten, die den heiklen Faellen entsprechen:
|
||||
|
||||
- ein Punkt, der auf den KUNDEN wartet (muss auffallen)
|
||||
- ein Punkt "nicht enthalten" (muss als solcher erkennbar sein)
|
||||
- eine INTERNE Notiz im Postfach (darf nicht wie eine gesendete
|
||||
Nachricht aussehen)
|
||||
*/
|
||||
import { chromium } from "playwright";
|
||||
import { createServer } from "node:http";
|
||||
import { readFileSync, existsSync } from "node:fs";
|
||||
import { join, extname } from "node:path";
|
||||
|
||||
const W = process.cwd();
|
||||
const T = { ".html": "text/html", ".css": "text/css", ".js": "text/javascript",
|
||||
".png": "image/png", ".jpg": "image/jpeg", ".svg": "image/svg+xml",
|
||||
".webp": "image/webp", ".ico": "image/x-icon", ".json": "application/json" };
|
||||
const srv = createServer((q, r) => {
|
||||
const p = join(W, decodeURIComponent(q.url.split("?")[0]));
|
||||
if (existsSync(p) && !p.endsWith("/")) {
|
||||
r.writeHead(200, { "content-type": T[extname(p)] || "text/plain" });
|
||||
return r.end(readFileSync(p));
|
||||
}
|
||||
r.writeHead(404).end("");
|
||||
});
|
||||
await new Promise((r) => srv.listen(4325, r));
|
||||
|
||||
const PROJEKT = {
|
||||
id: "p1", nummer: "P-0001", titel: "Website für Musterbau", paket: "website",
|
||||
status: "design", kunde_id: "k1", kunde_name: "Anna Muster", kunde_firma: "Musterbau",
|
||||
preis_cent: 180000, waehrung: "EUR", richttermin: null, archiviert: 0,
|
||||
anzahlung_bezahlt: 1, rest_bezahlt: 0, erstellt_am: new Date().toISOString(),
|
||||
aufg_gesamt: 6, aufg_erledigt: 2, aufg_kunde: 2, ungelesen: 1,
|
||||
};
|
||||
|
||||
const AUFGABEN = [
|
||||
{ id: "a1", titel: "Briefing-Gespräch führen", beschreibung: "Ziel und Umfang klären.",
|
||||
kategorie: "start", status: "erledigt", wer_dran: "dogfather", nicht_enthalten: 0, hinweis: null, reihenfolge: 10 },
|
||||
{ id: "a2", titel: "Texte und Bilder zusammenstellen", beschreibung: "Lieber früh als vollständig.",
|
||||
kategorie: "start", status: "offen", wer_dran: "kunde", nicht_enthalten: 0, hinweis: null, reihenfolge: 20 },
|
||||
{ id: "a3", titel: "Entwurf der Startseite", beschreibung: null,
|
||||
kategorie: "gestaltung", status: "erledigt", wer_dran: "dogfather", nicht_enthalten: 0, hinweis: null, reihenfolge: 110 },
|
||||
{ id: "a4", titel: "Entwurf ansehen und Rückmeldung geben", beschreibung: null,
|
||||
kategorie: "gestaltung", status: "offen", wer_dran: "kunde", nicht_enthalten: 0, hinweis: null, reihenfolge: 130 },
|
||||
{ id: "a5", titel: "Alle Unterseiten bauen", beschreibung: null,
|
||||
kategorie: "umsetzung", status: "in_arbeit", wer_dran: "dogfather", nicht_enthalten: 0, hinweis: null, reihenfolge: 210 },
|
||||
{ id: "a6", titel: "Mehrsprachigkeit", beschreibung: null,
|
||||
kategorie: "umsetzung", status: "offen", wer_dran: "dogfather", nicht_enthalten: 1,
|
||||
hinweis: "Nicht im Paket enthalten — auf Wunsch als Zusatz.", reihenfolge: 260 },
|
||||
{ id: "a7", titel: "Seite live schalten", beschreibung: null,
|
||||
kategorie: "abschluss", status: "offen", wer_dran: "dogfather", nicht_enthalten: 0, hinweis: null, reihenfolge: 860 },
|
||||
];
|
||||
|
||||
const FORTSCHRITT = {
|
||||
gesamt: 6, erledigt: 2, inArbeit: 1, beimKunden: 2, prozent: 33,
|
||||
jeKategorie: {
|
||||
start: { gesamt: 2, erledigt: 1, inArbeit: 0 },
|
||||
gestaltung: { gesamt: 2, erledigt: 1, inArbeit: 0 },
|
||||
umsetzung: { gesamt: 1, erledigt: 0, inArbeit: 1 },
|
||||
abschluss: { gesamt: 1, erledigt: 0, inArbeit: 0 },
|
||||
},
|
||||
};
|
||||
|
||||
const VERLAUF = [
|
||||
{ id: "n1", autor: "kunde", text: "Hallo! Können wir das Logo noch etwas grösser machen?",
|
||||
intern: 0, projekt_titel: null, erstellt_am: new Date(Date.now() - 7200000).toISOString() },
|
||||
{ id: "n2", autor: "dogfather", text: "Klar, mache ich heute noch.",
|
||||
intern: 0, projekt_titel: "Website für Musterbau", erstellt_am: new Date(Date.now() - 3600000).toISOString() },
|
||||
{ id: "n3", autor: "dogfather", text: "Nicht vergessen: Rechnung erst nach Abnahme.",
|
||||
intern: 1, projekt_titel: null, erstellt_am: new Date(Date.now() - 1800000).toISOString() },
|
||||
];
|
||||
|
||||
const ergebnisse = [];
|
||||
function pruefe(name, ok, zusatz) {
|
||||
ergebnisse.push({ name, ok });
|
||||
console.log((ok ? " ok " : " FEHL ") + name + (zusatz ? " -> " + zusatz : ""));
|
||||
}
|
||||
|
||||
const browser = await chromium.launch();
|
||||
|
||||
for (const [wo, breite, hoehe] of [["Computer", 1440, 950], ["Handy", 390, 844]]) {
|
||||
const seite = await browser.newPage({ viewport: { width: breite, height: hoehe } });
|
||||
const fehlerImProtokoll = [];
|
||||
seite.on("pageerror", (e) => fehlerImProtokoll.push(e.message));
|
||||
|
||||
// NUR die API abfangen. Ein Muster, das den ganzen Ordner /webdesign
|
||||
// umfasst, trifft auch die Seite SELBST -- dann bekommt der Browser
|
||||
// statt der HTML-Datei ein JSON serviert. Genau das ist beim ersten
|
||||
// Lauf passiert: Die Seite blieb leer, und der Test meldete
|
||||
// "Abzeichen fehlt" statt "Seite gar nicht geladen".
|
||||
// (Und der Hinweis steht in Zeilenkommentaren, weil das Glob-Muster
|
||||
// ein Sternchen-Schrägstrich enthält und einen Blockkommentar
|
||||
// vorzeitig beenden würde -- auch das ist hier schon passiert.)
|
||||
await seite.route("**/webdesign/admin/**", (route) => {
|
||||
const u = route.request().url();
|
||||
if (u.includes("/admin/projekte/p1/aufgaben")) {
|
||||
return route.fulfill({ json: { ok: true, projekt: PROJEKT, aufgaben: AUFGABEN, fortschritt: FORTSCHRITT } });
|
||||
}
|
||||
if (u.includes("/admin/projekte")) return route.fulfill({ json: { ok: true, projekte: [PROJEKT] } });
|
||||
if (u.includes("/admin/postfach-zaehler")) return route.fulfill({ json: { ok: true, ungelesen: 1 } });
|
||||
if (u.includes("/admin/postfach/k1")) {
|
||||
return route.fulfill({ json: { ok: true,
|
||||
kunde: { id: "k1", name: "Anna Muster", email: "[email protected]", sprache: "de" },
|
||||
nachrichten: VERLAUF, projekte: [{ id: "p1", titel: "Website für Musterbau" }] } });
|
||||
}
|
||||
if (u.includes("/admin/postfach")) {
|
||||
return route.fulfill({ json: { ok: true, ungelesen: 1, verlaeufe: [{
|
||||
kunde_id: "k1", name: "Anna Muster", firma: "Musterbau", email: "[email protected]",
|
||||
anzahl: 3, ungelesen: 1, letzte: new Date(Date.now() - 1800000).toISOString(),
|
||||
letzter_text: "Nicht vergessen: Rechnung erst nach Abnahme.", letzter_autor: "dogfather" }] } });
|
||||
}
|
||||
if (u.includes("/admin/anfragen")) return route.fulfill({ json: { ok: true, anfragen: [] } });
|
||||
if (u.includes("/admin/kunden")) return route.fulfill({ json: { ok: true, kunden: [] } });
|
||||
return route.fulfill({ json: { ok: true } });
|
||||
});
|
||||
await seite.route("**/auth/**", (r) => r.fulfill({ json: { ok: true, user: { name: "Test" } } }));
|
||||
await seite.addInitScript(() => {
|
||||
sessionStorage.setItem("wd-vw-token", "t");
|
||||
sessionStorage.setItem("wd-sitzung-aktiv", "1");
|
||||
});
|
||||
|
||||
await seite.goto("http://127.0.0.1:4325/webdesign/verwaltung.html");
|
||||
await seite.waitForTimeout(900);
|
||||
console.log("\n=== " + wo + " ===");
|
||||
|
||||
// Abzeichen am Reiter
|
||||
const abz = await seite.locator("#vw-pf-zaehler").innerText().catch(() => "");
|
||||
const abzSichtbar = await seite.locator("#vw-pf-zaehler").isVisible().catch(() => false);
|
||||
pruefe(wo + ": Postfach-Abzeichen zeigt die ungelesene Nachricht", abzSichtbar && abz === "1", abz);
|
||||
|
||||
// ---- Projekte ----
|
||||
await seite.click('[data-tab="projekte"]');
|
||||
await seite.waitForTimeout(500);
|
||||
pruefe(wo + ": Projektliste zeigt das Projekt", await seite.locator("[data-projekt]").count() === 1);
|
||||
pruefe(wo + ": „wartet auf Kunde“ ist sichtbar", await seite.locator(".vw-wartet").count() === 1);
|
||||
|
||||
await seite.click("[data-projekt]");
|
||||
await seite.waitForTimeout(600);
|
||||
|
||||
const m = await seite.evaluate(() => {
|
||||
const gruppen = Array.from(document.querySelectorAll(".vw-aufg-gruppe")).map((g) => g.dataset.k);
|
||||
const farben = Array.from(document.querySelectorAll(".vw-aufg-gruppe")).map((g) =>
|
||||
getComputedStyle(g.querySelector(".vw-aufg-kopf")).color);
|
||||
const balken = Array.from(document.querySelectorAll(".vw-fs-teil > i")).map((i) =>
|
||||
getComputedStyle(i).backgroundColor);
|
||||
const zeile = document.querySelector('[data-aufgabe="a2"]');
|
||||
const normal = document.querySelector('[data-aufgabe="a5"]');
|
||||
return {
|
||||
gruppen, farben, balkenFarben: [...new Set(balken)].length,
|
||||
kundenZeileAnders: zeile && normal
|
||||
? getComputedStyle(zeile).backgroundColor !== getComputedStyle(normal).backgroundColor : false,
|
||||
nichtEnthalten: document.querySelectorAll(".vw-aufg-nicht").length,
|
||||
haken: document.querySelectorAll(".vw-aufg-haken").length,
|
||||
erledigtDurchgestrichen: getComputedStyle(
|
||||
document.querySelector('[data-aufgabe="a1"] .vw-aufg-titel')).textDecorationLine,
|
||||
blickFelder: document.querySelectorAll(".vw-blick-feld").length,
|
||||
|
||||
/* Die REIHENFOLGE im Bild, nicht im Quelltext.
|
||||
|
||||
Diese Pruefung fehlte im ersten Anlauf, und deshalb meldete der
|
||||
Test 37 von 37 gruen, waehrend jede Zeile sichtbar falsch herum
|
||||
gebaut war: Kaestchen, dann die kleinen Knoepfe, und der Titel
|
||||
ganz rechts. Alle inhaltlichen Pruefungen (Farben, Anzahl,
|
||||
Durchgestrichenes) trafen zu -- keine einzige schaute darauf, WO
|
||||
die Dinge liegen. Aufgefallen ist es erst am Screenshot.
|
||||
|
||||
Lehre: Wer eine Anordnung baut, muss die Anordnung pruefen, nicht
|
||||
nur ihre Bestandteile. */
|
||||
anordnung: (() => {
|
||||
const z = document.querySelector('[data-aufgabe="a2"]');
|
||||
if (!z) return null;
|
||||
const k = z.querySelector(".vw-aufg-haken").getBoundingClientRect();
|
||||
const t = z.querySelector(".vw-aufg-titel").getBoundingClientRect();
|
||||
const r = z.querySelector(".vw-aufg-rechts").getBoundingClientRect();
|
||||
const b = z.querySelector(".vw-aufg-text");
|
||||
return {
|
||||
reihenfolgeStimmt: k.right <= t.left + 1 && t.right <= r.left + 1,
|
||||
textUnterTitel: b ? b.getBoundingClientRect().top >= t.bottom - 2 : true,
|
||||
textNichtUnterKaestchen: b ? b.getBoundingClientRect().left >= k.right - 1 : true,
|
||||
};
|
||||
})(),
|
||||
};
|
||||
});
|
||||
|
||||
pruefe(wo + ": alle vier Abschnitte erscheinen", m.gruppen.join(",") === "start,gestaltung,umsetzung,abschluss", m.gruppen.join(","));
|
||||
pruefe(wo + ": jeder Abschnitt hat eine EIGENE Farbe", new Set(m.farben).size === 4, m.farben.join(" | "));
|
||||
pruefe(wo + ": Punkt für den Kunden hebt sich ab", m.kundenZeileAnders);
|
||||
pruefe(wo + ": „nicht enthalten“ ist gekennzeichnet", m.nichtEnthalten === 1);
|
||||
pruefe(wo + ": jeder Punkt hat ein Kästchen zum Abhaken", m.haken === 7, "n=" + m.haken);
|
||||
pruefe(wo + ": Erledigtes ist durchgestrichen", m.erledigtDurchgestrichen.includes("line-through"), m.erledigtDurchgestrichen);
|
||||
pruefe(wo + ": Zusammenfassung mit vier Zahlen", m.blickFelder === 4);
|
||||
pruefe(wo + ": Kästchen — Titel — Knöpfe stehen in dieser Reihenfolge", m.anordnung?.reihenfolgeStimmt);
|
||||
pruefe(wo + ": die Erklärung steht UNTER dem Titel", m.anordnung?.textUnterTitel);
|
||||
pruefe(wo + ": die Erklärung ist am Titel ausgerichtet, nicht am Rand", m.anordnung?.textNichtUnterKaestchen);
|
||||
|
||||
// Touch-Ziele auf dem Handy
|
||||
if (breite < 900) {
|
||||
const zuKlein = await seite.evaluate(() =>
|
||||
Array.from(document.querySelectorAll(".vw-aufg-haken, .vw-mini"))
|
||||
.filter((b) => { const r = b.getBoundingClientRect(); return r.width < 24 || r.height < 24; }).length);
|
||||
pruefe(wo + ": alle Knöpfe sind gross genug zum Antippen", zuKlein === 0, "zu klein: " + zuKlein);
|
||||
}
|
||||
|
||||
await seite.screenshot({ path: `pruef-aufgaben-${wo.toLowerCase()}.png` });
|
||||
await seite.keyboard.press("Escape");
|
||||
await seite.waitForTimeout(300);
|
||||
|
||||
// ---- Postfach ----
|
||||
await seite.click('[data-tab="postfach"]');
|
||||
await seite.waitForTimeout(500);
|
||||
pruefe(wo + ": Postfach zeigt den Verlauf", await seite.locator("[data-verlauf]").count() === 1);
|
||||
|
||||
await seite.click("[data-verlauf]");
|
||||
await seite.waitForTimeout(600);
|
||||
|
||||
const pf = await seite.evaluate(() => {
|
||||
const intern = document.querySelector(".vw-blase--intern");
|
||||
const wir = document.querySelector(".vw-blase--wir");
|
||||
const kunde = document.querySelector(".vw-blase--kunde");
|
||||
const lauf = document.getElementById("vw-blasen");
|
||||
return {
|
||||
anzahl: document.querySelectorAll(".vw-blase").length,
|
||||
internDa: !!intern,
|
||||
internAnders: intern && wir
|
||||
? getComputedStyle(intern).borderStyle !== getComputedStyle(wir).borderStyle : false,
|
||||
kundeLinks: kunde && wir
|
||||
? kunde.getBoundingClientRect().left < wir.getBoundingClientRect().left : false,
|
||||
amEnde: lauf ? lauf.scrollTop + lauf.clientHeight >= lauf.scrollHeight - 5 : false,
|
||||
schalterDa: !!document.getElementById("vw-pf-intern"),
|
||||
};
|
||||
});
|
||||
pruefe(wo + ": alle drei Nachrichten erscheinen", pf.anzahl === 3, "n=" + pf.anzahl);
|
||||
pruefe(wo + ": die interne Notiz ist da", pf.internDa);
|
||||
pruefe(wo + ": interne Notiz sieht ANDERS aus als eine gesendete", pf.internAnders);
|
||||
pruefe(wo + ": Kunde links, eigene Nachrichten rechts", pf.kundeLinks);
|
||||
pruefe(wo + ": Verlauf springt ans Ende", pf.amEnde);
|
||||
|
||||
// Der Schalter muss das Schreibfeld sichtbar umfaerben
|
||||
const vorher = await seite.evaluate(() => getComputedStyle(document.getElementById("vw-schreibfeld")).borderColor);
|
||||
await seite.check("#vw-pf-intern");
|
||||
await seite.waitForTimeout(250);
|
||||
const nachher = await seite.evaluate(() => getComputedStyle(document.getElementById("vw-schreibfeld")).borderColor);
|
||||
pruefe(wo + ": „interne Notiz“ färbt das Schreibfeld sichtbar um", vorher !== nachher, vorher + " -> " + nachher);
|
||||
|
||||
pruefe(wo + ": keine Fehler im Protokoll", fehlerImProtokoll.length === 0, fehlerImProtokoll.join(" | "));
|
||||
|
||||
await seite.screenshot({ path: `pruef-postfach-${wo.toLowerCase()}.png` });
|
||||
await seite.close();
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
srv.close();
|
||||
const fehler = ergebnisse.filter((e) => !e.ok).length;
|
||||
console.log(`\n===== ${ergebnisse.length - fehler} bestanden, ${fehler} fehlgeschlagen =====`);
|
||||
process.exitCode = fehler ? 1 : 0;
|
||||
@@ -99,7 +99,7 @@ import {
|
||||
Kundenzugaenge -- es gibt bewusst keine Selbstregistrierung. */
|
||||
import {
|
||||
kundeAnlegen, kundeEinladungNeu, kundenListe, kundeSchalten,
|
||||
projektAnlegen, projektAendern, aenderungBeziffern, projektNachricht,
|
||||
projektAnlegen, projektAendern, aenderungBeziffern, projektNachricht, projekteListe,
|
||||
} from "./routes/webdesign-kunden.js";
|
||||
/* Aufgabenliste je Projekt ("was ist schon gemacht von dem, was im Plan
|
||||
war") und allgemeines Postfach fuer Nachrichten OHNE Projektbezug. */
|
||||
@@ -279,6 +279,7 @@ app.get("/webdesign/admin/kunden", wrap(kundenListe));
|
||||
app.post("/webdesign/admin/kunden", wrap(kundeAnlegen));
|
||||
app.post("/webdesign/admin/kunden/:id/einladung", wrap(kundeEinladungNeu));
|
||||
app.post("/webdesign/admin/kunden/:id/zugang", wrap(kundeSchalten));
|
||||
app.get("/webdesign/admin/projekte", wrap(projekteListe));
|
||||
app.post("/webdesign/admin/projekte", wrap(projektAnlegen));
|
||||
app.post("/webdesign/admin/projekte/:id", wrap(projektAendern));
|
||||
app.post("/webdesign/admin/aenderungen/:id/beziffern", wrap(aenderungBeziffern));
|
||||
|
||||
@@ -166,6 +166,55 @@ export async function kundenListe(req, res) {
|
||||
return json(res, { ok: true, kunden: zeilen });
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------
|
||||
PROJEKTE — flache Liste über alle Kunden
|
||||
|
||||
Bisher gab es nur die Anzahl je Kunde. Zum Abhaken von Aufgaben muss
|
||||
man aber ein einzelnes Projekt öffnen können, ohne vorher zu wissen,
|
||||
wem es gehört — man denkt in Projekten, nicht in Kunden.
|
||||
|
||||
Der Fortschritt wird gleich mitgerechnet. Er einzeln je Projekt
|
||||
nachzufragen hiesse bei zwanzig Projekten einundzwanzig Anfragen,
|
||||
und die Liste würde sichtbar stockend aufbauen.
|
||||
--------------------------------------------------------------------- */
|
||||
export async function projekteListe(req, res) {
|
||||
const p = await darf(req, "WD_PROJEKTE_MANAGE");
|
||||
if (!p.erlaubt) return json(res, { ok: false, error: "Kein Zugriff." }, p.grund);
|
||||
|
||||
const zeigeArchiv = req.query?.archiv === "1";
|
||||
|
||||
const zeilen = db
|
||||
.prepare(
|
||||
`SELECT p.id, p.nummer, p.titel, p.paket, p.status, p.kunde_id,
|
||||
p.preis_cent, p.waehrung, p.richttermin, p.archiviert,
|
||||
p.anzahlung_bezahlt, p.rest_bezahlt, p.erstellt_am,
|
||||
k.name AS kunde_name, k.firma AS kunde_firma,
|
||||
|
||||
/* Aufgabenzahlen direkt mitgeliefert. "entfaellt" zaehlt
|
||||
nicht mit -- ein weggefallener Punkt darf den Fortschritt
|
||||
weder bremsen noch beschleunigen. */
|
||||
(SELECT COUNT(*) FROM wd_aufgaben a
|
||||
WHERE a.projekt_id = p.id AND a.status <> 'entfaellt') AS aufg_gesamt,
|
||||
(SELECT COUNT(*) FROM wd_aufgaben a
|
||||
WHERE a.projekt_id = p.id AND a.status = 'erledigt') AS aufg_erledigt,
|
||||
/* Die Zahl, die zuerst interessiert: worauf warte ICH gerade
|
||||
vom Kunden? Steht sie über 0, ist Nachfassen dran. */
|
||||
(SELECT COUNT(*) FROM wd_aufgaben a
|
||||
WHERE a.projekt_id = p.id AND a.wer_dran = 'kunde'
|
||||
AND a.status <> 'erledigt' AND a.status <> 'entfaellt') AS aufg_kunde,
|
||||
(SELECT COUNT(*) FROM wd_postfach n
|
||||
WHERE n.projekt_id = p.id AND n.gelesen_admin = 0
|
||||
AND n.autor = 'kunde') AS ungelesen
|
||||
FROM wd_projekte p
|
||||
JOIN wd_kunden k ON k.id = p.kunde_id
|
||||
WHERE p.archiviert = ?
|
||||
ORDER BY p.erstellt_am DESC LIMIT 300`
|
||||
)
|
||||
.all(zeigeArchiv ? 1 : 0);
|
||||
|
||||
return json(res, { ok: true, projekte: zeilen });
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------
|
||||
FREISCHALTEN / SPERREN
|
||||
--------------------------------------------------------------------- */
|
||||
|
||||
@@ -13,8 +13,8 @@
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="Dogfather Webdesign" />
|
||||
<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>
|
||||
/* Zeitstrahl: die Linie verbindet die Nummernkreise. Sie liegt hinter
|
||||
den Kreisen (z-index) und endet beim letzten Schritt, damit sie nicht
|
||||
@@ -183,7 +183,7 @@
|
||||
|
||||
<div id="wd-fuss"></div>
|
||||
|
||||
<script src="/assets/js/i18n-wd-ablauf.js?v=20260822wd3"></script>
|
||||
<script src="/assets/js/wd-core.js?v=20260822wd3"></script>
|
||||
<script src="/assets/js/i18n-wd-ablauf.js?v=20260822wd4"></script>
|
||||
<script src="/assets/js/wd-core.js?v=20260822wd4"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -13,8 +13,8 @@
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="Dogfather Webdesign" />
|
||||
<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>
|
||||
/* ---------- Schrittanzeige ----------
|
||||
Zeigt alle vier Schritte gleichzeitig statt nur "3 von 4". Man sieht
|
||||
@@ -418,8 +418,8 @@
|
||||
|
||||
<div id="wd-fuss"></div>
|
||||
|
||||
<script src="/assets/js/i18n-wd-anfrage.js?v=20260822wd3"></script>
|
||||
<script src="/assets/js/wd-core.js?v=20260822wd3"></script>
|
||||
<script src="/assets/js/wd-anfrage.js?v=20260822wd3"></script>
|
||||
<script src="/assets/js/i18n-wd-anfrage.js?v=20260822wd4"></script>
|
||||
<script src="/assets/js/wd-core.js?v=20260822wd4"></script>
|
||||
<script src="/assets/js/wd-anfrage.js?v=20260822wd4"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+4
-4
@@ -13,8 +13,8 @@
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="Dogfather Webdesign" />
|
||||
<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>
|
||||
.fq-thema { scroll-margin-top: 90px; }
|
||||
.fq-thema + .fq-thema { margin-top: clamp(2rem, 4vw, 3rem); }
|
||||
@@ -195,7 +195,7 @@
|
||||
|
||||
<div id="wd-fuss"></div>
|
||||
|
||||
<script src="/assets/js/i18n-wd-faq.js?v=20260822wd3"></script>
|
||||
<script src="/assets/js/wd-core.js?v=20260822wd3"></script>
|
||||
<script src="/assets/js/i18n-wd-faq.js?v=20260822wd4"></script>
|
||||
<script src="/assets/js/wd-core.js?v=20260822wd4"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -16,8 +16,8 @@
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="Dogfather Webdesign" />
|
||||
<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>
|
||||
/* ---------- Nur auf der Startseite gebraucht ---------- */
|
||||
|
||||
@@ -407,8 +407,8 @@
|
||||
|
||||
<div id="wd-fuss"></div>
|
||||
|
||||
<script src="/assets/js/i18n-wd-index.js?v=20260822wd3"></script>
|
||||
<script src="/assets/js/wd-core.js?v=20260822wd3"></script>
|
||||
<script src="/assets/js/i18n-wd-index.js?v=20260822wd4"></script>
|
||||
<script src="/assets/js/wd-core.js?v=20260822wd4"></script>
|
||||
<script>
|
||||
/* ---------------------------------------------------------------------
|
||||
Marken-Intro (Masterplan S.3, Schritt 02: "Nach erfolgreichem Code
|
||||
|
||||
@@ -13,8 +13,8 @@
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="Dogfather Webdesign" />
|
||||
<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>
|
||||
/* Sprungmarken.
|
||||
Frueher eine waagerecht wischbare Leiste. Rueckmeldung 22.08.2026:
|
||||
@@ -331,7 +331,7 @@
|
||||
|
||||
<div id="wd-fuss"></div>
|
||||
|
||||
<script src="/assets/js/i18n-wd-leistungen.js?v=20260822wd3"></script>
|
||||
<script src="/assets/js/wd-core.js?v=20260822wd3"></script>
|
||||
<script src="/assets/js/i18n-wd-leistungen.js?v=20260822wd4"></script>
|
||||
<script src="/assets/js/wd-core.js?v=20260822wd4"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -12,8 +12,8 @@
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="Dogfather Webdesign" />
|
||||
<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>
|
||||
.po-login { max-width: 440px; margin: clamp(1.5rem,6vh,4rem) auto; }
|
||||
|
||||
@@ -134,8 +134,8 @@
|
||||
|
||||
<div id="wd-fuss"></div>
|
||||
|
||||
<script src="/assets/js/i18n-wd-portal.js?v=20260822wd3"></script>
|
||||
<script src="/assets/js/wd-core.js?v=20260822wd3"></script>
|
||||
<script src="/assets/js/wd-portal.js?v=20260822wd3"></script>
|
||||
<script src="/assets/js/i18n-wd-portal.js?v=20260822wd4"></script>
|
||||
<script src="/assets/js/wd-core.js?v=20260822wd4"></script>
|
||||
<script src="/assets/js/wd-portal.js?v=20260822wd4"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -13,8 +13,8 @@
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="Dogfather Webdesign" />
|
||||
<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>
|
||||
.pfo-projekt { overflow: hidden; padding: 0; margin-bottom: clamp(1.4rem, 3vw, 2.2rem); }
|
||||
.pfo-bild {
|
||||
@@ -192,7 +192,7 @@
|
||||
|
||||
<div id="wd-fuss"></div>
|
||||
|
||||
<script src="/assets/js/i18n-wd-portfolio.js?v=20260822wd3"></script>
|
||||
<script src="/assets/js/wd-core.js?v=20260822wd3"></script>
|
||||
<script src="/assets/js/i18n-wd-portfolio.js?v=20260822wd4"></script>
|
||||
<script src="/assets/js/wd-core.js?v=20260822wd4"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -13,8 +13,8 @@
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="Dogfather Webdesign" />
|
||||
<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>
|
||||
.pr-preis-gross { font-family: var(--font-head); font-weight: 800; color: var(--wd-blau); white-space: nowrap; }
|
||||
|
||||
@@ -196,7 +196,7 @@
|
||||
|
||||
<div id="wd-fuss"></div>
|
||||
|
||||
<script src="/assets/js/i18n-wd-preise.js?v=20260822wd3"></script>
|
||||
<script src="/assets/js/wd-core.js?v=20260822wd3"></script>
|
||||
<script src="/assets/js/i18n-wd-preise.js?v=20260822wd4"></script>
|
||||
<script src="/assets/js/wd-core.js?v=20260822wd4"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+1
-1
@@ -34,7 +34,7 @@
|
||||
Start alle alten Zwischenspeicher weg. Muss bei jeder Änderung an den
|
||||
Dateien unten hochgezählt werden, sonst hängen Nutzer auf einem alten
|
||||
Stand fest. */
|
||||
const CACHE_NAME = "dogfather-webdesign-v3";
|
||||
const CACHE_NAME = "dogfather-webdesign-v4";
|
||||
|
||||
/* Bausteine, die die Oberfläche zum Anzeigen braucht. Bewusst KEINE
|
||||
HTML-Datei in dieser Liste. */
|
||||
|
||||
@@ -13,8 +13,8 @@
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="Dogfather Webdesign" />
|
||||
<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>
|
||||
.ue-kopf { display: grid; grid-template-columns: minmax(0, .85fr) minmax(0, 1.15fr); gap: clamp(1.5rem, 4vw, 3rem); align-items: center; }
|
||||
/* Auf dem Handy zuerst der Text, dann das Bild: sonst schiebt ein
|
||||
@@ -127,7 +127,7 @@
|
||||
|
||||
<div id="wd-fuss"></div>
|
||||
|
||||
<script src="/assets/js/i18n-wd-ueber.js?v=20260822wd3"></script>
|
||||
<script src="/assets/js/wd-core.js?v=20260822wd3"></script>
|
||||
<script src="/assets/js/i18n-wd-ueber.js?v=20260822wd4"></script>
|
||||
<script src="/assets/js/wd-core.js?v=20260822wd4"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+700
-7
@@ -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();
|
||||
|
||||
@@ -21,8 +21,8 @@
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="Dogfather Webdesign" />
|
||||
<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>
|
||||
/* =====================================================================
|
||||
Zugangswand — nur hier gebraucht, deshalb bewusst NICHT in
|
||||
@@ -177,8 +177,8 @@
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script src="/assets/js/i18n-wd-zugang.js?v=20260822wd3"></script>
|
||||
<script src="/assets/js/wd-core.js?v=20260822wd3"></script>
|
||||
<script src="/assets/js/i18n-wd-zugang.js?v=20260822wd4"></script>
|
||||
<script src="/assets/js/wd-core.js?v=20260822wd4"></script>
|
||||
<script>
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
Reference in New Issue
Block a user