diff --git a/server/pruef-bewerbung-aufgaben.mjs b/server/pruef-bewerbung-aufgaben.mjs
index 2d867fef..6ec29b99 100644
--- a/server/pruef-bewerbung-aufgaben.mjs
+++ b/server/pruef-bewerbung-aufgaben.mjs
@@ -696,6 +696,175 @@ melde("=== 6. Am echten Bildschirm ===");
ok(!!b && b.knoepfe.some((t) => /fange an/i.test(t)),
`sondern „Ich fange an" (${b?.knoepfe.join(" | ")})`);
} finally { await m4.ctx.close(); }
+
+ /* ===============================================================
+ UND JETZT EINE ABSAGE -- VanVans vierte Meldung (30.09.2026)
+
+ „dann bekommt der Modi keine Benachrichtigung darüber, dass
+ die Aufgabe abgelehnt wurde und sieht somit auch eine
+ eventuelle Begründung nicht.
+ Außerdem haben auch wir nirgendwo eine Übersicht, bei wem wir
+ welche Aufgaben schon abgelehnt haben."
+
+ Bei einer ZUSAGE entsteht eine Aufgabe, und an ihr steht die
+ Antwort. Bei einer ABSAGE entsteht nichts -- die Begruendung
+ wird in `vorlagen_bewerbungen.entscheid_text` geschrieben und
+ danach von niemandem mehr ausgeliefert:
+ `vorlagenBewerbungenFuer` fragt `WHERE zustand = 'beworben'`.
+
+ Der Satz wird also verlangt, geschrieben und weggesperrt.
+ =============================================================== */
+ const v2 = katalogAufgaben.at(-2);
+ const vb2 = await roh("/workspace/api/vorlagen/bewerben", {
+ methode: "POST", keks: modi.keks,
+ body: { vorlage: v2.schluessel, text: "Die würde ich auch gern machen." } });
+ ok(vb2.code === 201 || vb2.code === 200,
+ `Frida bewirbt sich auf „${v2.titel}" (${vb2.code})`);
+
+ const offen3 = await roh("/workspace/api/vorlagen/bewerbungen", { keks: hand.keks });
+ const bw2 = (offen3.json?.bewerbungen || []).find((x) => x.vorlage === v2.schluessel);
+ const GRUND = "Diese Woche nicht — du hast schon drei offene.";
+ const vab = await roh("/workspace/api/vorlagen/bewerbung/ablehnen", {
+ methode: "POST", keks: hand.keks, body: { id: bw2?.id, kommentar: GRUND } });
+ ok(vab.code === 200, `die rechte Hand lehnt ab, mit Begruendung (${vab.code})`);
+
+ /* ---- 1. SIEHT FRIDA DIE ANTWORT? ------------------------- */
+ const beiFrida = await roh("/workspace/api/vorlagen", { keks: modi.keks });
+ /* `antworten` UND NICHT `bewerbungen`. Das alte Feld heisst im
+ Browser „wartet auf Antwort" -- beantwortete Zeilen darin
+ haetten bei einer Absage genau das angezeigt. */
+ const ihre = beiFrida.json?.antworten || [];
+ const abgelehnt = ihre.find((x) => x.vorlage === v2.schluessel);
+ ok(!!abgelehnt,
+ `Frida sieht ihre beantwortete Bewerbung (${ihre.length} insgesamt)`);
+ ok(abgelehnt?.zustand === "abgelehnt",
+ `und zwar als abgelehnt (${abgelehnt?.zustand})`);
+ ok(abgelehnt?.entscheid_text === GRUND,
+ `mit der Begruendung, die geschrieben wurde (${JSON.stringify(abgelehnt?.entscheid_text)})`);
+
+ /* ---- 2. SIEHT DIE LEITUNG, WAS SIE ABGELEHNT HAT? -------- */
+ const beiHand = await roh("/workspace/api/vorlagen", { keks: hand.keks });
+ const absagen = beiHand.json?.absagen;
+ const verlauf = absagen?.liste || [];
+ ok(verlauf.length >= 1,
+ `die Leitung sieht die Absagen der letzten ${absagen?.seit_tagen} Tage `
+ + `(${verlauf.length})`);
+ const eintrag = verlauf.find((x) => x.kennung === v2.schluessel);
+ ok(!!eintrag && eintrag.person_name === "Frida",
+ `mit Namen und Aufgabe (${eintrag?.person_name} / ${eintrag?.titel})`);
+ ok(eintrag?.entscheid_text === GRUND,
+ `und der eigenen Begruendung (${JSON.stringify(eintrag?.entscheid_text)})`);
+
+ /* VANVANS EIGENTLICHE FRAGE: „Falls sich jemand immer wieder auf
+ Aufgaben bewirbt die immer wieder abgelehnt werden, wäre das
+ sinnvoll." Eine Liste beantwortet das nicht, eine Zahl je
+ Mensch schon. */
+ const frida = (absagen?.je_mensch || []).find((x) => x.name === "Frida");
+ ok(!!frida && frida.anzahl >= 1,
+ `und wie oft es je Mensch war (Frida: ${frida?.anzahl})`);
+
+ /* BEIDE WEGE STEHEN DRIN. Weiter oben hat DogFather zwei
+ Bewerbungen auf AUFGABEN abgelehnt. Eine Uebersicht, die nur
+ die Vorlagen zeigt, waere eine Auskunft, die stimmt und
+ taeuscht. */
+ ok(verlauf.some((x) => x.quelle === "aufgabe")
+ && verlauf.some((x) => x.quelle === "vorlage"),
+ `aus beiden Wegen (${[...new Set(verlauf.map((x) => x.quelle))].join(", ")})`);
+
+ /* GEGENPROBE: Frida selbst bekommt die Uebersicht NICHT -- sie
+ entscheidet nicht, und eine Liste der Absagen an andere geht
+ sie nichts an. */
+ ok(beiFrida.json?.absagen === null,
+ `ein Modi bekommt die Uebersicht nicht (${JSON.stringify(beiFrida.json?.absagen)})`);
+
+ /* ---- UND AM ECHTEN BILDSCHIRM -----------------------------
+ „Der Server liefert es" waere ein gruener Haken ueber etwas,
+ das niemand sieht. Das Vorlagenbrett steht auf der
+ Aufgabenseite -- also aufmachen und nachlesen. */
+ const liesBrett = (seite) => seite.evaluate(() => {
+ const a = document.querySelector(".vorlagenbrett__antwort");
+ const u = document.querySelector(".vorlagenbrett__absagen");
+ return {
+ antwort: a ? (a.innerText || "").replace(/\s+/g, " ").trim().slice(0, 220) : null,
+ zeilen: a ? a.querySelectorAll(".vorlagenbrett__antwort-zeile").length : 0,
+ absagen: u ? (u.innerText || "").replace(/\s+/g, " ").trim().slice(0, 220) : null,
+ zugeklappt: u ? !u.open : null,
+ leute: u ? [...u.querySelectorAll(".vorlagenbrett__absagen-mensch")]
+ .map((x) => x.textContent.trim()) : [],
+ };
+ });
+
+ /* AUF „EURE AUFGABEN" UND NICHT AUF DEM AUFGABENBRETT. Dort
+ bewirbt man sich, dort steht der Vorlagenkatalog
+ (`zweig: 'team'` in entwicklung.js). Das Aufgabenbrett zeigt
+ den anderen Zweig (`creator`, den Aufbauplan) -- beim ersten
+ Anlauf habe ich dort gemessen und nichts gefunden. Ein
+ Messfehler, der wie ein Befund aussieht. */
+ const m5 = await seiteAls("modi", "CODE-MODI-0001", 390);
+ try {
+ await m5.s.goto(`${SEITE}/workspace/entwicklung.html`, { waitUntil: "networkidle" });
+ await m5.s.waitForTimeout(1800);
+ const w = await liesBrett(m5.s);
+ ok(w.zeilen >= 1, `Frida sieht die Antwort auf ihrem Brett (${w.zeilen} Zeilen)`);
+ /* OHNE RUECKSICHT AUF GROSS UND KLEIN. `innerText` liefert den
+ Text SO, WIE ER DASTEHT -- und das Schild traegt
+ `text-transform: uppercase`. Die Zeile suchte „Diesmal
+ nicht" und fand „DIESMAL NICHT": rot, obwohl alles stimmte.
+ `textContent` haette den Rohtext gegeben, aber `innerText`
+ ist hier richtig: Gemessen wird, was ein Mensch liest. */
+ ok(/diesmal nicht/i.test(w.antwort || ""),
+ `und dass es diesmal nichts wurde („${(w.antwort || "").slice(0, 60)}…")`);
+ ok(!!w.antwort && w.antwort.includes("schon drei offene"),
+ "mitsamt der Begruendung");
+ /* GEGENPROBE: Die Uebersicht der Absagen sieht sie NICHT. */
+ ok(w.absagen === null,
+ `und die Uebersicht der Absagen nicht (${JSON.stringify(w.absagen)})`);
+ /* UND EIN BILD DAVON -- am Handy, denn dort liest ein Modi. */
+ const kasten2 = await m5.s.$(".vorlagenbrett__antwort");
+ if (kasten2) {
+ await kasten2.screenshot({ path: "server/pruef-bewerbung-antwort.png" });
+ console.log(" -- Bild: server/pruef-bewerbung-antwort.png");
+ }
+ } finally { await m5.ctx.close(); }
+
+ const h2 = await seiteAls("hand", "CODE-HAND-0001", 1280);
+ try {
+ await h2.s.goto(`${SEITE}/workspace/entwicklung.html`, { waitUntil: "networkidle" });
+ await h2.s.waitForTimeout(1800);
+ /* DAS BRETT AUFKLAPPEN. Die Uebersicht ist ein
+ Nachschlagewerk und liegt im Koerper des Vorlagenbretts --
+ der wird zugeklappt gar nicht erst gebaut. Die ANTWORT auf
+ die eigene Bewerbung steht dagegen davor: Sie ist eine
+ Nachricht, und eine Nachricht, die man aufklappen muss,
+ ist keine. */
+ await h2.s.evaluate(() => {
+ const k = document.querySelector("#vorlagenbrett .vorlagenbrett__kopf");
+ if (k && k.getAttribute("aria-expanded") !== "true") k.click();
+ });
+ await h2.s.waitForTimeout(1200);
+ const w = await liesBrett(h2.s);
+ ok(!!w.absagen, `die rechte Hand sieht die Uebersicht („${(w.absagen || "").slice(0, 50)}…")`);
+ /* ZUGEKLAPPT. Eine Liste von Absagen, die immer offen steht,
+ ist eine Sammlung von Nein-Sagen ueber Menschen, mit denen
+ man morgen wieder arbeitet. */
+ ok(w.zugeklappt === true, `und zwar zugeklappt (${w.zugeklappt})`);
+ ok(w.leute.some((t) => /Frida/.test(t)),
+ `mit der Zahl je Mensch (${w.leute.join(" | ")})`);
+
+ /* EIN BILD VON DEM, WAS NEU IST. Zahlen sagen, dass etwas da
+ ist; ob es auch auszuhalten ist, sieht man nur. Aufgeklappt
+ -- zugeklappt waere ein Bild von einer Zeile. */
+ await h2.s.evaluate(() => {
+ const u = document.querySelector(".vorlagenbrett__absagen");
+ if (u) u.open = true;
+ });
+ await h2.s.waitForTimeout(300);
+ const kasten = await h2.s.$("#vorlagenbrett");
+ if (kasten) {
+ await kasten.screenshot({ path: "server/pruef-bewerbung-absagen.png" });
+ console.log(" -- Bild: server/pruef-bewerbung-absagen.png");
+ }
+ } finally { await h2.ctx.close(); }
}
/* =================================================================
diff --git a/server/workspace-vorlagen.js b/server/workspace-vorlagen.js
index 064c70bf..273f9836 100644
--- a/server/workspace-vorlagen.js
+++ b/server/workspace-vorlagen.js
@@ -48,6 +48,11 @@ import {
MODI_STUFEN,
entscheidetUeberAufgaben,
darfAufgabenVerteilen,
+ /* Fuer die Absagen-Uebersicht (30.09.2026): Wer den Menschen gar
+ nicht sehen darf, sieht auch nicht, dass ihm abgesagt wurde.
+ Ohne diese Zeile erfuehre die Agentur ueber eine Uebersicht,
+ dass es Team Dogi gibt. */
+ darfSchreibenMit,
KATALOG_EMPFAENGER,
KATALOG_EMPFAENGER_SQL,
} from "./workspace.js";
@@ -1468,6 +1473,18 @@ vorlagenRouter.get("/workspace/api/vorlagen", (req, res) => {
bekommt jeder, der die Seite oeffnet. */
bewerbungen: kategorienFuer(req.person).length
? vorlagenBewerbungenFuer(req.person) : null,
+ /* ==== UND WAS SCHON BEANTWORTET IST (30.09.2026) =============
+
+ ZWEI NEUE FELDER UND NICHT `bewerbungen` ERWEITERT. Dort heisst
+ eine Zeile im Browser „wartet auf Antwort" -- daraus wird ein
+ Schild und ein Zurueckziehen-Knopf. Beantwortete Zeilen
+ hineinzumischen hiesse, bei einer Absage „wartet auf Antwort"
+ zu zeigen. Ein Feld, dessen Bedeutung sich aendert, bricht
+ seine Leser lautlos. */
+ antworten: kategorienFuer(req.person).length
+ ? vorlagenAntwortenFuer(req.person) : null,
+ absagen: kategorienFuer(req.person).length
+ ? absagenUebersicht(req.person) : null,
darf_entscheiden: entscheidetUeberAufgaben(req.person),
darf_verteilen: darfAufgabenVerteilen(req.person),
});
@@ -1562,6 +1579,146 @@ function katalogAufgabeAnlegen(v, zielId, vonId, zustand = "offen") {
drueben bei den Aufgaben -- ein Satz, kein Aufsatz. */
const BEWERBUNG_TEXT_MAX = 600;
+/* =====================================================================
+ WAS NACH EINER ANTWORT UEBRIG BLEIBT (30.09.2026)
+
+ VanVan im Support: „dann bekommt der Modi keine Benachrichtigung
+ darüber, dass die Aufgabe abgelehnt wurde und sieht somit auch eine
+ eventuelle Begründung nicht. Außerdem haben auch wir nirgendwo eine
+ Übersicht, bei wem wir welche Aufgaben schon abgelehnt haben."
+
+ Gemessen, bevor gebaut wurde: Frida sah nach der Absage NULL
+ beantwortete Bewerbungen, die Leitung NULL Absagen.
+
+ Der Grund steht eine Funktion weiter unten: `WHERE zustand =
+ 'beworben'`. Sobald jemand antwortet, faellt die Zeile aus jeder
+ Ansicht heraus -- mitsamt der Begruendung, die man ausdruecklich
+ verlangt hat.
+
+ Bei einer ZUSAGE fiel das nicht auf: Dort entsteht eine Aufgabe,
+ und an ihrer Karte steht die Antwort. Bei einer ABSAGE entsteht
+ nichts.
+ ===================================================================== */
+
+/** Wie lange eine beantwortete Bewerbung beim Bewerber stehen bleibt.
+ * Zwei Wochen: lang genug, dass man sie sieht, kurz genug, dass das
+ * Brett nicht zur Ablage wird. */
+const ANTWORT_TAGE = 14;
+
+/** Wie weit die Absagen-Uebersicht zurueckreicht.
+ * Drei Monate -- VanVans Frage ist „bewirbt sich jemand immer wieder
+ * auf etwas, das wir immer wieder ablehnen?", und die beantwortet
+ * eine Woche nicht. */
+const ABSAGEN_TAGE = 90;
+
+const vorTagen = (n) => new Date(Date.now() - n * 86400_000).toISOString();
+
+/** Meine eigenen beantworteten Bewerbungen -- mit der Begruendung.
+ *
+ * NUR DIE EIGENEN, ausdruecklich. Wer entscheidet, sieht die Absagen
+ * in der Uebersicht darunter; hier geht es um die Antwort, auf die
+ * man selbst gewartet hat.
+ */
+function vorlagenAntwortenFuer(person) {
+ try {
+ return db().prepare(`
+ SELECT b.id, b.vorlage, b.zustand, b.text, b.entscheid_text,
+ b.entschieden_am, b.aufgabe_id, e.name AS von_name
+ FROM vorlagen_bewerbungen b
+ LEFT JOIN personen e ON e.id = b.entschieden_von
+ WHERE b.person_id = ?
+ AND b.zustand IN ('angenommen','abgelehnt')
+ AND COALESCE(b.entschieden_am, '') >= ?
+ ORDER BY b.entschieden_am DESC`)
+ .all(person.id, vorTagen(ANTWORT_TAGE))
+ .map((z) => ({ ...z, titel: vorlageZuSchluessel(z.vorlage)?.titel || z.vorlage }));
+ } catch (fehler) {
+ console.error("[vorlagen] Antworten:", fehler?.message);
+ return [];
+ }
+}
+
+/** Was die Leitung schon abgelehnt hat -- aus BEIDEN Wegen.
+ *
+ * AUS BEIDEN, und das ist der Punkt. Man kann sich auf eine Vorlage
+ * bewerben und auf eine Aufgabe, die es schon gibt. Eine Uebersicht,
+ * die nur die Haelfte zeigt, ist schlimmer als keine: „Frida wurde
+ * nie abgelehnt" waere dann eine Auskunft, die stimmt und taeuscht.
+ *
+ * `entschieden_von IS NOT NULL` TRENNT ZWEI DINGE, die beide
+ * `abgelehnt` heissen:
+ * · die Leitung hat eine Bewerbung abgelehnt <- das hier
+ * · die Person hat eine Zuteilung abgelehnt <- ihre Entscheidung
+ * · „Von jemand anderem uebernommen" <- gar keine Absage
+ * Die letzten beiden tragen keinen Entscheider und fallen heraus.
+ *
+ * UND DIE HAUSTRENNUNG GILT: `darfSchreibenMit` -- wer den Menschen
+ * gar nicht sehen darf, sieht auch nicht, dass ihm abgesagt wurde.
+ */
+function absagenUebersicht(person) {
+ if (!entscheidetUeberAufgaben(person)) return null;
+ try {
+ const seit = vorTagen(ABSAGEN_TAGE);
+ const zeilen = db().prepare(`
+ SELECT 'vorlage' AS quelle, b.person_id, p.name AS person_name,
+ b.vorlage AS kennung, NULL AS aufgabe_id,
+ b.text AS wort, b.entscheid_text, b.entschieden_am,
+ e.name AS von_name
+ FROM vorlagen_bewerbungen b
+ LEFT JOIN personen p ON p.id = b.person_id
+ LEFT JOIN personen e ON e.id = b.entschieden_von
+ WHERE b.zustand = 'abgelehnt'
+ AND b.entschieden_von IS NOT NULL
+ AND COALESCE(b.entschieden_am, '') >= ?
+ UNION ALL
+ SELECT 'aufgabe', z.person_id, p.name,
+ a.titel, a.id,
+ z.grund, z.entscheid_text, z.entschieden_am,
+ e.name
+ FROM aufgaben_zuteilung z
+ JOIN aufgaben a ON a.id = z.aufgabe_id
+ LEFT JOIN personen p ON p.id = z.person_id
+ LEFT JOIN personen e ON e.id = z.entschieden_von
+ WHERE z.zustand = 'abgelehnt'
+ AND z.entschieden_von IS NOT NULL
+ AND COALESCE(z.entschieden_am, '') >= ?
+ ORDER BY entschieden_am DESC`).all(seit, seit);
+
+ const sichtbar = zeilen
+ .filter((z) => z.person_id && darfSchreibenMit(person, z.person_id))
+ .map((z) => ({
+ ...z,
+ titel: z.quelle === "vorlage"
+ ? (vorlageZuSchluessel(z.kennung)?.titel || z.kennung)
+ : z.kennung,
+ }));
+
+ /* WIE OFT JE MENSCH -- das ist VanVans eigentliche Frage. Eine
+ Liste von zwanzig Zeilen beantwortet „bewirbt sich jemand immer
+ wieder auf etwas, das wir immer wieder ablehnen?" nicht; eine
+ Zahl je Person schon.
+
+ ABGELEITET AUS DERSELBEN LISTE und nicht zweitgezaehlt: Zwei
+ Rechnungen fuer dieselbe Frage laufen auseinander. */
+ const jeMensch = new Map();
+ for (const z of sichtbar) {
+ const t = jeMensch.get(z.person_id)
+ || { person_id: z.person_id, name: z.person_name, anzahl: 0, zuletzt: null };
+ t.anzahl += 1;
+ if (!t.zuletzt || String(z.entschieden_am) > t.zuletzt) t.zuletzt = z.entschieden_am;
+ jeMensch.set(z.person_id, t);
+ }
+ return {
+ seit_tagen: ABSAGEN_TAGE,
+ liste: sichtbar,
+ je_mensch: [...jeMensch.values()].sort((a, b) => b.anzahl - a.anzahl),
+ };
+ } catch (fehler) {
+ console.error("[vorlagen] Absagen:", fehler?.message);
+ return null;
+ }
+}
+
/** Die offenen Bewerbungen, so wie sie die Seite braucht.
*
* WER WAS SIEHT, ENTSCHEIDET HIER DER SERVER: Wer entscheidet, sieht
diff --git a/workspace/anruf-probe.html b/workspace/anruf-probe.html
index 60402299..7224f03c 100644
--- a/workspace/anruf-probe.html
+++ b/workspace/anruf-probe.html
@@ -16,8 +16,8 @@
verdeckt. Gefunden hat es pruef-struktur.
Richtig sind `gate.css` (die Farbwerte des Hauses) und `haus.css`. -->
-
-
+
+
-
+