Talente: der Trichter bekommt einen Ausgang

Gemessen vor dem Bau: talentNaechste kettet aufgefallen -> beobachtet ->
angesprochen -> probe -> imteam, und danach kommt nichts. Eine Suche nach
"beenden|abgesagt|verworfen|archiv" in workspace-entwicklung.js fand
nichts. Der Trichter hatte genau einen Ausgang -- obwohl der Text der
Probe-Stufe woertlich verspricht: "Nach 30 Tagen: uebernehmen -- der
Zugang entsteht dabei -- oder sauber beenden."

Wer nicht passte, blieb also auf seiner Stufe stehen, mit wachsender
Standzeit und rotem "liegt"-Zeichen. Nach einem Jahr hat die Liste mehr
Karteileichen als Kandidaten, und dann schaut niemand mehr hin.

DREI SPALTEN, KEINE NEUE STUFE. Eine neue Stufe haette einen
CHECK-Umbau auf laufenden Daten verlangt -- und eine Auskunft zerstoert:
Die Stufe haelt fest, WO es geendet hat. "Beendet in der Probe" und
"beendet nach dem ersten Hinsehen" sind zwei verschiedene Saetze, und
genau der Unterschied ist die Frage, wenn dieselbe Person in einem
halben Jahr wieder auffaellt.

DER SATZ IST PFLICHT (10 Zeichen, wie beim Ablehnen einer Anfrage) --
aus zwei Richtungen: Nach vorn, weil beim zweiten Anlauf niemand mehr
weiss, was damals war. Nach innen, weil wer schreiben muss, anders
entscheidet als wer klickt. "Passt nicht" hat neun Zeichen.

WIEDER AUFNEHMEN setzt auf "aufgefallen" zurueck -- Menschen aendern
sich, ein Urteil von damals gilt nicht weiter -- und rettet den alten
Satz in die Notiz, statt ihn zu loeschen.

AM BILDSCHIRMFOTO GEFUNDEN, nicht an einer Zahl: Auf einer Karte auf
"Im Team" stand "Nichts mehr zu tun -- der Zugang steht" und direkt
darunter "Passt doch nicht?". Ein Beenden dort haette die Karte
stillgelegt und den ZUGANG offengelassen -- eine halbe Handlung, die
sich wie eine ganze anfuehlt. Jetzt steht dort der Weg zu "Personen &
Zugaenge", und der Server weist es mit 409 ab.

pruef-nachwuchs 152 -> 200, davon 21 im echten Browser (der erste
Browserblick auf diese Seite ueberhaupt -- mit einer Messung, ob Name
und Datum bei 390 px uebereinanderliegen, statt einer Zahl im CSS).
Sechs Gegenproben, jede genau auf ihr Ziel:
  Grenze auf 0          -> 3 rot
  Beendete bleiben drin -> 2 rot
  beendete: []          -> 5 rot
  alter Grund verworfen -> 1 rot
  Serverriegel imteam   -> 5 rot
  Browserzweig imteam   -> 3 rot
Dazu gruen: css-klassen, struktur, entwicklung, uebergang.

