Verwaltung: Projektstand aendern, Wuensche beziffern, im Projekt antworten

Drei Server-Funktionen existierten seit Tagen und hatten KEINE Oberflaeche.
Erst im Vergleich "was kann der Server" gegen "was ruft die Seite auf"
ist es aufgefallen:

  POST /admin/projekte/:id           projektAendern
  POST /admin/aenderungen/:id/beziffern
  POST /admin/projekt-nachricht

Jede davon schliesst einen Kreis, der bisher offen war.

1. DIE SCHMERZHAFTESTE LUECKE: DER STAND

Der Kunde sieht in seinem Portal GANZ OBEN die Phasenleiste und den
"naechsten Schritt". Beides liess sich nirgends aendern. Ein Projekt blieb
also fuer immer im Briefing stehen, egal wie weit es wirklich war -- und
der prominenteste Text im ganzen Kundenportal war dauerhaft leer.

Jetzt: sieben Phasen als Knoepfe (plus Pausiert/Abgebrochen daneben, denn
das sind keine Phasen, sondern Zustaende), "wer ist am Zug", naechster
Schritt, Richttermin, Zahlungsstand, Portfolio-Freigabe.

Phase und "wer ist am Zug" speichern SOFORT ohne Speichern-Knopf: Es ist
ein Klick auf genau einen Wert, ein zweiter Klick waere Zeremonie. Die
Textfelder haben einen Knopf, weil man beim Tippen zwischendurch nicht
speichern will.

2. AENDERUNGSWUENSCHE

Der Kunde konnte Ideen einreichen, seit es das Portal gibt. Beziffern ging
serverseitig auch -- nur gab es keine Oberflaeche. Der Kreis war offen: Er
schickt etwas los und hoert nie wieder davon.

Jetzt stehen alle Wuensche im Projekt, offene mit Feldern fuer Preis,
Dauer und einer kurzen Erklaerung. Ohne Preis geht nichts raus (geprueft)
-- ohne Preis kann der Kunde nicht entscheiden, und ein "Angebot" ohne
Zahl ist keins.

3. NACHRICHTEN ZUM PROJEKT

Getrennt vom allgemeinen Postfach, weil sie zum Projekt gehoeren und im
Portal auch dort erscheinen. Sie hier nicht zu haben hiess: Der Kunde
schreibt im Projekt, und ich kann ihm nur woanders antworten.

AUFBAU

Neuer Endpunkt /admin/projekte/:id/alles liefert Projekt, Aufgaben,
Fortschritt, Aenderungswuensche und Nachrichten in EINER Antwort. Fuenf
Anfragen hintereinander wuerden die Ansicht sichtbar ruckelnd aufbauen.

Dieselbe Aufteilung wie in der Anfrageansicht -- links die Arbeit
(Aufgaben), rechts das Steuern. Wer zwischen beiden Ansichten wechselt,
muss nicht umdenken.

GEPRUEFT: 69 Pruefungen, Computer und Handy, alle bestanden.

Die neuen Pruefungen schauen nicht darauf, ob sich ein Knopf einfaerbt --
das beweist nichts. Sie schneiden mit, was die Seite TATSAECHLICH an den
Server schickt: {"status":"entwicklung"}, {"restBezahlt":true},
{"preisEuro":250,"dauer":"3 Tage"}. Und sie pruefen den Fall, in dem
nichts rausgehen darf: Ohne Preis bleibt die Mitschrift leer.

Ein Fehler im Test selbst behoben: Die Nachrichtenzaehlung war
document-weit und zaehlte die Nachrichten der geschlossenen Projektansicht
mit -- geschlossen heisst nicht aus dem Dokument entfernt. Jetzt auf den
Postfach-Verlauf eingegrenzt.

