Filipe: "ich will dass diese zwei kategorien perfektionniert werden, ich
will dass fertige aufgaben da stehen und so ... so dass dogfather und
rechte hand es so einfach wie möglich haben aber so professionell wie
auch nur möglich."
Bis heute waren beide Kacheln ein leeres Formular: Titel tippen, Art
wählen, Text schreiben. Ein leeres Feld beantwortet keine Frage — es
stellt sie noch einmal.
ENTWICKLUNG: 34 Beobachtungen in fünf Blöcken, vier Antworten je Zeile
(Läuft · Wächst gerade · Da hakt es · Kann ich nicht sagen). Es sind
BEOBACHTUNGEN, keine Eigenschaften — "Absagen kommen rechtzeitig" statt
"ist zuverlässig". Eine Eigenschaft kann man nur bestätigen oder
bestreiten; ein Verhalten kann man gesehen haben oder nicht.
TALENTE: 21 Merkmale in fünf Gruppen, 6 Warnzeichen getrennt geführt,
fünf Trichterstufen mit je GENAU EINEM nächsten Schritt. Eine Liste
wächst und wird nie wieder angesehen; ein Trichter fragt bei jeder Karte
"und jetzt?". Die Standzeit steht dabei — "seit 41 Tagen beobachtet"
ist eine Auskunft über uns, nicht über den Kandidaten.
VIER RIEGEL, JEDER MIT GEGENPROBE:
· Block 5 ("Wie geht es dir damit?") gehört der Person — lesend UND
schreibend, auch vor DogFather. Er kommt gar nicht erst aus dem
Server; Schreiben gibt dasselbe 404 wie ein erfundener Schlüssel.
Nach außen dringt eine Ampel OHNE Namen, und die schweigt, solange
das Team so klein ist, dass "jemandem geht es zu viel" dasselbe wäre
wie ein Name.
· Der Stand des anderen kommt erst nach dem eigenen Klick — sonst
klickt man dasselbe. Sichtbar ist nur, DASS es einen gibt: sonst
sähen "noch niemand" und "du darfst es nicht sehen" gleich aus.
· "Da hakt es" ohne Anlass wird abgelehnt. "Läuft" nicht — 28
Pflichtfelder je Person wären das Gegenteil von "so einfach wie
möglich".
· Talente gehört DogFather und der rechten Hand. Ein Modi bekommt 404
auf Seite UND Schnittstelle.
WAS NICHT GEBAUT IST, UND WARUM: keine Punktzahl, kein Durchschnitt,
keine Rangliste, keine Gesamtnote. Eine Zahl über einem Menschen wird
gelesen, verglichen und weitererzählt. Und kein Rot — "da hakt es" ist
eine Beobachtung, kein Alarm.
DER PLAN LAG AN EINER STELLE FALSCH, gemessen statt vermutet: Er sagte
"keine neue Tabelle, der Stand liegt in punkt_stand". Dort ist der
Schlüssel (bereich, schluessel, creator_id) — genau EIN Stand je Punkt
und Person. Zwei unabhängige Augenpaare passen da nicht hinein, ohne
eine lebende Tabelle neu zu bauen. Also eine eigene.
EIN RÜCKSCHLAG, DEN NUR DIE PRÜFUNG GEFUNDEN HAT: Die Liste der
Bretter, die ein Modi oder die rechte Hand öffnen darf, wird aus den
KACHELZIELEN abgeleitet. Als die Kacheln auf die neuen Seiten zeigten,
fielen beide Bretter heraus — und die Seite verlinkte auf ein 404. Kein
Fehler im Riegel, sondern in seiner Quelle.
Dazu zwei kleinere Funde vom Hinsehen statt vom Messen: Die Ampel sagte
"Solange ihr zu 1 seid" (jetzt drei Sätze für drei Lagen), und die
Fußzeile der Talente-Seite lag auf der Bühne — die Kontrastprüfung hatte
sie übersprungen, weil ein Link darin steht. Beide behoben, und die
Prüfung hat den stillen Aussetzer gleich mit verloren.
Geprüft: rollen 314 · start-ansicht 147 · crew-adresse 132 · neue-seiten
70 · nachwuchs 69 · entwicklung 40 · css-klassen 30 · zwischenspeicher
21 · alle-wege 19 · rechtetafel 19 — alles grün.
Was noch fehlt, steht in der Notiz, Abschnitt 9: die eigene Karte für
die Person, der Übergang "Probe" → Zugang, der Vorlagentext zum
Ansprechen.
Co-Authored-By: Claude Opus 5 <[email protected]>
638 lines
27 KiB
JavaScript
638 lines
27 KiB
JavaScript
/* =====================================================================
|
||
Entwicklung & Nachwuchs — die Mechanik (11.09.2026)
|
||
=====================================================================
|
||
|
||
Filipe: *„ich will dass diese zwei kategorien perfektionniert werden,
|
||
ich will dass fertige aufgaben da stehen und so … so dass dogfather
|
||
und rechte hand es so einfach wie möglich haben aber so professionell
|
||
wie auch nur möglich."*
|
||
|
||
Die Kataloge stehen in workspace-entwicklungs-punkte.js und
|
||
workspace-talent-punkte.js. Hier steht, was mit ihnen passiert.
|
||
|
||
---------------------------------------------------------------------
|
||
EINE EIGENE TABELLE — UND WARUM DER PLAN DARIN FALSCH LAG
|
||
|
||
Der Plan im Vault sagt: „zwei Spalten, keine neue Tabelle" — der
|
||
Stand solle in `punkt_stand` liegen, derselben Tabelle wie die
|
||
Checkliste. Nachgemessen geht das NICHT:
|
||
|
||
PRIMARY KEY (bereich, schluessel, creator_id)
|
||
|
||
Es passt genau EIN Stand je Punkt und Person hinein. Der Kern dieses
|
||
Bereichs ist aber, dass DogFather und die rechte Hand UNABHAENGIG
|
||
voneinander setzen und die Abweichung das eigentliche Ergebnis ist.
|
||
Dafuer muesste der Schluessel um den Beurteiler wachsen — also ein
|
||
Tabellenneubau an lebenden Checklisten-Daten, fuer einen Zweck, der
|
||
mit der Checkliste nichts zu tun hat.
|
||
|
||
Deshalb eine eigene Tabelle. Der Plan war ein Wegweiser, keine
|
||
Wahrheit; gemessen wurde vor dem Bauen, nicht danach.
|
||
|
||
---------------------------------------------------------------------
|
||
DREI RIEGEL, DIE VON ANFANG AN STEHEN
|
||
|
||
1. BLOCK 5 GEHOERT DER PERSON. „Wie geht es dir damit?" beantwortet
|
||
nur sie selbst — lesend UND schreibend. Auch DogFather nicht. Was
|
||
nach aussen dringt, ist eine Ampel ohne Namen.
|
||
|
||
2. DER STAND DES ANDEREN KOMMT ERST NACH DEM EIGENEN. Wer zuerst
|
||
sieht, was der andere angeklickt hat, klickt dasselbe — das ist
|
||
kein Verdacht, sondern ein gut belegter Effekt. Er wird hier nicht
|
||
durch Disziplin verhindert, sondern dadurch, dass die Auskunft
|
||
erst gar nicht ausgeliefert wird.
|
||
|
||
3. TALENTE ENTHAELT NAMEN VON MENSCHEN, DIE NICHTS DAVON WISSEN.
|
||
Der Bereich gehoert DogFather und der rechten Hand. Kein Modi,
|
||
keine Agentur, und ganz sicher nicht die Community.
|
||
|
||
---------------------------------------------------------------------
|
||
UND EINE ABWEICHUNG VOM PLAN, DIE FILIPES SATZ GESCHULDET IST
|
||
|
||
Der Plan verlangt einen Anlass (die drei SBI-Felder) bei JEDEM Klick
|
||
ausser „kann ich nicht sagen". Bei 28 Punkten je Person waeren das 28
|
||
Textfelder — und „so einfach wie moeglich" war die andere Haelfte des
|
||
Auftrags. Pflicht ist der Anlass deshalb dort, wo er etwas
|
||
entscheidet: bei **„Da hakt es"**. Bei „Laeuft" und „Waechst gerade"
|
||
steht dasselbe Feld da, freiwillig — wer etwas Konkretes gesehen hat,
|
||
schreibt es hin, und beim Gespraech steht es da.
|
||
===================================================================== */
|
||
|
||
import express from "express";
|
||
import {
|
||
db, sitzungLesen, protokolliere, echteIp, fuehrtTeamDogi, ROLLEN_NAME,
|
||
} from "./workspace.js";
|
||
import {
|
||
ENTWICKLUNG_BLOECKE, ENTWICKLUNG_PUNKTE, ENTWICKLUNG_STUFEN,
|
||
ENTWICKLUNG_ERWARTUNG, ENTWICKLUNG_FREMD, ENTWICKLUNG_SELBST,
|
||
ENTWICKLUNG_NACH_SCHLUESSEL,
|
||
} from "./workspace-entwicklungs-punkte.js";
|
||
import {
|
||
TALENT_STUFEN, TALENT_GRUPPEN, TALENT_MERKMALE, TALENT_WARNZEICHEN,
|
||
TALENT_NACH_SCHLUESSEL, talentStufe, talentNaechste,
|
||
} from "./workspace-talent-punkte.js";
|
||
|
||
export const entwicklungRouter = express.Router();
|
||
|
||
const jetzt = () => new Date().toISOString();
|
||
const TAG = 86400000;
|
||
const ANLASS_MAX = 1200;
|
||
|
||
/* 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
|
||
verschwindet. Eine Akte, die alles behaelt, hat hier nichts zu
|
||
suchen; eine, die heimlich vergisst, auch nicht. */
|
||
export const VERBLASST_NACH_TAGEN = 90;
|
||
|
||
/* Die Rollen, ueber die eine Entwicklungskarte gefuehrt wird. Das Team
|
||
von Dogi -- nicht die Agentur, nicht die Community. Als Liste und
|
||
nicht als "alle ausser X": Eine neue Rolle soll hier NICHT
|
||
automatisch eine Personalakte bekommen. */
|
||
const KARTEN_ROLLEN = ["hand", "modi"];
|
||
|
||
/* =====================================================================
|
||
DIE TABELLEN
|
||
===================================================================== */
|
||
|
||
let bereit = false;
|
||
function tabellen() {
|
||
if (bereit) return;
|
||
db().exec(`
|
||
/* Eine Einschaetzung: WER hat bei WEM zu WELCHEM Punkt was gesetzt.
|
||
|
||
Der Beurteiler steht IM Schluessel. Genau daran ist die Idee
|
||
gescheitert, dieselbe Tabelle wie die Checkliste zu benutzen --
|
||
dort passt nur ein Stand je Punkt und Person hinein. */
|
||
CREATE TABLE IF NOT EXISTS entwicklung_stand (
|
||
person_id INTEGER NOT NULL REFERENCES personen(id) ON DELETE CASCADE,
|
||
schluessel TEXT NOT NULL,
|
||
von_id INTEGER NOT NULL REFERENCES personen(id) ON DELETE CASCADE,
|
||
stufe TEXT NOT NULL CHECK (stufe IN ('laeuft','waechst','hakt','unklar')),
|
||
anlass TEXT,
|
||
am TEXT NOT NULL,
|
||
PRIMARY KEY (person_id, schluessel, von_id)
|
||
);
|
||
CREATE INDEX IF NOT EXISTS idx_entwicklung_person
|
||
ON entwicklung_stand (person_id, am);
|
||
|
||
/* Ein angeklicktes Merkmal an einem Kandidaten.
|
||
|
||
KEIN von_id IM SCHLUESSEL, anders als oben -- und das ist
|
||
Absicht: Bei einem Talent geht es nicht um zwei Meinungen,
|
||
sondern darum, OB etwas beobachtet wurde. Zwei Leute, die
|
||
dasselbe gesehen haben, haben es einmal gesehen. Wer es gesetzt
|
||
hat, steht trotzdem dabei. */
|
||
CREATE TABLE IF NOT EXISTS talent_merkmal (
|
||
eintrag_id INTEGER NOT NULL REFERENCES eintraege(id) ON DELETE CASCADE,
|
||
schluessel TEXT NOT NULL,
|
||
von_id INTEGER REFERENCES personen(id) ON DELETE SET NULL,
|
||
am TEXT NOT NULL,
|
||
PRIMARY KEY (eintrag_id, schluessel)
|
||
);
|
||
|
||
/* Die Trichterstufe eines Kandidaten.
|
||
|
||
EIGENE TABELLE UND NICHT eintraege.status: Der Status dort hat
|
||
drei Werte fuer etwas ganz anderes (offen/erledigt/angenommen),
|
||
und die Stufe braucht ausserdem ein SEIT -- ohne das gibt es
|
||
keine Standzeit, und ohne Standzeit wird aus dem Trichter wieder
|
||
eine Liste. */
|
||
CREATE TABLE IF NOT EXISTS talent_stufe (
|
||
eintrag_id INTEGER PRIMARY KEY REFERENCES eintraege(id) ON DELETE CASCADE,
|
||
stufe TEXT NOT NULL
|
||
CHECK (stufe IN ('aufgefallen','beobachtet','angesprochen','probe','imteam')),
|
||
seit TEXT NOT NULL,
|
||
von_id INTEGER REFERENCES personen(id) ON DELETE SET NULL,
|
||
notiz TEXT
|
||
);
|
||
`);
|
||
bereit = true;
|
||
}
|
||
|
||
/* =====================================================================
|
||
DIE HUERDEN
|
||
===================================================================== */
|
||
|
||
function angemeldet(req, res, next) {
|
||
const person = sitzungLesen(req);
|
||
if (!person) return res.status(401).json({ fehler: "nicht_angemeldet" });
|
||
req.person = person;
|
||
try { tabellen(); } catch (f) {
|
||
console.error("[entwicklung] Tabellen:", f?.message);
|
||
return res.status(503).json({ fehler: "nicht_verfuegbar" });
|
||
}
|
||
next();
|
||
}
|
||
|
||
/** Nur DogFather und die rechte Hand. 404 und nicht 403 -- wer „kein
|
||
* Zugriff" liest, fragt als Naechstes, wer denn dann. */
|
||
function nurLeitung(req, res, next) {
|
||
if (fuehrtTeamDogi(req.person)) return next();
|
||
return res.status(404).json({ fehler: "nicht_gefunden" });
|
||
}
|
||
|
||
function gleicheHerkunft(req, res, next) {
|
||
const herkunft = req.get("origin");
|
||
if (!herkunft) return next();
|
||
let erlaubt;
|
||
try { erlaubt = new URL(herkunft).host === req.get("host"); } catch { erlaubt = false; }
|
||
if (!erlaubt) return res.status(403).json({ fehler: "fremde_herkunft" });
|
||
next();
|
||
}
|
||
|
||
entwicklungRouter.use("/workspace/api/entwicklung", angemeldet);
|
||
entwicklungRouter.use("/workspace/api/talente", angemeldet);
|
||
entwicklungRouter.use("/workspace/api/talente", nurLeitung);
|
||
|
||
const nummer = (x) => { const n = Number(x); return Number.isInteger(n) && n > 0 ? n : 0; };
|
||
const text = (x, max) => String(x ?? "").trim().slice(0, max);
|
||
const tageSeit = (iso) => {
|
||
const t = Date.parse(iso || "");
|
||
return Number.isFinite(t) ? Math.floor((Date.now() - t) / TAG) : null;
|
||
};
|
||
|
||
/* =====================================================================
|
||
ENTWICKLUNG
|
||
===================================================================== */
|
||
|
||
/* Der Katalog, wie ihn DIESE Person sehen darf.
|
||
Block 5 faellt fuer alle ausser der Person selbst heraus -- und zwar
|
||
schon hier, nicht erst in der Oberflaeche. */
|
||
function katalogFuer(person, ueberMichSelbst) {
|
||
return ENTWICKLUNG_BLOECKE
|
||
.filter((b) => !b.nurSelbst || ueberMichSelbst)
|
||
.map((b) => ({ ...b, punkte: ENTWICKLUNG_PUNKTE[b.schluessel] || [] }));
|
||
}
|
||
|
||
entwicklungRouter.get("/workspace/api/entwicklung/lage", (req, res) => {
|
||
try {
|
||
const leitung = fuehrtTeamDogi(req.person);
|
||
const platz = KARTEN_ROLLEN.map(() => "?").join(", ");
|
||
const personen = leitung
|
||
? db().prepare(`SELECT id, name, rolle, erstellt FROM personen
|
||
WHERE rolle IN (${platz}) AND aktiv = 1 ORDER BY name COLLATE NOCASE`)
|
||
.all(...KARTEN_ROLLEN)
|
||
: [];
|
||
|
||
/* Je Person: wie viele Punkte ICH schon gesetzt habe, wie viele
|
||
davon verblasst sind, und wie viele wir beide verschieden sehen.
|
||
Die Abweichung ist das Ergebnis -- deshalb steht sie schon in
|
||
der Uebersicht und nicht erst in der Karte. */
|
||
const gesetzt = db().prepare(
|
||
"SELECT person_id, COUNT(*) AS n FROM entwicklung_stand WHERE von_id = ? GROUP BY person_id")
|
||
.all(req.person.id);
|
||
const meineZahl = new Map(gesetzt.map((z) => [z.person_id, z.n]));
|
||
|
||
const alt = db().prepare(
|
||
`SELECT person_id, COUNT(*) AS n FROM entwicklung_stand
|
||
WHERE von_id = ? AND am < ? GROUP BY person_id`)
|
||
.all(req.person.id, new Date(Date.now() - VERBLASST_NACH_TAGEN * TAG).toISOString());
|
||
const alteZahl = new Map(alt.map((z) => [z.person_id, z.n]));
|
||
|
||
const anders = db().prepare(
|
||
`SELECT a.person_id, COUNT(*) AS n
|
||
FROM entwicklung_stand a JOIN entwicklung_stand b
|
||
ON a.person_id = b.person_id AND a.schluessel = b.schluessel
|
||
WHERE a.von_id = ? AND b.von_id <> ? AND a.stufe <> b.stufe
|
||
AND a.stufe <> 'unklar' AND b.stufe <> 'unklar'
|
||
GROUP BY a.person_id`).all(req.person.id, req.person.id);
|
||
const andersZahl = new Map(anders.map((z) => [z.person_id, z.n]));
|
||
|
||
res.json({
|
||
leitung,
|
||
stufen: ENTWICKLUNG_STUFEN,
|
||
erwartung: ENTWICKLUNG_ERWARTUNG,
|
||
verblasst_nach: VERBLASST_NACH_TAGEN,
|
||
gesamt_fremd: ENTWICKLUNG_FREMD.length,
|
||
personen: personen.map((p) => ({
|
||
id: p.id, name: p.name,
|
||
rolle_name: ROLLEN_NAME[p.rolle] ?? p.rolle,
|
||
dabei_seit_tagen: tageSeit(p.erstellt),
|
||
gesetzt: meineZahl.get(p.id) || 0,
|
||
veraltet: alteZahl.get(p.id) || 0,
|
||
abweichungen: andersZahl.get(p.id) || 0,
|
||
})),
|
||
/* Die eigene Karte -- fuer jeden, der im Team ist. */
|
||
ich: { id: req.person.id, name: req.person.name },
|
||
});
|
||
} catch (f) {
|
||
console.error("[entwicklung] Lage:", f?.message);
|
||
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
||
}
|
||
});
|
||
|
||
entwicklungRouter.get("/workspace/api/entwicklung/person/:id", nurLeitung, (req, res) => {
|
||
try {
|
||
const id = nummer(req.params.id);
|
||
const p = id && db().prepare("SELECT id, name, rolle FROM personen WHERE id = ?").get(id);
|
||
if (!p || !KARTEN_ROLLEN.includes(p.rolle)) {
|
||
return res.status(404).json({ fehler: "nicht_gefunden" });
|
||
}
|
||
|
||
const meine = new Map(db().prepare(
|
||
"SELECT schluessel, stufe, anlass, am FROM entwicklung_stand WHERE person_id = ? AND von_id = ?")
|
||
.all(id, req.person.id).map((z) => [z.schluessel, z]));
|
||
|
||
/* DER STAND DES ANDEREN -- nur zu Punkten, bei denen ich selbst
|
||
schon gesetzt habe. Der Riegel sitzt hier, nicht in der
|
||
Oberflaeche: Was nicht ausgeliefert wird, kann niemand ankern. */
|
||
const fremde = db().prepare(
|
||
`SELECT s.schluessel, s.stufe, s.anlass, s.am, v.name AS von_name
|
||
FROM entwicklung_stand s LEFT JOIN personen v ON v.id = s.von_id
|
||
WHERE s.person_id = ? AND s.von_id <> ?`).all(id, req.person.id);
|
||
|
||
const grenze = Date.now() - VERBLASST_NACH_TAGEN * TAG;
|
||
const bloecke = katalogFuer(req.person, false).map((b) => ({
|
||
...b,
|
||
punkte: b.punkte.map((pt) => {
|
||
const m = meine.get(pt.schluessel) || null;
|
||
const andere = m
|
||
? fremde.filter((f) => f.schluessel === pt.schluessel)
|
||
.map((f) => ({ von_name: f.von_name, stufe: f.stufe, anlass: f.anlass, am: f.am }))
|
||
: null;
|
||
return {
|
||
...pt,
|
||
meins: m ? { ...m, verblasst: Date.parse(m.am) < grenze } : null,
|
||
andere,
|
||
/* WIE VIELE ES SONST NOCH GIBT -- ohne zu sagen, was sie
|
||
gesetzt haben. Ohne diese Zahl waere „noch nichts von der
|
||
rechten Hand" und „sie hat gesetzt, du siehst es nur
|
||
nicht" dasselbe Bild, und das erste ist eine Auskunft,
|
||
das zweite eine Taeuschung. */
|
||
andere_offen: m ? 0 : fremde.filter((f) => f.schluessel === pt.schluessel).length,
|
||
};
|
||
}),
|
||
}));
|
||
|
||
/* DIE ABWEICHUNGEN ZUERST. Sie sind das Ergebnis, nicht ein
|
||
Nebenprodukt: Wo zwei Leute dasselbe sehen, gibt es nichts zu
|
||
besprechen. */
|
||
const abweichungen = [];
|
||
for (const b of bloecke) {
|
||
for (const pt of b.punkte) {
|
||
if (!pt.meins || !pt.andere?.length) continue;
|
||
for (const a of pt.andere) {
|
||
if (a.stufe === pt.meins.stufe) continue;
|
||
if (a.stufe === "unklar" || pt.meins.stufe === "unklar") continue;
|
||
abweichungen.push({
|
||
schluessel: pt.schluessel, titel: pt.titel, block: b.name,
|
||
meins: pt.meins.stufe, andere: a.stufe, von_name: a.von_name,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
res.json({
|
||
person: { id: p.id, name: p.name, rolle_name: ROLLEN_NAME[p.rolle] ?? p.rolle },
|
||
stufen: ENTWICKLUNG_STUFEN,
|
||
erwartung: ENTWICKLUNG_ERWARTUNG,
|
||
bloecke,
|
||
abweichungen,
|
||
gesetzt: meine.size,
|
||
gesamt: ENTWICKLUNG_FREMD.length,
|
||
});
|
||
} catch (f) {
|
||
console.error("[entwicklung] Person:", f?.message);
|
||
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
||
}
|
||
});
|
||
|
||
const STUFEN_SATZ = new Set(ENTWICKLUNG_STUFEN.map((s) => s.schluessel));
|
||
|
||
entwicklungRouter.put("/workspace/api/entwicklung/person/:id/:schluessel",
|
||
gleicheHerkunft, nurLeitung, (req, res) => {
|
||
try {
|
||
const id = nummer(req.params.id);
|
||
const schluessel = String(req.params.schluessel || "");
|
||
const stufe = String(req.body?.stufe || "");
|
||
const anlass = text(req.body?.anlass, ANLASS_MAX);
|
||
|
||
const p = id && db().prepare("SELECT id, rolle FROM personen WHERE id = ?").get(id);
|
||
if (!p || !KARTEN_ROLLEN.includes(p.rolle)) {
|
||
return res.status(404).json({ fehler: "nicht_gefunden" });
|
||
}
|
||
/* NUR PUNKTE, DIE ANDERE SETZEN DUERFEN. Block 5 faellt hier
|
||
heraus -- und zwar an derselben Stelle, an der auch die
|
||
Auslieferung ihn wegnimmt. Ein Schluessel aus Block 5 sieht
|
||
von aussen genauso aus wie ein erfundener. */
|
||
if (!ENTWICKLUNG_FREMD.some((x) => x.schluessel === schluessel)) {
|
||
return res.status(404).json({ fehler: "nicht_gefunden" });
|
||
}
|
||
if (!STUFEN_SATZ.has(stufe)) return res.status(400).json({ fehler: "unbekannte_stufe" });
|
||
|
||
/* DER ANLASS IST PFLICHT, WO ER ETWAS ENTSCHEIDET.
|
||
„Da hakt es" ohne Beispiel ist eine Meinung; mit Beispiel ist
|
||
es ein Gespraech. Bei den anderen beiden steht dasselbe Feld
|
||
freiwillig da. */
|
||
if (stufe === "hakt" && anlass.length < 10) {
|
||
return res.status(400).json({
|
||
fehler: "Woran hast du das gemerkt? Ein Satz genügt – ohne ihn "
|
||
+ "ist es beim Gespräch nur noch ein Gefühl.",
|
||
});
|
||
}
|
||
|
||
db().prepare(`INSERT INTO entwicklung_stand
|
||
(person_id, schluessel, von_id, stufe, anlass, am)
|
||
VALUES (?,?,?,?,?,?)
|
||
ON CONFLICT(person_id, schluessel, von_id) DO UPDATE SET
|
||
stufe = excluded.stufe, anlass = excluded.anlass, am = excluded.am`)
|
||
.run(id, schluessel, req.person.id, stufe, anlass || null, jetzt());
|
||
|
||
res.json({ ok: true });
|
||
} catch (f) {
|
||
console.error("[entwicklung] Setzen:", f?.message);
|
||
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
||
}
|
||
});
|
||
|
||
/* ---------- Block 5: die eigene Karte -------------------------------- */
|
||
|
||
/* KEIN :id IN DER ADRESSE, und das ist der Riegel.
|
||
Eine Route /entwicklung/mich/:id waere eine Einladung, eine fremde
|
||
Nummer einzusetzen -- und dann haengt alles an einer einzigen
|
||
Vergleichszeile. Hier gibt es die Nummer gar nicht: Es ist immer die
|
||
Person, die fragt. */
|
||
entwicklungRouter.get("/workspace/api/entwicklung/mich", (req, res) => {
|
||
try {
|
||
const meine = new Map(db().prepare(
|
||
"SELECT schluessel, stufe, anlass, am FROM entwicklung_stand WHERE person_id = ? AND von_id = ?")
|
||
.all(req.person.id, req.person.id).map((z) => [z.schluessel, z]));
|
||
const block = ENTWICKLUNG_BLOECKE.find((b) => b.nurSelbst);
|
||
res.json({
|
||
block,
|
||
stufen: ENTWICKLUNG_STUFEN,
|
||
punkte: (ENTWICKLUNG_PUNKTE[block.schluessel] || []).map((pt) => ({
|
||
...pt, meins: meine.get(pt.schluessel) || null,
|
||
})),
|
||
});
|
||
} catch (f) {
|
||
console.error("[entwicklung] Mich:", f?.message);
|
||
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
||
}
|
||
});
|
||
|
||
entwicklungRouter.put("/workspace/api/entwicklung/mich/:schluessel", gleicheHerkunft,
|
||
(req, res) => {
|
||
try {
|
||
const schluessel = String(req.params.schluessel || "");
|
||
const stufe = String(req.body?.stufe || "");
|
||
if (!ENTWICKLUNG_SELBST.some((x) => x.schluessel === schluessel)) {
|
||
return res.status(404).json({ fehler: "nicht_gefunden" });
|
||
}
|
||
if (!STUFEN_SATZ.has(stufe)) return res.status(400).json({ fehler: "unbekannte_stufe" });
|
||
/* KEIN ANLASS, NIRGENDS. Hier antwortet jemand ueber sich selbst;
|
||
ein Pflichtfeld waere die zuverlaessigste Art, ehrliche
|
||
Antworten zu verhindern. */
|
||
db().prepare(`INSERT INTO entwicklung_stand
|
||
(person_id, schluessel, von_id, stufe, anlass, am)
|
||
VALUES (?,?,?,?,NULL,?)
|
||
ON CONFLICT(person_id, schluessel, von_id) DO UPDATE SET
|
||
stufe = excluded.stufe, am = excluded.am`)
|
||
.run(req.person.id, schluessel, req.person.id, stufe, jetzt());
|
||
res.json({ ok: true });
|
||
} catch (f) {
|
||
console.error("[entwicklung] Mich setzen:", f?.message);
|
||
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
||
}
|
||
});
|
||
|
||
/* ---------- Die Ampel ohne Namen -------------------------------------- */
|
||
|
||
/* WAS HIER HERAUSKOMMT UND WAS NICHT.
|
||
Heraus kommt: wie viele im Team gerade mindestens einen Punkt aus
|
||
Block 5 auf „da hakt es" stehen haben -- und ab zwei Personen auch
|
||
das nicht mehr genau, sondern als Spanne.
|
||
Nicht heraus kommt: wer, welcher Punkt, seit wann.
|
||
|
||
WARUM UEBERHAUPT ETWAS: Ein Block, den niemand je sieht, ist ein
|
||
Block, den niemand ausfuellt. Die Ampel ist der einzige Weg von
|
||
dort nach draussen -- und sie ist absichtlich so schmal, dass man
|
||
daraus niemanden erkennen kann.
|
||
|
||
AB WELCHER ZAHL SIE UEBERHAUPT ETWAS SAGT: Bei einem Team von zwei
|
||
Personen waere „einer hat gerade viel" dasselbe wie ein Name.
|
||
Deshalb schweigt sie unter MINDESTENS_TEAM Personen -- mit Grund,
|
||
nicht stillschweigend. */
|
||
const MINDESTENS_TEAM = 3;
|
||
|
||
entwicklungRouter.get("/workspace/api/entwicklung/ampel", nurLeitung, (req, res) => {
|
||
try {
|
||
const platz = KARTEN_ROLLEN.map(() => "?").join(", ");
|
||
const { n: team } = db().prepare(
|
||
`SELECT COUNT(*) AS n FROM personen WHERE rolle IN (${platz}) AND aktiv = 1`)
|
||
.all(...KARTEN_ROLLEN)[0] || { n: 0 };
|
||
|
||
if (team < MINDESTENS_TEAM) {
|
||
/* DREI VERSCHIEDENE LAGEN, DREI VERSCHIEDENE SAETZE.
|
||
|
||
Der erste Entwurf hatte einen Satz fuer alle: "Solange ihr zu
|
||
{n} seid …". Im Bildschirmfoto stand dort "Solange ihr zu 1
|
||
seid" -- und bei einem leeren Team "zu 0". Ein Satz, der bei
|
||
den haeufigsten Faellen falsches Deutsch ergibt, liest sich
|
||
wie ein Fehler, und ab da glaubt man auch dem Rest nicht mehr.
|
||
|
||
Gefunden hat das kein Test, sondern ein Blick auf die Seite. */
|
||
return res.json({
|
||
sagbar: false, team,
|
||
grund: team === 0
|
||
? "Noch ist niemand im Team. Sobald jemand dazukommt, kann hier etwas stehen."
|
||
: team === 1
|
||
? "Es ist gerade eine Person im Team – da wäre jede Auskunft hier "
|
||
+ "dasselbe wie ein Name. Frag sie direkt."
|
||
: `Ihr seid zu ${team}. Jede Auskunft an dieser Stelle wäre damit `
|
||
+ "so gut wie ein Name – frag sie direkt, das ist hier ohnehin der bessere Weg.",
|
||
});
|
||
}
|
||
|
||
const schluessel = ENTWICKLUNG_SELBST.map((p) => p.schluessel);
|
||
const pl = schluessel.map(() => "?").join(", ");
|
||
const { n: betroffen } = db().prepare(
|
||
`SELECT COUNT(DISTINCT person_id) AS n FROM entwicklung_stand
|
||
WHERE schluessel IN (${pl}) AND stufe = 'hakt' AND person_id = von_id`)
|
||
.all(...schluessel)[0] || { n: 0 };
|
||
const { n: geantwortet } = db().prepare(
|
||
`SELECT COUNT(DISTINCT person_id) AS n FROM entwicklung_stand
|
||
WHERE schluessel IN (${pl}) AND person_id = von_id`)
|
||
.all(...schluessel)[0] || { n: 0 };
|
||
|
||
res.json({
|
||
sagbar: true, team, geantwortet,
|
||
/* Keine genaue Zahl: „einer" waere bei kleinen Teams wieder ein
|
||
Name. Es gibt drei Antworten, und mehr braucht es nicht. */
|
||
lage: betroffen === 0 ? "ruhig" : betroffen === 1 ? "jemand" : "mehrere",
|
||
text: betroffen === 0
|
||
? (geantwortet === 0
|
||
? "Noch hat niemand geantwortet. Die Ampel sagt erst etwas, wenn jemand da war."
|
||
: "Gerade meldet niemand, dass es zu viel wird.")
|
||
: betroffen === 1
|
||
? "Jemand im Team hat gerade viel. Kein Name – frag reihum, nicht gezielt."
|
||
: "Mehrere im Team haben gerade viel. Das ist selten ein Zufall.",
|
||
});
|
||
} catch (f) {
|
||
console.error("[entwicklung] Ampel:", f?.message);
|
||
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
||
}
|
||
});
|
||
|
||
/* =====================================================================
|
||
TALENTE
|
||
===================================================================== */
|
||
|
||
/** Die Karte eines Kandidaten -- Stufe, Standzeit, was zu tun ist. */
|
||
function talentKarte(e, merkmale) {
|
||
const stand = db().prepare("SELECT stufe, seit, notiz FROM talent_stufe WHERE eintrag_id = ?")
|
||
.get(e.id);
|
||
const stufe = talentStufe(stand?.stufe) || TALENT_STUFEN[0];
|
||
const seit = stand?.seit || e.erstellt;
|
||
const tage = tageSeit(seit);
|
||
const gesetzt = merkmale.filter((m) => m.eintrag_id === e.id).map((m) => m.schluessel);
|
||
const warn = gesetzt.filter((s) => TALENT_NACH_SCHLUESSEL.get(s)?.warnung);
|
||
return {
|
||
id: e.id, titel: e.titel, text: e.text, einsatz: e.einsatz,
|
||
stufe: stufe.schluessel, stufe_name: stufe.name,
|
||
bedeutet: stufe.bedeutet, schritt: stufe.schritt, knopf: stufe.knopf,
|
||
naechste: talentNaechste(stufe.schluessel)?.schluessel || null,
|
||
seit, tage,
|
||
/* 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,
|
||
merkmale: gesetzt.filter((s) => !TALENT_NACH_SCHLUESSEL.get(s)?.warnung),
|
||
warnzeichen: warn,
|
||
notiz: stand?.notiz || null,
|
||
};
|
||
}
|
||
|
||
entwicklungRouter.get("/workspace/api/talente/lage", (req, res) => {
|
||
try {
|
||
const eintraege = db().prepare(
|
||
`SELECT id, titel, text, einsatz, erstellt FROM eintraege
|
||
WHERE bereich = 'talente' ORDER BY id DESC LIMIT 300`).all();
|
||
const merkmale = eintraege.length
|
||
? db().prepare(
|
||
`SELECT eintrag_id, schluessel FROM talent_merkmal
|
||
WHERE eintrag_id IN (${eintraege.map(() => "?").join(", ")})`)
|
||
.all(...eintraege.map((e) => e.id))
|
||
: [];
|
||
|
||
const karten = eintraege.map((e) => talentKarte(e, merkmale));
|
||
res.json({
|
||
stufen: TALENT_STUFEN,
|
||
gruppen: TALENT_GRUPPEN.map((g) => ({ ...g, merkmale: TALENT_MERKMALE[g.schluessel] || [] })),
|
||
warnzeichen: TALENT_WARNZEICHEN,
|
||
kandidaten: karten,
|
||
/* Wie viele auf jeder Stufe liegen -- der Trichter als Zahlen.
|
||
Abgeleitet, damit er nicht danebenliegen kann. */
|
||
trichter: TALENT_STUFEN.map((s) => ({
|
||
schluessel: s.schluessel, name: s.name,
|
||
anzahl: karten.filter((k) => k.stufe === s.schluessel).length,
|
||
liegt: karten.filter((k) => k.stufe === s.schluessel && k.liegt).length,
|
||
})),
|
||
});
|
||
} catch (f) {
|
||
console.error("[talente] Lage:", f?.message);
|
||
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
||
}
|
||
});
|
||
|
||
entwicklungRouter.put("/workspace/api/talente/:id/merkmal/:schluessel", gleicheHerkunft,
|
||
(req, res) => {
|
||
try {
|
||
const id = nummer(req.params.id);
|
||
const schluessel = String(req.params.schluessel || "");
|
||
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" });
|
||
if (!TALENT_NACH_SCHLUESSEL.has(schluessel)) {
|
||
return res.status(404).json({ fehler: "nicht_gefunden" });
|
||
}
|
||
/* EIN KNOPF, ZWEI RICHTUNGEN. Ein Merkmal ist gesehen oder nicht;
|
||
ein zweiter Klick nimmt es zurueck. Kein eigener Weg zum
|
||
Abwaehlen -- der waere die Stelle, an der die Rechtepruefung
|
||
irgendwann fehlt. */
|
||
const schon = db().prepare(
|
||
"SELECT 1 AS x FROM talent_merkmal WHERE eintrag_id = ? AND schluessel = ?")
|
||
.get(id, schluessel);
|
||
if (schon) {
|
||
db().prepare("DELETE FROM talent_merkmal WHERE eintrag_id = ? AND schluessel = ?")
|
||
.run(id, schluessel);
|
||
} else {
|
||
db().prepare(
|
||
"INSERT INTO talent_merkmal (eintrag_id, schluessel, von_id, am) VALUES (?,?,?,?)")
|
||
.run(id, schluessel, req.person.id, jetzt());
|
||
}
|
||
res.json({ ok: true, gesetzt: !schon });
|
||
} catch (f) {
|
||
console.error("[talente] Merkmal:", f?.message);
|
||
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
||
}
|
||
});
|
||
|
||
entwicklungRouter.put("/workspace/api/talente/:id/stufe", gleicheHerkunft, (req, res) => {
|
||
try {
|
||
const id = nummer(req.params.id);
|
||
const stufe = String(req.body?.stufe || "");
|
||
const notiz = text(req.body?.notiz, ANLASS_MAX);
|
||
const e = id && db().prepare("SELECT id, bereich, titel FROM eintraege WHERE id = ?").get(id);
|
||
if (!e || e.bereich !== "talente") return res.status(404).json({ fehler: "nicht_gefunden" });
|
||
if (!talentStufe(stufe)) return res.status(400).json({ fehler: "unbekannte_stufe" });
|
||
|
||
db().prepare(`INSERT INTO talent_stufe (eintrag_id, stufe, seit, von_id, notiz)
|
||
VALUES (?,?,?,?,?)
|
||
ON CONFLICT(eintrag_id) DO UPDATE SET
|
||
stufe = excluded.stufe, seit = excluded.seit,
|
||
von_id = excluded.von_id, notiz = excluded.notiz`)
|
||
.run(id, stufe, jetzt(), req.person.id, notiz || null);
|
||
|
||
protokolliere("talent_stufe", {
|
||
personId: req.person.id, ip: echteIp(req),
|
||
detail: `${e.titel}: ${stufe}`.slice(0, 120),
|
||
});
|
||
res.json({ ok: true, stufe });
|
||
} catch (f) {
|
||
console.error("[talente] Stufe:", f?.message);
|
||
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
||
}
|
||
});
|