Nebenbefund behoben: absage() im Browser warf einen erklaerenden Satz
des Servers lautlos weg und zeigte "Ging nicht." -- die Auskunft war da.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-09-17 18:39:06 +02:00
co-authored by Claude Opus 5
parent 3a49e30aa4
commit e54c6829f1
37 changed files with 1269 additions and 406 deletions
+205 -3
View File
@@ -85,6 +85,13 @@ const jetzt = () => new Date().toISOString();
const TAG = 86400000;
const ANLASS_MAX = 1200;
/* WIE KURZ EIN ABSCHIEDSSATZ SEIN DARF.
Zehn Zeichen -- dieselbe Grenze wie bei einer abgelehnten Anfrage
(NACHRICHT_MIN, workspace-bewerbung-fragen.js), und aus demselben
Grund: Sie verhindert nicht das schlechte Argument, sie verhindert
das leere Feld. "Passt nicht" hat neun Zeichen. */
const BEENDEN_GRUND_MIN = 10;
/* WANN EINE EINSCHAETZUNG VERBLASST.
Nach 90 Tagen zaehlt ein Stand nicht mehr als aktuell. Er wird NICHT
geloescht -- man soll sehen, dass etwas alt ist, statt dass es
@@ -230,6 +237,31 @@ export function tabellen() {
for (const [spalte, typ] of [
["buddy_id", "INTEGER REFERENCES personen(id) ON DELETE SET NULL"],
["erste_schicht", "TEXT"],
/* DER AUSGANG (17.09.2026).
Der Trichter hatte bis heute genau EINEN: "Ins Team holen". Die
Stufenbeschreibung versprach daneben "oder sauber beenden" --
den Weg dazu gab es nicht. Ein Kandidat, bei dem es nicht passt,
blieb damit fuer immer auf seiner Stufe stehen, mit wachsender
Standzeit und rotem Hinweis. Das ist schlimmer als keine
Funktion: Die Liste fuellt sich mit Leuten, bei denen laengst
entschieden ist, und irgendwann liest niemand mehr die
Standzeiten.
ZWEI SPALTEN UND KEINE NEUE STUFE, und das ist Absicht:
Eine Stufe "beendet" haette die Regel in talent_stufe umbauen
muessen (CHECK) -- ein Tabellenneubau an lebenden Daten fuer
etwas, das sich mit zwei Spalten sagen laesst.
Und sie haette eine Auskunft VERNICHTET: So bleibt stehen, WO
es geendet hat. "Beendet in der Probe" und "beendet nach dem
ersten Hinsehen" sind zwei verschiedene Saetze ueber dieselbe
Person -- und wenn sie in einem halben Jahr wieder auffaellt,
ist genau das die Frage. */
["beendet_am", "TEXT"],
["beendet_grund", "TEXT"],
["beendet_von", "INTEGER"],
]) {
try {
const da = db().prepare("PRAGMA table_info(talent_stufe)").all().map((s) => s.name);
@@ -890,7 +922,11 @@ function uebergangVon(stand) {
/** Die Karte eines Kandidaten -- Stufe, Standzeit, was zu tun ist. */
function talentKarte(e, merkmale) {
const stand = db().prepare(
"SELECT stufe, seit, notiz, buddy_id, erste_schicht FROM talent_stufe WHERE eintrag_id = ?")
`SELECT s.stufe, s.seit, s.notiz, s.buddy_id, s.erste_schicht,
s.beendet_am, s.beendet_grund, p.name AS beendet_von_name
FROM talent_stufe s
LEFT JOIN personen p ON p.id = s.beendet_von
WHERE s.eintrag_id = ?`)
.get(e.id);
const stufe = talentStufe(stand?.stufe) || TALENT_STUFEN[0];
const seit = stand?.seit || e.erstellt;
@@ -906,7 +942,11 @@ function talentKarte(e, merkmale) {
/* LIEGENGEBLIEBEN IST EINE AUSKUNFT UEBER UNS, nicht ueber den
Kandidaten. Deshalb steht sie an der Karte und nicht in einer
Erinnerungsmail, die man wegklickt. */
liegt: stufe.frist !== null && tage !== null && tage > stufe.frist,
/* Ein beendeter Kandidat liegt nicht -- er ist fertig. Ohne diese
Ausnahme wuerde die Standzeit weiterlaufen und die Karte nach
einer Woche rot werden, obwohl niemand mehr etwas zu tun hat. */
liegt: !stand?.beendet_am && stufe.frist !== null
&& tage !== null && tage > stufe.frist,
merkmale: gesetzt.filter((s) => !TALENT_NACH_SCHLUESSEL.get(s)?.warnung),
warnzeichen: warn,
notiz: stand?.notiz || null,
@@ -935,6 +975,23 @@ function talentKarte(e, merkmale) {
Abruf -- sonst holt man sie erst, wenn man schon entschieden
hat. */
probe: probeVon(e.id, stufe.schluessel),
/* DER AUSGANG (17.09.2026).
Die Stufe bleibt stehen -- dass jemand raus ist, steht daneben,
nicht anstelle. "Beendet in der Probe" und "beendet nach dem
ersten Hinsehen" sind zwei verschiedene Auskuenfte, und bei
einem zweiten Anlauf in einem halben Jahr ist genau der
Unterschied die Frage.
DER GRUND WIRD MITGELIEFERT, NICHT NUR DAS DATUM. Ein Ausgang
ohne Satz waere dieselbe Sackgasse, gegen die der Satz beim
Ablehnen einer Anfrage verlangt wird. */
beendet: stand?.beendet_am ? {
am: stand.beendet_am,
grund: stand.beendet_grund || null,
von: stand.beendet_von_name || null,
tage: tageSeit(stand.beendet_am),
} : null,
};
}
@@ -983,7 +1040,22 @@ entwicklungRouter.get("/workspace/api/talente/lage", (req, res) => {
.all(...eintraege.map((e) => e.id))
: [];
const karten = eintraege.map((e) => talentKarte(e, merkmale));
/* BEENDETE KOMMEN AUS DER ARBEITSLISTE HERAUS -- aber nicht aus
dem Haus (17.09.2026).
Wer einmal abgesagt hat, steht sonst fuer immer im Trichter,
mit wachsender Standzeit und rotem "liegt"-Zeichen. Nach einem
halben Jahr sind das mehr als die echten Kandidaten, und dann
schaut niemand mehr hin -- eine Liste, in der 20 von 25 Eintraege
erledigt sind, ist keine Liste mehr.
SIE WERDEN TROTZDEM MITGELIEFERT, in einem eigenen Feld. Das
Loeschen waere die bequeme Variante und die, nach der in sechs
Monaten niemand mehr weiss, dass es schon einmal einen Anlauf
gab. */
const alle = eintraege.map((e) => talentKarte(e, merkmale));
const karten = alle.filter((k) => !k.beendet);
const beendete = alle.filter((k) => k.beendet);
res.json({
stufen: TALENT_STUFEN,
gruppen: TALENT_GRUPPEN.map((g) => ({ ...g, merkmale: TALENT_MERKMALE[g.schluessel] || [] })),
@@ -1002,6 +1074,9 @@ entwicklungRouter.get("/workspace/api/talente/lage", (req, res) => {
dazu steht; siehe ZUGANG_ROLLE oben. */
zugang: { rolle: ZUGANG_ROLLE, name: ROLLEN_NAME[ZUGANG_ROLLE] ?? ZUGANG_ROLLE },
kandidaten: karten,
/* Nach Ausgangsdatum, das juengste zuerst -- wer gerade abgesagt
hat, ist der, an den man sich erinnert. */
beendete: beendete.sort((x, y) => (y.beendet.am > x.beendet.am ? 1 : -1)),
/* Wie viele auf jeder Stufe liegen -- der Trichter als Zahlen.
Abgeleitet, damit er nicht danebenliegen kann. */
trichter: TALENT_STUFEN.map((s) => ({
@@ -1098,6 +1173,133 @@ entwicklungRouter.put("/workspace/api/talente/:id/probe/:schluessel", gleicheHer
}
});
/* =====================================================================
DEN TRICHTER SAUBER VERLASSEN (17.09.2026)
Filipe: „wie talente bewertet werden, aufgaben bekommen, analysiert
werden ... alles moegliche, wie gesagt ich will hoch profissionel
arbeiten."
EIN GRUND IST PFLICHT, und zwar aus zwei Richtungen:
Nach vorn -- wenn dieselbe Person in einem halben Jahr wieder
auffaellt, ist die erste Frage: Was war damals? Ohne Satz
beantwortet sie niemand, und man faengt bei null an.
Nach innen -- wer einen Satz schreiben muss, entscheidet anders
als wer einen Knopf drueckt. "Passt nicht" laesst sich nicht
aufschreiben, ohne dass auffaellt, dass es keine Begruendung ist.
DER EINTRAG BLEIBT. Er wird nicht geloescht, sondern bekommt ein
Datum -- und verschwindet damit aus der Arbeitsliste, nicht aus dem
Gedaechtnis. Loeschen waere die bequeme Variante und die, nach der
in sechs Monaten niemand mehr weiss, dass es schon einmal einen
Anlauf gab.
===================================================================== */
entwicklungRouter.put("/workspace/api/talente/:id/beenden", gleicheHerkunft,
express.json({ limit: "8kb" }), (req, res) => {
try {
const id = nummer(req.params.id);
const e = id && db().prepare(
"SELECT id, bereich FROM eintraege WHERE id = ?").get(id);
if (!e || e.bereich !== "talente") return res.status(404).json({ fehler: "nicht_gefunden" });
const grund = text(req.body?.grund, 600);
if (grund.length < BEENDEN_GRUND_MIN) {
return res.status(400).json({
fehler: "Schreib kurz dazu, woran es lag – in einem halben Jahr "
+ "weiß es sonst niemand mehr.",
});
}
/* WER IM TEAM IST, WIRD NICHT HIER BEENDET (17.09.2026).
Gefunden am Bildschirmfoto, nicht an einer Zahl: Auf einer
Karte auf "Im Team" stand woertlich "Nichts mehr zu tun -- der
Zugang steht" und direkt darunter "Passt doch nicht?".
Ein Beenden haette dort die KARTE stillgelegt und den ZUGANG
bestehen lassen -- die Person koennte sich weiter anmelden,
waehrend im Trichter steht, sie sei raus. Das ist schlimmer
als gar kein Ausgang: Man glaubt, gehandelt zu haben.
Der Riegel steht hier und nicht nur im Browser. Was der Browser
prueft, ist Hoeflichkeit; was der Server prueft, ist die Regel. */
const wo = db().prepare("SELECT stufe FROM talent_stufe WHERE eintrag_id = ?").get(id);
if (wo?.stufe === "imteam") {
return res.status(409).json({
fehler: "Der ist schon im Team. Wer aufhört, wird unter "
+ "„Personen & Zugänge“ abgeschaltet – sonst bleibt der "
+ "Zugang offen, während hier steht, er sei raus.",
});
}
const jetztZeit = jetzt();
/* Die STUFE bleibt stehen. Wo es geendet hat, ist die Auskunft --
"beendet in der Probe" und "beendet nach dem ersten Hinsehen"
sind zwei verschiedene Saetze. */
const da = db().prepare("SELECT eintrag_id FROM talent_stufe WHERE eintrag_id = ?").get(id);
if (da) {
db().prepare(`UPDATE talent_stufe
SET beendet_am = ?, beendet_grund = ?, beendet_von = ?
WHERE eintrag_id = ?`).run(jetztZeit, grund, req.person.id, id);
} else {
db().prepare(`INSERT INTO talent_stufe
(eintrag_id, stufe, seit, von_id, beendet_am, beendet_grund, beendet_von)
VALUES (?, 'aufgefallen', ?, ?, ?, ?, ?)`)
.run(id, jetztZeit, req.person.id, jetztZeit, grund, req.person.id);
}
protokolliere("talent_beendet", {
personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req),
detail: `#${id}`,
});
res.json({ ok: true, beendet: true });
} catch (f) {
console.error("[talente] Beenden:", f?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
/* WIEDER AUFNEHMEN -- und der alte Grund bleibt stehen.
Menschen aendern sich; ein "damals zu wenig Zeit" ist in einem Jahr
keine Aussage mehr. Wer wieder aufgenommen wird, startet deshalb
wieder bei "aufgefallen" -- aber der Satz von damals wandert in die
Notiz, statt geloescht zu werden. Genau er ist beim zweiten Anlauf
das Wertvollste, was man hat. */
entwicklungRouter.put("/workspace/api/talente/:id/wieder", gleicheHerkunft,
(req, res) => {
try {
const id = nummer(req.params.id);
const stand = id && db().prepare(
`SELECT s.eintrag_id, s.beendet_am, s.beendet_grund, s.notiz
FROM talent_stufe s JOIN eintraege e ON e.id = s.eintrag_id
WHERE s.eintrag_id = ? AND e.bereich = 'talente'`).get(id);
if (!stand) return res.status(404).json({ fehler: "nicht_gefunden" });
if (!stand.beendet_am) {
return res.status(409).json({ fehler: "Der läuft doch noch." });
}
const alt = stand.beendet_grund
? `Beim ersten Anlauf beendet: ${stand.beendet_grund}` : null;
const notiz = [stand.notiz, alt].filter(Boolean).join("\n\n").slice(0, 2000) || null;
db().prepare(`UPDATE talent_stufe
SET stufe = 'aufgefallen', seit = ?, von_id = ?, notiz = ?,
beendet_am = NULL, beendet_grund = NULL, beendet_von = NULL,
buddy_id = NULL, erste_schicht = NULL
WHERE eintrag_id = ?`)
.run(jetzt(), req.person.id, notiz, id);
/* Die Probe faengt neu an -- alte Haken wuerden sonst eine
Probezeit beschreiben, die vier Wochen zurueckliegt. */
db().prepare("DELETE FROM talent_probe WHERE eintrag_id = ?").run(id);
protokolliere("talent_wieder", {
personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req),
detail: `#${id}`,
});
res.json({ ok: true });
} catch (f) {
console.error("[talente] Wieder:", f?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
/** Buddy und Datum prüfen. Gibt `{ fehlt: [...] }` oder `{ buddyId, schicht }`.
*
* EINE STELLE, ZWEI AUFRUFER: der Stufenwechsel auf „Probe" und das