Versionsstempel und Cache-Name auf v6.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-08-23 12:08:57 +02:00
co-authored by Claude Opus 5
parent 469a6c195f
commit 98fc04c666
15 changed files with 470 additions and 50 deletions
+256 -5
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=20260823wd5" />
<link rel="stylesheet" href="/assets/css/webdesign.css?v=20260823wd5" />
<link rel="stylesheet" href="/assets/css/main.css?v=20260823wd6" />
<link rel="stylesheet" href="/assets/css/webdesign.css?v=20260823wd6" />
<style>
/* =====================================================================
Verwaltungsbereich — bewusst NUR AUF DEUTSCH.
@@ -753,7 +753,7 @@
<div class="vw-detail-blatt" id="vw-detail-blatt"></div>
</div>
<script src="/assets/js/wd-core.js?v=20260823wd5"></script>
<script src="/assets/js/wd-core.js?v=20260823wd6"></script>
<script>
/* =====================================================================
Verwaltung: Anfragen ansehen, Status ändern, Notizen, Archivieren.
@@ -1462,6 +1462,22 @@
};
var offenesProjekt = null;
/* Die Phasen des Projekts — dieselbe Reihenfolge wie die Leiste, die
der Kunde in seinem Portal sieht. "pausiert" und "abgebrochen"
stehen bewusst am Ende und ausserhalb der Reihe: Sie sind keine
Phase, sondern ein Zustand daneben. */
var PHASEN = ["briefing", "angebot", "design", "entwicklung", "tests", "abnahme", "uebergeben"];
var PHASEN_SONDER = ["pausiert", "abgebrochen"];
var PHASE_TEXT = {
briefing: "Briefing", angebot: "Angebot", design: "Design",
entwicklung: "Entwicklung", tests: "Tests", abnahme: "Abnahme",
uebergeben: "Übergeben", pausiert: "Pausiert", abgebrochen: "Abgebrochen",
};
var AE_TEXT = {
eingereicht: "Eingereicht", geprueft: "Wird geprüft", angeboten: "Angebot liegt vor",
angenommen: "Angenommen", abgelehnt: "Abgelehnt", umgesetzt: "Umgesetzt",
};
/* Der Balken in vier Abschnitten. Ein einzelner Balken sagt "60 %",
vier Abschnitte sagen zusaetzlich WO diese 60 % liegen. */
function balken(f) {
@@ -1567,7 +1583,7 @@
blatt.innerHTML = '<div class="vw-laedt">Wird geladen…</div>';
try {
var d = await api("/webdesign/admin/projekte/" + encodeURIComponent(projektId) + "/aufgaben");
var d = await api("/webdesign/admin/projekte/" + encodeURIComponent(projektId) + "/alles");
zeichneAufgaben(d);
} catch (fehler) {
if (fehler.status === 401) { schliesseDetail(); token = null; tokenSchreiben(null); zeigeAnmeldung(); return; }
@@ -1617,6 +1633,12 @@
"</div>" +
balken(f) +
/* Gleiche Aufteilung wie in der Anfrageansicht: links die Arbeit,
rechts das Steuern. Wer zwischen beiden Ansichten wechselt, muss
nicht umdenken. */
'<div class="vw-detail-spalten">' +
'<div class="vw-dt-links">' +
(d.aufgaben.length ? gruppen :
'<div class="vw-leer-hinweis">Für dieses Projekt gibt es noch keine Aufgabenliste.' +
'<div style="margin-top:1rem"><button type="button" class="wd-btn wd-btn--haupt wd-btn--klein" ' +
@@ -1628,7 +1650,136 @@
"Der Kunde sieht jede Änderung sofort in seinem Portal. " +
'<button type="button" class="vw-mini" id="vw-vorlage" style="min-width:auto;padding:.25rem .6rem">' +
"Fehlende Standardpunkte ergänzen</button></p>"
: "");
: "") +
"</div>" +
'<div class="vw-dt-rechts">' +
/* ----- STAND -----
Das Wichtigste an dieser ganzen Erweiterung. Phase und "nächster
Schritt" stehen im Portal des Kunden GANZ OBEN — und liessen
sich bis hierher nirgends ändern. Ein Projekt blieb dadurch für
immer im Briefing stehen, egal wie weit es wirklich war. */
'<div class="vw-abschnitt"><h3>Stand</h3>' +
'<p class="wd-klein wd-leise" style="margin-top:-.2rem">' +
"Das sieht der Kunde ganz oben in seinem Portal." +
"</p>" +
'<div class="vw-status-wahl" style="margin-bottom:.7rem">' +
PHASEN.map(function (k, i) {
return '<button type="button" class="vw-filter-knopf' + (p.status === k ? " vw-an" : "") +
'" data-phase="' + k + '">' + (i + 1) + ". " + s(PHASE_TEXT[k]) + "</button>";
}).join("") +
"</div>" +
'<div class="vw-status-wahl" style="margin-bottom:.9rem">' +
PHASEN_SONDER.map(function (k) {
return '<button type="button" class="vw-filter-knopf' + (p.status === k ? " vw-an" : "") +
'" data-phase="' + k + '">' + s(PHASE_TEXT[k]) + "</button>";
}).join("") +
"</div>" +
'<div class="wd-feld" style="margin-bottom:.6rem">' +
'<label for="vw-p-schritt">Nächster Schritt</label>' +
'<input type="text" id="vw-p-schritt" maxlength="300" value="' + s(p.naechster_schritt || "") +
'" placeholder="z. B. Entwurf ansehen und Rückmeldung geben" />' +
'<span class="wd-feld-hilfe">Ein Satz in der Sprache des Kunden — nicht „QA-Runde 2“.</span>' +
"</div>" +
'<div class="wd-feld" style="margin-bottom:.6rem">' +
"<label>Wer ist am Zug?</label>" +
'<div class="vw-status-wahl">' +
'<button type="button" class="vw-filter-knopf' + (p.wartet_auf === "dogfather" ? " vw-an" : "") +
'" data-wartet="dogfather">Ich</button>' +
'<button type="button" class="vw-filter-knopf' + (p.wartet_auf === "kunde" ? " vw-an" : "") +
'" data-wartet="kunde">Der Kunde</button>' +
"</div>" +
"</div>" +
'<div class="wd-feld" style="margin-bottom:.8rem">' +
'<label for="vw-p-termin">Richttermin</label>' +
'<input type="text" id="vw-p-termin" maxlength="40" value="' + s(p.richttermin || "") +
'" placeholder="z. B. Mitte Oktober" />' +
"</div>" +
'<button type="button" class="wd-btn wd-btn--haupt wd-btn--klein" id="vw-p-speichern">Stand speichern</button>' +
"</div>" +
/* ----- ZAHLUNGSSTAND ----- */
'<div class="vw-abschnitt"><h3>Zahlungsstand</h3>' +
'<label class="vw-pf-schalter" style="margin-bottom:.5rem">' +
'<input type="checkbox" id="vw-p-anz"' + (p.anzahlung_bezahlt ? " checked" : "") + " />" +
"<span>Anzahlung ist eingegangen</span></label>" +
'<label class="vw-pf-schalter" style="margin-bottom:.5rem">' +
'<input type="checkbox" id="vw-p-rest"' + (p.rest_bezahlt ? " checked" : "") + " />" +
"<span>Restbetrag ist eingegangen</span></label>" +
'<label class="vw-pf-schalter">' +
'<input type="checkbox" id="vw-p-portfolio"' + (p.portfolio_freigabe ? " checked" : "") + " />" +
"<span>Darf ins Portfolio</span></label>" +
'<p class="wd-klein wd-leise" style="margin:.5rem 0 0">Wird sofort gespeichert.</p>' +
"</div>" +
/* ----- ÄNDERUNGSWÜNSCHE -----
Der Kunde konnte Ideen einreichen, seit es das Portal gibt. Sie
zu beziffern ging serverseitig auch — nur gab es keine
Oberfläche. Der Kreis war also offen: Er schickt etwas los und
hört nie wieder davon. */
'<div class="vw-abschnitt"><h3>Änderungswünsche (' + d.aenderungen.length + ")</h3>" +
(d.aenderungen.length
? d.aenderungen.map(function (a) {
var offen = a.status === "eingereicht" || a.status === "geprueft";
return '<div class="vw-notiz" data-ae="' + s(a.id) + '">' +
'<div class="vw-notiz-kopf">' + window.WD.datum(a.erstellt_am) +
' · <span class="wd-tag' + (offen ? " wd-tag--gold" : a.status === "angenommen" ? " wd-tag--ok" : "") +
'">' + s(AE_TEXT[a.status] || a.status) + "</span></div>" +
"<p><strong>" + s(a.titel) + "</strong></p>" +
"<p>" + s(a.beschreibung || "") + "</p>" +
(a.zusatzpreis_cent != null
? '<p class="wd-klein wd-leise">Beziffert: ' +
(a.zusatzpreis_cent / 100).toFixed(2).replace(".", ",") + " €" +
(a.zusatzdauer_text ? " · " + s(a.zusatzdauer_text) : "") + "</p>"
: "") +
(offen
? '<div style="margin-top:.6rem">' +
'<div class="wd-feld" style="margin-bottom:.4rem">' +
'<input type="number" class="vw-ae-preis" min="0" step="1" inputmode="numeric" ' +
'placeholder="Preis in Euro" />' +
"</div>" +
'<div class="wd-feld" style="margin-bottom:.4rem">' +
'<input type="text" class="vw-ae-dauer" maxlength="200" placeholder="Zusätzliche Dauer, z. B. 3 Tage" />' +
"</div>" +
'<div class="wd-feld" style="margin-bottom:.5rem">' +
'<textarea class="vw-ae-antwort" rows="2" placeholder="Kurze Erklärung für den Kunden"></textarea>' +
"</div>" +
'<button type="button" class="wd-btn wd-btn--zweit wd-btn--klein" data-beziffern="' +
s(a.id) + '">Angebot an den Kunden schicken</button></div>'
: "") +
"</div>";
}).join("")
: '<p class="wd-klein wd-leise">Noch keine Wünsche eingereicht.</p>') +
"</div>" +
/* ----- NACHRICHTEN ZUM PROJEKT -----
Getrennt vom allgemeinen Postfach, weil sie hier zum Projekt
gehören und im Portal auch dort erscheinen. Sie hier NICHT zu
haben hiess: Der Kunde schreibt im Projekt, und ich kann ihm nur
woanders antworten. */
'<div class="vw-abschnitt"><h3>Nachrichten zum Projekt (' + d.nachrichten.length + ")</h3>" +
(d.nachrichten.length
? '<div class="vw-pf-verlauf" style="max-height:34vh" id="vw-p-blasen">' +
d.nachrichten.map(function (n) {
var vomKunden = n.autor === "kunde";
return '<div class="vw-blase vw-blase--' + (vomKunden ? "kunde" : "wir") + '">' +
'<div class="vw-blase-kopf">' +
(vomKunden ? s(p.kunde_name) : "Du") + " · " + window.WD.datum(n.erstellt_am) +
"</div>" + s(n.text) + "</div>";
}).join("") + "</div>"
: '<p class="wd-klein wd-leise">Noch keine Nachrichten.</p>') +
'<div class="wd-feld" style="margin:.7rem 0 .5rem">' +
'<textarea id="vw-p-nachricht" rows="2" placeholder="Antwort zum Projekt…"></textarea>' +
"</div>" +
'<button type="button" class="wd-btn wd-btn--zweit wd-btn--klein" id="vw-p-senden">Senden</button>' +
"</div>" +
"</div></div>";
blatt.querySelector(".vw-zu").addEventListener("click", schliesseDetail);
@@ -1669,6 +1820,106 @@
});
});
/* ---- Stand ändern ----
Phase und "wer ist am Zug" werden SOFORT gespeichert, ohne
Speichern-Knopf: Es ist ein Klick auf genau einen Wert, da wäre
ein zweiter Klick reine Zeremonie. Die Textfelder brauchen den
Knopf, weil man beim Tippen zwischendurch nicht speichern will. */
function standAendern(felder, danach) {
return api("/webdesign/admin/projekte/" + encodeURIComponent(offenesProjekt),
{ method: "POST", body: felder })
.then(function () { if (danach) danach(); })
.catch(function (f) { window.WD.meldung(meldung, f.message, "fehler"); });
}
blatt.querySelectorAll("[data-phase]").forEach(function (b) {
b.addEventListener("click", function () {
blatt.querySelectorAll("[data-phase]").forEach(function (x) {
x.classList.toggle("vw-an", x === b);
});
standAendern({ status: b.dataset.phase });
});
});
blatt.querySelectorAll("[data-wartet]").forEach(function (b) {
b.addEventListener("click", function () {
blatt.querySelectorAll("[data-wartet]").forEach(function (x) {
x.classList.toggle("vw-an", x === b);
});
standAendern({ wartetAuf: b.dataset.wartet });
});
});
var speichern = blatt.querySelector("#vw-p-speichern");
speichern.addEventListener("click", function () {
window.WD.knopfSperren(speichern, true, "…");
standAendern({
naechsterSchritt: blatt.querySelector("#vw-p-schritt").value,
richttermin: blatt.querySelector("#vw-p-termin").value,
}).then(function () {
window.WD.knopfSperren(speichern, false);
window.WD.meldung(meldung, "Gespeichert — der Kunde sieht es sofort.", "ok");
});
});
[["#vw-p-anz", "anzahlungBezahlt"], ["#vw-p-rest", "restBezahlt"],
["#vw-p-portfolio", "portfolioFreigabe"]].forEach(function (paar2) {
var el = blatt.querySelector(paar2[0]);
el.addEventListener("change", function () {
var felder = {};
felder[paar2[1]] = el.checked;
standAendern(felder);
});
});
/* ---- Änderungswunsch beziffern ---- */
blatt.querySelectorAll("[data-beziffern]").forEach(function (b) {
b.addEventListener("click", async function () {
var kasten = b.closest("[data-ae]");
var preis = kasten.querySelector(".vw-ae-preis").value;
if (!preis || Number(preis) < 0) {
window.WD.meldung(meldung,
"Bitte einen Preis angeben — ohne Preis kann der Kunde nicht entscheiden.", "fehler");
kasten.querySelector(".vw-ae-preis").focus();
return;
}
window.WD.knopfSperren(b, true, "…");
try {
await api("/webdesign/admin/aenderungen/" + encodeURIComponent(b.dataset.beziffern) + "/beziffern",
{ method: "POST", body: {
preisEuro: Number(preis),
dauer: kasten.querySelector(".vw-ae-dauer").value,
antwort: kasten.querySelector(".vw-ae-antwort").value,
} });
window.WD.meldung(meldung, "Angebot ist beim Kunden. Er entscheidet in seinem Portal.", "ok");
oeffneAufgaben(offenesProjekt);
} catch (f2) {
window.WD.knopfSperren(b, false);
window.WD.meldung(meldung, f2.message, "fehler");
}
});
});
/* ---- Nachricht zum Projekt ---- */
var blasen = blatt.querySelector("#vw-p-blasen");
if (blasen) blasen.scrollTop = blasen.scrollHeight;
var pSenden = blatt.querySelector("#vw-p-senden");
pSenden.addEventListener("click", async function () {
var feld = blatt.querySelector("#vw-p-nachricht");
var inhalt = feld.value.trim();
if (!inhalt) { feld.focus(); return; }
window.WD.knopfSperren(pSenden, true, "…");
try {
await api("/webdesign/admin/projekt-nachricht",
{ method: "POST", body: { projektId: offenesProjekt, text: inhalt } });
oeffneAufgaben(offenesProjekt);
} catch (f2) {
window.WD.knopfSperren(pSenden, false);
window.WD.meldung(meldung, f2.message, "fehler");
}
});
var vorlage = blatt.querySelector("#vw-vorlage");
if (vorlage) {
vorlage.addEventListener("click", async function () {