Anfragen annehmen und ablehnen — mit laufender Zeit bis zur Übergabe

Annehmen war bisher nur ein Etikett: Man stellte den Status um, und es
passierte nichts. Kunde, Projekt, Aufgabenliste, Termin und Zugang musste
man danach von Hand in fünf Schritten nachbauen. Der alte Code gab das im
Kommentar selbst zu -- schlug der zweite von zwei Aufrufen fehl, stand der
Kunde schon in der Datenbank und man durfte es nicht noch einmal
versuchen.

Jetzt macht das EIN Aufruf, ganz oder gar nicht:
Kunde finden oder anlegen, Projekt anlegen, die komplette Aufgabenliste
des Pakets einsetzen, Liefertermin berechnen, Einladungslink erzeugen.
Der Kunde verfolgt ab diesem Moment alles in seinem Portal.

Die Zeit läuft wirklich:
- Eigenes Datumsfeld (termin_am) neben dem freien Text. Aus 'Mitte
  Oktober' kann man keine verbleibenden Tage rechnen.
- Werktage statt Kalendertage, inklusive luxemburgischer Feiertage. Die
  beweglichen werden über die Osterformel berechnet statt gepflegt --
  eine Liste ist im übernächsten Jahr lautlos falsch.
- Vorschlag je Paket (Onepager 10, Website 20, Shop 30 Werktage),
  überschreibbar vor dem Bestätigen.
- Verbleibende Zeit wird SERVERSEITIG gerechnet. Der Browser kennt die
  Feiertage nicht; zwei verschiedene Zahlen für denselben Termin wären
  schlimmer als gar keine.

Ablehnen mit Grund und vorbereiteter Absage in fünf Sprachen, Text
serverseitig erzeugt und vor dem Abschicken lesbar. Ein laufendes
Projekt lässt sich nicht nachträglich als Anfrage ablehnen.

Sechs Fehler dabei gefunden:
- Ein unbekanntes Paket hätte GAR KEINEN Termin bekommen statt des
  Ersatzwerts. Zwei Funktionen lasen dieselbe Tabelle, nur eine hatte
  einen Rückfallwert. Eine fehlende Zahl fällt nirgends auf.
- wd_anfragen hat keine Spalte 'firma' -- better-sqlite3 weist undefined
  ab, die ganze Annahme wäre gescheitert.
- Migrationen stehen in einer ausdrücklichen Liste; 0015 fehlte darin.
- datumKurz() wurde aufgerufen, gab es aber nicht. Der Fehler wäre erst
  NACH dem Anlegen aufgetreten.
- Der Installations-Hinweis lag fest über dem Annehmen-Knopf und machte
  ihn auf dem Handy untreffbar. Die Seite reserviert jetzt Platz dafür.
- 'richttermin' ist freier Text, lief aber durch einen Datumsformatierer:
  Der Kunde las 'Invalid Date' an der wichtigsten Stelle seines Projekts.

Geprüft: 49 Prüfungen der Terminrechnung gegen nachschlagbare Osterdaten
und Wochentage, 70 gegen eine echte Datenbank (darunter: zweiter Klick
legt nichts doppelt an, Abbruch mittendrin lässt NICHTS zurück, gesperrter
Kunde wird nicht still entsperrt), 42 im Browser auf Computer und Handy,
40 im Portal. Alle bestehenden Prüfungen weiter grün.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-08-23 22:44:08 +02:00
co-authored by Claude Opus 5
parent 37f1b4a8f5
commit 131bc8a755
18 changed files with 1818 additions and 89 deletions
+44 -20
View File
@@ -348,31 +348,31 @@ export async function adminAufgabeLoeschen(req, res) {
Funktion angelegt wurden, oder wenn eine Liste versehentlich geleert
wurde. Fügt HINZU statt zu ersetzen: Vorhandene Punkte könnten schon
abgehakt sein, und ein Ersetzen würde diesen Fortschritt löschen. */
export async function adminVorlageAnwenden(req, res) {
const p = await darf(req, "WD_PROJEKTE_MANAGE");
if (!p.erlaubt) return json(res, { ok: false, error: "Kein Zugriff." }, p.grund);
const projekt = db
.prepare(`SELECT id, kunde_id, paket FROM wd_projekte WHERE id = ?`)
.get(String(req.params.id || ""));
if (!projekt) return json(res, { ok: false, error: "Projekt nicht gefunden." }, 404);
const paket = text(req.body?.paket, 40) || projekt.paket;
/**
* Wendet die Aufgabenvorlage eines Pakets auf ein Projekt an.
*
* Herausgeloest aus dem Endpunkt, weil die Annahme einer Anfrage
* dasselbe braucht. Kopiert man den Ablauf statt ihn zu teilen, laufen
* beide Fassungen mit der Zeit auseinander -- und dann bekommen von
* Hand angelegte Projekte eine andere Liste als angenommene, ohne dass
* es jemand merkt.
*
* @returns {{neu:number, uebersprungen:number, gesamt:number}}
*/
export function vorlageAnwenden(projektId, kundeId, paket) {
const vorlage = db
.prepare(
`SELECT titel, beschreibung, kategorie, wer_dran, reihenfolge
FROM wd_aufgaben_vorlagen WHERE paket = ? AND aktiv = 1 ORDER BY reihenfolge`
)
.all(paket);
if (!vorlage.length) {
return json(res, { ok: false, error: "Für dieses Paket gibt es keine Vorlage." }, 404);
}
if (!vorlage.length) return { neu: 0, uebersprungen: 0, gesamt: 0 };
/* Titel, die schon in der Liste stehen, werden übersprungen. Sonst
erzeugt ein zweiter Klick eine doppelte Liste — und das merkt man
/* Titel, die schon in der Liste stehen, werden uebersprungen. Sonst
erzeugt ein zweiter Klick eine doppelte Liste -- und das merkt man
erst, wenn der Kunde sie sieht. */
const vorhanden = new Set(
db.prepare(`SELECT titel FROM wd_aufgaben WHERE projekt_id = ?`).all(projekt.id)
db.prepare(`SELECT titel FROM wd_aufgaben WHERE projekt_id = ?`).all(projektId)
.map((z) => z.titel.toLowerCase())
);
@@ -386,17 +386,41 @@ export async function adminVorlageAnwenden(req, res) {
db.transaction(() => {
for (const v of vorlage) {
if (vorhanden.has(v.titel.toLowerCase())) continue;
einfuegen.run(neueId(), projekt.id, projekt.kunde_id, v.titel, v.beschreibung,
einfuegen.run(neueId(), projektId, kundeId, v.titel, v.beschreibung,
v.kategorie, v.wer_dran, v.reihenfolge, jetzt());
neu++;
}
})();
return { neu, uebersprungen: vorlage.length - neu, gesamt: vorlage.length };
}
verlauf("projekt", projekt.id, "vorlage_angewendet", werIst(p), `${paket}: ${neu} Punkte`);
export async function adminVorlageAnwenden(req, res) {
const p = await darf(req, "WD_PROJEKTE_MANAGE");
if (!p.erlaubt) return json(res, { ok: false, error: "Kein Zugriff." }, p.grund);
const projekt = db
.prepare(`SELECT id, kunde_id, paket FROM wd_projekte WHERE id = ?`)
.get(String(req.params.id || ""));
if (!projekt) return json(res, { ok: false, error: "Projekt nicht gefunden." }, 404);
const paket = text(req.body?.paket, 40) || projekt.paket;
const vorhandeneVorlage = db
.prepare(
`SELECT COUNT(*) AS n
FROM wd_aufgaben_vorlagen WHERE paket = ? AND aktiv = 1`
)
.get(paket);
if (!vorhandeneVorlage || !vorhandeneVorlage.n) {
return json(res, { ok: false, error: "Für dieses Paket gibt es keine Vorlage." }, 404);
}
const ergebnis = vorlageAnwenden(projekt.id, projekt.kunde_id, paket);
verlauf("projekt", projekt.id, "vorlage_angewendet", werIst(p), `${paket}: ${ergebnis.neu} Punkte`);
return json(res, {
ok: true,
neu,
uebersprungen: vorlage.length - neu,
neu: ergebnis.neu,
uebersprungen: ergebnis.uebersprungen,
fortschritt: fortschritt(projekt.id),
});
}