VanVan im Support: „wenn ich … Aufgaben an einen Modi zutrage und
auch bewerte, dann sieht der Modi zwar die Auswertung im Bereich
Entwicklung, aber bei ihm taucht nichts im Bereich eure Aufgaben
unten in der Karte auf."
IHR FALL NACHGESTELLT (server/mess-vanvan-karte.mjs): VanVan traegt
einem Modi zwei von 68 Punkten zu und bewertet sie, Filipe bewertet
nichts -- genau wie bei ihr.
vorher seine Karte: fertig=0 von 68, alles „offen",
Punktzeilen sichtbar: 0
Kennt sie das Wort „zugetragen"? NEIN
nachher Abschnitt „Deine Aufgaben (2)" mit beiden Titeln
ZWEI DINGE WAREN EINS, DIE GETRENNT GEHOEREN
Was er TUN soll die Zuteilung. Die gibt es, bevor jemand etwas
bewertet, und sie gehoert ihm.
Was man SIEHT die Bewertung. Die erscheint erst, wenn alle
hingesehen haben.
`meine-karte` kannte nur das Zweite. War die Bewertung noch nicht
vollstaendig, stieg die Anzeige mit `return` aus -- und damit sah er
gar nichts, obwohl ihm zwei Aufgaben zugetragen waren. Das ist die
falsche Reihenfolge: Wer seine Aufgabe erst sieht, wenn sich zwei
Leute ueber ihre Ausfuehrung einig sind, kann sie nicht angehen.
Die Regel fuer die Bewertung bleibt unveraendert. Eine einzelne
Meinung soll bei einem Menschen nicht als Urteil des Teams ankommen.
UND DIE ANDERE HAELFTE: NIEMAND SAGTE ES IHR
VanVan hatte gesetzt, es kam nicht an, und nichts auf ihrem
Bildschirm erklaerte das. Ein Mensch, der das zweimal erlebt, hoert
auf zu setzen. An einem Punkt, den sie bewertet hat, steht jetzt
leise: „Er sieht das noch nicht -- es fehlt noch eine Einschaetzung."
Ohne Namen: Wer fehlt, waere eine Aufforderung, jemanden
anzutreiben.
IHRE ZWEITE BEOBACHTUNG, EBENFALLS GEMESSEN
„bei jedem Punkt läuft obwohl auch wenn die Aufgaben darüber gar
nicht zugetragen wurde." Stimmt: Die vier Bewertungsknoepfe („✓
Läuft ↗ Wächst ! Da hakt es – Kann ich nicht sagen") stehen an allen
68 Punkten, auch an den nicht zugetragenen. Beim Durchscrollen liest
man deshalb ueberall „Läuft".
DIE 68 BLEIBEN. Filipe am 25.09.2026 ausdruecklich: „dogfather und
die rechte hand sollen immer noch die 68 sachen sehen wie vorher …
unsere sicht soll sich nicht aendern." Also kein Ausblenden und
keine neue Vorgabe, sondern ein Knopf mehr in der Leiste, die es
schon gibt: „Zugetragen · 2". „Alle" bleibt, was beim Aufmachen
gilt. Gemessen grenzt er auf 2 Punkte ein, Kopf „2 / 2".
NEBENBEI ZUSAMMENGEFUEHRT
* `beurteilerZahl()` an einer Stelle -- beide Enden stellen dieselbe
Frage, zwei Abschriften saegten irgendwann Verschiedenes ueber
denselben Punkt.
* `antwortReihe()` und `punkteGefiltert()` ebenso.
* Die Abfrage stand zuerst je Punkt in der Schleife: 68
Datenbankfragen fuer eine Zahl, die sich nicht aendert.
GEPRUEFT
pruef-entwicklung 79/0 (9 neue, mit Gegenproben in beide Richtungen:
ein nicht zugetragener Punkt traegt die Marke nicht, und sobald der
zweite Beurteiler setzt, geht es durch). mess-vanvan-karte ohne
ACHTUNG. pruef-werdegang 103/0, pruef-entwicklung-kacheln 34/0,
pruef-deutsche-texte, pruef-css-klassen, pruef-zwischenspeicher 34/0.
Co-Authored-By: Claude Opus 5 <[email protected]>
2206 lines
101 KiB
JavaScript
2206 lines
101 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,
|
||
ROLLEN_SORTIERUNG, MODI_KATEGORIEN, kategorienFuer, darfKatalogBekommen,
|
||
} from "./workspace.js";
|
||
/* Die Aufgaben, die ein Neuer bekommen kann -- dieselbe Liste, aus der
|
||
auch die Aufgabenseite ihre Vorschlaege baut. Eine zweite waere die,
|
||
die beim naechsten Nachschaerfen auseinanderlaeuft. */
|
||
import { MODI_AUFGABEN_FLACH } from "./workspace-vorlagen.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,
|
||
// (Probeplan siehe unten -- eigene Datei, eigener Gegenstand)
|
||
SCHICHT_WEIT_TAGE, SCHICHT_MAX_TAGE, ERSTER_TAG,
|
||
TALENT_NACH_SCHLUESSEL, talentStufe, talentNaechste, ansprechtext,
|
||
} from "./workspace-talent-punkte.js";
|
||
import {
|
||
PROBE_PHASEN, PROBE_SCHRITTE, PROBE_WER, PROBE_ALLE, PROBE_FRAGEN,
|
||
PROBE_NACH_SCHLUESSEL, probeStand,
|
||
} from "./workspace-probeplan.js";
|
||
|
||
export const entwicklungRouter = express.Router();
|
||
|
||
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
|
||
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", "linke", "modi"];
|
||
|
||
/** Wer einen Neuen einarbeiten kann.
|
||
*
|
||
* DIESELBEN WIE BEI DER KARTE, PLUS DOGFATHER. Er steht nicht ueber
|
||
* dem Team, er gehoert dazu -- und wer eine Probe begleitet, macht
|
||
* dieselbe Arbeit wie alle anderen. Ein Manager oder Scout dagegen
|
||
* arbeitet im anderen Haus und kann niemanden durch eine Schicht
|
||
* begleiten, die er selbst nicht macht. */
|
||
const BUDDY_ROLLEN = ["admin", "hand", "linke", "modi"];
|
||
|
||
/** Welchen Zugang der letzte Schritt des Trichters anlegt.
|
||
*
|
||
* ER STEHT HIER UND NICHT IM BROWSER (16.09.2026). Die Talente-Seite
|
||
* hatte `rolle: 'modi'` als festen Text im Skript -- und jede Datei
|
||
* unter `workspace/` liefert der Server an JEDEN aus, der die Adresse
|
||
* kennt. Nachgemessen am 16.09.: `talente.js` kommt ohne jede
|
||
* Anmeldung mit 200 aus dem offenen Netz, 25 KB Quelltext, der
|
||
* Rollenname mittendrin.
|
||
*
|
||
* Damit stand der verborgene Zugang in einer Datei, die man nicht
|
||
* einmal geschenkt bekommen muss -- man kann sie abrufen. Die
|
||
* Rollentabelle nebenan sagt seit dem 11.09. genau das Richtige dazu:
|
||
* „Der Name steht hier und NICHT in einer Datei, die jeder
|
||
* herunterlaedt -- ausgeliefert wird er nur an den, der ihn selbst
|
||
* traegt, und an die, die ihn sehen duerfen."
|
||
*
|
||
* Die Seite fragt ihn deshalb ab, statt ihn zu kennen. Die Antwort
|
||
* geht ueber `nurLeitung` -- also nur an DogFather und die rechte
|
||
* Hand. */
|
||
const ZUGANG_ROLLE = "modi";
|
||
|
||
/* =====================================================================
|
||
DIE TABELLEN
|
||
===================================================================== */
|
||
|
||
let bereit = false;
|
||
/** Auch von der Befindensseite aufgerufen.
|
||
*
|
||
* Sie liest `entwicklung_stand`, legt die Tabelle aber nicht selbst an
|
||
* -- eine zweite Abschrift derselben Spalten waere die Liste, die beim
|
||
* naechsten Zuwachs auseinanderlaeuft. Gefunden hat das die Pruefung:
|
||
* Wer die neue Seite oeffnete, ohne dass vorher jemand die
|
||
* Entwicklungsseite besucht hatte, bekam "no such table". */
|
||
export 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);
|
||
|
||
/* =================================================================
|
||
DIE SPUR (19.09.2026) — jede Aenderung, angehaengt statt ueberschrieben.
|
||
|
||
Die Tabelle darueber hat den Schluessel (person, punkt, beurteiler)
|
||
und wird bei jeder Aenderung UEBERSCHRIEBEN. Das ist dort richtig:
|
||
Eine Beobachtung ueber einen Menschen soll aktuell sein, nicht
|
||
historisch.
|
||
|
||
Nur: Damit existierte nichts, woraus man einen Verlauf zeichnen
|
||
koennte. Am 19.09. nachgemessen: 43 Zeilen Stand, NULL Zeilen
|
||
Geschichte. Ein Diagramm ueber einen einzigen Zeitpunkt ist kein
|
||
Diagramm, sondern ein Balken.
|
||
|
||
SIE STEHT HIER UND NICHT IN workspace-werdegang.js, obwohl sie
|
||
dort ausgewertet wird: Eine Tabelle gehoert zu dem Modul, das sie
|
||
SCHREIBT. Andersherum gaebe es einen Ring zwischen zwei Modulen,
|
||
und Ringe halten in JavaScript genau so lange, bis jemand eine
|
||
Konstante auf oberster Ebene benutzt.
|
||
|
||
KEIN ANLASS, NIE. Neben jedem "da hakt es" steht in der Tabelle
|
||
oben ein Satz, woran man es gemerkt hat. Der ist fuer ein
|
||
Gespraech aufgeschrieben. Eine CHRONIK dieser Saetze waere das
|
||
Belastendste, was dieses Haus speichern kann -- jede laengst
|
||
geklaerte Beobachtung bliebe auf Dauer lesbar. Dasselbe Prinzip
|
||
wie beim Befinden-Verlauf.
|
||
|
||
VORHER steht dabei, obwohl es aus der vorigen Zeile abzuleiten
|
||
waere: Fuer die Startaufnahme (siehe unten) gibt es kein
|
||
"vorher", und NULL sagt das, waehrend eine abgeleitete Kette es
|
||
verschweigen wuerde.
|
||
|
||
NACHGETRAGEN unterscheidet beobachtete Uebergaenge von der
|
||
Startaufnahme. Ohne dieses Feld saehe der Tag, an dem die Spur
|
||
entstand, wie ein Tag aus, an dem 43 Dinge gleichzeitig passiert
|
||
sind.
|
||
============================================================= */
|
||
/* =================================================================
|
||
WELCHE PUNKTE GELTEN FUER DIESEN MENSCHEN? (25.09.2026)
|
||
=================================================================
|
||
|
||
Filipe: „ich will dass die rechte hand und ich diese aufgaben die
|
||
es da gibt, individuel aussuchen koennen wer welche aufgabe
|
||
bekommt. bis dahin sollen die keine aufgaben sehen. nur die, die
|
||
die rechte hand oder dogfather ihnen zutragen."
|
||
|
||
BIS HEUTE GALTEN ALLE 68 FUER JEDEN. Das war bequem und in der
|
||
Sache falsch: „Clips, Schnitt und Kommentare" gehoert nicht zu
|
||
jemandem, der nur im Chat moderiert -- und ein Punkt, der nie
|
||
zutrifft, steht trotzdem in der Zaehlung („0 von 68"). Wer eine
|
||
Liste sieht, in der ein Drittel nie passt, liest die ganze Liste
|
||
weniger ernst.
|
||
|
||
EINE ZEILE JE PAAR, KEINE LISTE IN EINER SPALTE: Eine
|
||
kommagetrennte Liste in personen waere schneller gebaut und
|
||
liesse sich nicht abfragen („wer hat diesen Punkt?"), nicht
|
||
zaehlen und nicht mit einem Fremdschluessel absichern.
|
||
|
||
WER UND WANN stehen mit drin. Nicht fuer die Anzeige -- fuer die
|
||
Frage „seit wann gilt das eigentlich fuer ihn", die
|
||
erfahrungsgemaess in dem Moment kommt, in dem jemand sie nicht
|
||
mehr beantworten kann. */
|
||
CREATE TABLE IF NOT EXISTS entwicklung_zuteilung (
|
||
person_id INTEGER NOT NULL REFERENCES personen(id) ON DELETE CASCADE,
|
||
schluessel TEXT NOT NULL,
|
||
seit TEXT NOT NULL,
|
||
von_id INTEGER REFERENCES personen(id) ON DELETE SET NULL,
|
||
PRIMARY KEY (person_id, schluessel)
|
||
);
|
||
CREATE INDEX IF NOT EXISTS idx_entw_zuteilung_person
|
||
ON entwicklung_zuteilung (person_id);
|
||
|
||
/* =================================================================
|
||
EINMALIG: WAS SCHON BEURTEILT WURDE, BLEIBT SICHTBAR
|
||
=================================================================
|
||
|
||
Ab heute zeigt die Karte nur noch zugeteilte Punkte. Fuer jemanden,
|
||
bei dem noch nichts ausgesucht ist, heisst das „leer" -- und genau
|
||
das hat Filipe gewollt („bis dahin sollen die keine aufgaben
|
||
sehen").
|
||
|
||
FUER BESTEHENDE BEURTEILUNGEN WAERE ES ABER DATENVERLUST AUF DEM
|
||
BILDSCHIRM: Wer zwanzig Punkte gesetzt hat, saehe am naechsten
|
||
Morgen eine leere Karte -- die Daten liegen noch da, man kommt nur
|
||
nicht mehr hin. Das sieht aus wie ein Fehler und ist einer.
|
||
|
||
DESHALB WIRD EINMALIG UEBERNOMMEN, wozu es schon eine
|
||
Einschaetzung gibt. Nicht mehr: Ein Punkt, den nie jemand gesetzt
|
||
hat, war auch vorher nur Papier.
|
||
|
||
Ein INSERT, das Vorhandenes stehen laesst, UND EIN FLAG: Das Flag verhindert, dass die
|
||
Uebernahme wiederkommt, nachdem jemand bewusst alles abgewaehlt
|
||
hat -- sonst waere „ich will diesen Punkt nicht mehr" nach dem
|
||
naechsten Neustart rueckgaengig gemacht, und niemand wuesste,
|
||
warum. */
|
||
INSERT INTO entwicklung_zuteilung (person_id, schluessel, seit, von_id)
|
||
SELECT DISTINCT s.person_id, s.schluessel, MIN(s.am), NULL
|
||
FROM entwicklung_stand s
|
||
WHERE NOT EXISTS (SELECT 1 FROM einstellungen
|
||
WHERE schluessel = 'entwicklung_zuteilung_uebernommen')
|
||
GROUP BY s.person_id, s.schluessel
|
||
ON CONFLICT(person_id, schluessel) DO NOTHING;
|
||
|
||
INSERT INTO einstellungen (schluessel, wert, geaendert, von)
|
||
VALUES ('entwicklung_zuteilung_uebernommen', 'ja', datetime('now'), NULL)
|
||
ON CONFLICT(schluessel) DO NOTHING;
|
||
|
||
CREATE TABLE IF NOT EXISTS entwicklung_lauf (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
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')),
|
||
vorher TEXT,
|
||
nachgetragen INTEGER NOT NULL DEFAULT 0,
|
||
am TEXT NOT NULL
|
||
);
|
||
CREATE INDEX IF NOT EXISTS idx_lauf_person
|
||
ON entwicklung_lauf (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. */
|
||
/* WAS IN DER PROBEZEIT SCHON PASSIERT IST (17.09.2026).
|
||
|
||
Filipe: "wie talente ... aufgaben bekommen, analysiert werden von
|
||
vanvan und mir."
|
||
|
||
EIGENE TABELLE UND NICHT DIE AUFGABEN: Ein Talent ist ein EINTRAG,
|
||
kein Zugang -- es gibt keine Person, der man eine Aufgabe geben
|
||
koennte. Und die Schritte gehoeren nicht alle ihm: drei davon tut
|
||
der Buddy, drei die Leitung. Eine Aufgabe auf dem Brett waere
|
||
damit entweder herrenlos oder an der falschen Person.
|
||
|
||
MIT von_id UND am, anders als bei den Merkmalen mit Absicht
|
||
SICHTBAR: Bei einem Schritt zaehlt, WER ihn gesehen hat. "Das
|
||
Team wurde gefragt" von dir und von der rechten Hand sind zwei
|
||
verschiedene Auskuenfte. */
|
||
CREATE TABLE IF NOT EXISTS talent_probe (
|
||
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,
|
||
notiz TEXT,
|
||
PRIMARY KEY (eintrag_id, schluessel)
|
||
);
|
||
|
||
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
|
||
);
|
||
`);
|
||
|
||
/* DIE BEIDEN SPALTEN DES ÜBERGANGS (15.09.2026).
|
||
|
||
Sie stehen hier und nicht in der Umstellungsliste von workspace.js:
|
||
Die ueberspringt eine Tabelle stillschweigend, wenn es sie noch
|
||
nicht gibt -- und talent_stufe entsteht erst beim ersten Aufruf
|
||
dieser Seite, nicht beim Start. Hier, zwei Zeilen nach dem CREATE,
|
||
ist sie sicher da.
|
||
|
||
ON DELETE SET NULL beim Buddy: Wer geht, nimmt die Probe eines
|
||
anderen nicht mit. Die Karte verliert den Namen und faellt damit
|
||
genau dort auf, wo jemand einen neuen Buddy bestimmen muss. */
|
||
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"],
|
||
|
||
/* WER AUS DEM TALENT GEWORDEN IST (17.09.2026).
|
||
|
||
Der letzte Schritt des Trichters legt einen Zugang an -- und bis
|
||
heute war das eine Einbahnstrasse: Die Person entstand, der Code
|
||
wurde einmal gezeigt, und die Karte wusste hinterher NICHT, wer
|
||
daraus geworden ist.
|
||
|
||
Was dadurch nicht ging:
|
||
- von der Karte zur Person springen
|
||
- dem Neuen seine ersten Aufgaben aus der Karte heraus geben
|
||
- in einem halben Jahr nachsehen, aus welchem Kandidaten
|
||
eigentlich welches Teammitglied wurde
|
||
|
||
KEIN FREMDSCHLUESSEL AUF personen. Wird ein Zugang geloescht,
|
||
soll die Karte stehen bleiben -- sie erzaehlt, wie jemand
|
||
gekommen ist, und das bleibt wahr, auch wenn er wieder geht. Ein
|
||
CASCADE haette genau diesen Verlauf mitgeloescht. */
|
||
["person_id", "INTEGER"],
|
||
|
||
/* WER SICH UM DIESEN KANDIDATEN KUEMMERT (17.09.2026).
|
||
|
||
Dieselbe Luecke wie bei den Anfragen, nur teurer: Ein Kandidat,
|
||
fuer den sich niemand zustaendig fuehlt, bleibt auf seiner Stufe
|
||
liegen -- und die Standzeit an der Karte sagt zwar, DASS etwas
|
||
liegt, aber nicht, wer es aufheben sollte.
|
||
|
||
`zustaendig_seit` kommt mit: „seit gestern uebernommen“ und „seit
|
||
drei Wochen uebernommen" sind zwei verschiedene Auskuenfte, und
|
||
die zweite ist die, die man sehen will. */
|
||
["zustaendig_id", "INTEGER"],
|
||
["zustaendig_seit", "TEXT"],
|
||
]) {
|
||
try {
|
||
const da = db().prepare("PRAGMA table_info(talent_stufe)").all().map((s) => s.name);
|
||
if (!da.includes(spalte)) {
|
||
db().exec(`ALTER TABLE talent_stufe ADD COLUMN ${spalte} ${typ}`);
|
||
console.log(`[talente] Spalte '${spalte}' ergaenzt.`);
|
||
}
|
||
} catch (f) { console.error(`[talente] Spalte '${spalte}':`, f?.message); }
|
||
}
|
||
startaufnahme();
|
||
bereit = true;
|
||
}
|
||
|
||
/** Die Spur einmalig aus dem heutigen Stand fuellen (19.09.2026).
|
||
*
|
||
* WARUM UEBERHAUPT: Ohne sie waere die Auswertungsseite am ersten Tag
|
||
* leer, und zwar fuer Monate -- bis jemand genug Einschaetzungen
|
||
* geaendert hat. Eine Seite, die nach dem Bauen leer bleibt, sieht aus
|
||
* wie ein Fehler und wird kein zweites Mal geoeffnet.
|
||
*
|
||
* WARUM DAS EHRLICH IST: Jede Zeile in `entwicklung_stand` traegt ihr
|
||
* echtes `am` -- den Tag, an dem jemand wirklich geklickt hat. Es wird
|
||
* nichts erfunden, nur uebernommen, was ohnehin dasteht. Was sich
|
||
* NICHT rekonstruieren laesst -- ob vorher schon etwas anderes
|
||
* dastand --, bleibt NULL und traegt `nachgetragen = 1`. Die
|
||
* Auswertung zaehlt diese Zeilen ausdruecklich NICHT als Bewegung:
|
||
* Der Tag der Startaufnahme ist der Nullpunkt, nicht ein Tag, an dem
|
||
* etwas passiert ist.
|
||
*
|
||
* GENAU EINMAL: nur wenn die Spur leer ist. Ein zweiter Lauf traege
|
||
* jeden Stand ein zweites Mal ein, und ab da zaehlte jede Bewegung
|
||
* doppelt.
|
||
*
|
||
* UND SIE DARF NIE DEN SERVER AUFHALTEN. Schlaegt sie fehl, ist die
|
||
* Auswertung duenn -- das ist aergerlich. Wuerde dadurch `tabellen()`
|
||
* scheitern, waere die ganze Entwicklungsseite mit 503 tot, und das
|
||
* waere ungleich schlimmer. */
|
||
function startaufnahme() {
|
||
try {
|
||
const da = db().prepare("SELECT COUNT(*) AS n FROM entwicklung_lauf").get();
|
||
if (da.n > 0) return;
|
||
|
||
const staende = db().prepare(
|
||
`SELECT person_id, schluessel, von_id, stufe, am
|
||
FROM entwicklung_stand ORDER BY am`).all();
|
||
if (!staende.length) return;
|
||
|
||
const rein = db().prepare(
|
||
`INSERT INTO entwicklung_lauf
|
||
(person_id, schluessel, von_id, stufe, vorher, nachgetragen, am)
|
||
VALUES (?,?,?,?,NULL,1,?)`);
|
||
let n = 0;
|
||
for (const s of staende) {
|
||
/* DIE SELBSTAUSKUNFT FAELLT HERAUS. Block 5 ("Wie geht es dir
|
||
damit?") hat mit dem Befinden-Verlauf eine eigene Tabelle, die
|
||
niemand sonst liest. Hier waere sie eine Aussage, die ein
|
||
Mensch ueber sich selbst gemacht hat -- und die ploetzlich in
|
||
einer Auswertung fuer die Leitung stuende. */
|
||
if (!ENTWICKLUNG_FREMD.some((x) => x.schluessel === s.schluessel)) continue;
|
||
rein.run(s.person_id, s.schluessel, s.von_id, s.stufe, s.am);
|
||
n++;
|
||
}
|
||
if (n) console.log(`[entwicklung] Startaufnahme: ${n} Staende in die Spur uebernommen.`);
|
||
} catch (f) {
|
||
console.error("[entwicklung] Startaufnahme:", f?.message);
|
||
}
|
||
}
|
||
|
||
/** Eine Aenderung in der Spur festhalten.
|
||
*
|
||
* SIE DARF NIE DEN KLICK VERHINDERN. Geht hier etwas schief, ist die
|
||
* Auswertung lueckenhaft. Wuerde dadurch die Einschaetzung nicht
|
||
* gespeichert, waere die Arbeit weg. Deshalb faengt sie ihren Fehler
|
||
* selbst und meldet ihn nur ins Protokoll der Konsole. */
|
||
function spurLegen({ personId, schluessel, vonId, stufe, vorher }) {
|
||
try {
|
||
db().prepare(
|
||
`INSERT INTO entwicklung_lauf
|
||
(person_id, schluessel, von_id, stufe, vorher, nachgetragen, am)
|
||
VALUES (?,?,?,?,?,0,?)`)
|
||
.run(personId, schluessel, vonId, stufe, vorher ?? null, jetzt());
|
||
} catch (f) {
|
||
console.error("[entwicklung] Spur:", f?.message);
|
||
}
|
||
}
|
||
|
||
/* =====================================================================
|
||
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] || [] }));
|
||
}
|
||
|
||
/* ===== WELCHE PUNKTE GELTEN FUER DIESEN MENSCHEN? (25.09.2026) ======
|
||
|
||
Filipe: „ich will dass die rechte hand und ich diese aufgaben die es
|
||
da gibt, individuel aussuchen koennen wer welche aufgabe bekommt.
|
||
bis dahin sollen die keine aufgaben sehen."
|
||
|
||
EINE STELLE FUER DIE ANTWORT, drei Aufrufer: die Karte der Leitung,
|
||
die Karte des Menschen selbst und die Uebersicht mit den Zahlen.
|
||
Drei Abschriften waeren drei Gelegenheiten, dass eine davon beim
|
||
naechsten Umbau nicht mitzieht -- und dann stuende in der Uebersicht
|
||
„12 von 68", waehrend in der Karte elf Punkte stehen.
|
||
|
||
EIN SET UND KEINE LISTE: Gefragt wird 68-mal je Person („gehoert
|
||
dieser Punkt dazu?"). In einer Liste ist das 68 x N Vergleiche, im
|
||
Set 68 Nachschlagevorgaenge. */
|
||
function zugeteiltFuer(personId) {
|
||
return new Set(db().prepare(
|
||
"SELECT schluessel FROM entwicklung_zuteilung WHERE person_id = ?")
|
||
.all(personId).map((z) => z.schluessel));
|
||
}
|
||
|
||
/* Der Katalog, auf das eingegrenzt, was fuer DIESEN Menschen gilt.
|
||
`nurSelbst`-Bloecke bleiben unberuehrt: Das ist der Block, den die
|
||
Person selbst beantwortet (warum Moderatoren aufhoeren), er gehoert
|
||
niemandem sonst und ist nicht Teil der 68. */
|
||
function katalogFuerPerson(person, personId, ueberMichSelbst) {
|
||
const dabei = zugeteiltFuer(personId);
|
||
return katalogFuer(person, ueberMichSelbst)
|
||
.map((b) => (b.nurSelbst
|
||
? b
|
||
: { ...b, punkte: b.punkte.filter((pt) => dabei.has(pt.schluessel)) }))
|
||
.filter((b) => b.nurSelbst || b.punkte.length);
|
||
}
|
||
|
||
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 ${ROLLEN_SORTIERUNG}, 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. */
|
||
/* NUR, WAS ZUGETRAGEN IST (25.09.2026). Der Verbund mit der
|
||
Zuteilung ist der ganze Unterschied: Ohne ihn zaehlt die
|
||
Uebersicht Einschaetzungen zu Punkten mit, die dieser Mensch nie
|
||
bekommen hat -- und in der Karte daneben stuende eine kleinere
|
||
Zahl. Zwei Zahlen fuer dieselbe Sache, und die Uebersicht ist
|
||
die, die man zuerst sieht. */
|
||
const gesetzt = db().prepare(
|
||
`SELECT s.person_id, COUNT(*) AS n
|
||
FROM entwicklung_stand s
|
||
JOIN entwicklung_zuteilung z
|
||
ON z.person_id = s.person_id AND z.schluessel = s.schluessel
|
||
WHERE s.von_id = ? GROUP BY s.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]));
|
||
|
||
/* WIE VIELE PUNKTE GELTEN JE PERSON (25.09.2026). Ohne diese Zahl
|
||
stuende in der Uebersicht weiter „von 68" -- und in der Karte
|
||
daneben „von 12". Zwei Zahlen fuer dieselbe Sache, und die
|
||
Uebersicht ist die, die man zuerst sieht. */
|
||
const zugeteilt = db().prepare(
|
||
"SELECT person_id, COUNT(*) AS n FROM entwicklung_zuteilung GROUP BY person_id")
|
||
.all();
|
||
const zuteilZahl = new Map(zugeteilt.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,
|
||
/* Wie viele es im Katalog GIBT -- fuer die Auswahl, nicht fuer
|
||
die Zaehlung an der Kachel. Die steht seit heute auf dem, was
|
||
zugetragen ist (`zugeteilt` je Person). */
|
||
gesamt_fremd: ENTWICKLUNG_FREMD.length,
|
||
personen: personen.map((p) => ({
|
||
id: p.id, name: p.name,
|
||
rolle_name: ROLLEN_NAME[p.rolle] ?? p.rolle,
|
||
/* OB SIE EINE KATALOG-VORLAGE BEKOMMEN KANN -- vom Server
|
||
beantwortet, nicht im Browser nachgerechnet. Der Browser
|
||
kennt die Rolle nur als ANZEIGENAME ("Rechte Hand"); wer
|
||
danach entscheidet, entscheidet nach einem Text, den man
|
||
aendern darf. Und eine zweite Rollenliste im Browser waere
|
||
genau die Doppelung, die diesen Fehler erzeugt hat -- die
|
||
"An wen"-Reihe zeigte sechs Leute, beliefert wurden vier. */
|
||
kann_katalog: darfKatalogBekommen(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,
|
||
/* WIE VIELE PUNKTE IHM ZUGETRAGEN SIND (25.09.2026).
|
||
|
||
Filipe: „es sollen nur die menge angezeigt werden die wir
|
||
zutragen." Diese Zahl ist ab jetzt das GANZE der Kachel --
|
||
„3 von 12", nicht „3 von 68". `0` heisst ausdruecklich „noch
|
||
nichts zugetragen" und nicht „keine Angabe"; die Oberflaeche
|
||
sagt dann einen Satz statt einer Null. */
|
||
zugeteilt: zuteilZahl.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 schritte = schritteVon(p.id);
|
||
/* ==== KORRIGIERT NOCH AM SELBEN TAG (25.09.2026) ==============
|
||
|
||
Der erste Anlauf hat die Karte auf das Zugetragene eingegrenzt.
|
||
Filipe: „falsch, dogfather und die rechte hand sollen immer noch
|
||
die 68 sachen sehen wie vorher … aber unsere sicht von dogfather
|
||
und rechte hand soll sich nicht aendern."
|
||
|
||
ER HAT RECHT, und der Unterschied ist wichtig: Die 68 sind das,
|
||
woran IHR euch entlanghangelt, wenn ihr jemanden anseht -- eine
|
||
Liste, die sich je Person verkuerzt, waere eine andere Liste bei
|
||
jedem Menschen, und dann faellt kein Vergleich mehr auf.
|
||
|
||
ZUGETRAGEN WIRD ETWAS ANDERES: eine AUFGABE fuer diesen
|
||
Menschen. Das ist eine Auswahl AUS der Liste, nicht eine
|
||
Beschneidung der Liste. Deshalb steht hier wieder der ganze
|
||
Katalog -- und an jedem Punkt, ob er zugetragen ist.
|
||
|
||
GEZAEHLT WIRD TROTZDEM DAS ZUGETRAGENE (siehe unten bei
|
||
`gesetzt`/`gesamt`): „0 von 68" war die Zahl, die ihn gestoert
|
||
hat, und sie sagt bei jemandem mit drei Aufgaben nichts. */
|
||
const zugeteilt = zugeteiltFuer(id);
|
||
/* EINMAL, NICHT JE PUNKT. Beim ersten Anlauf stand der Aufruf
|
||
drinnen in der Schleife -- 68 Datenbankfragen fuer eine Zahl,
|
||
die sich waehrend eines Kartenaufrufs nicht aendert. */
|
||
const noetigeStimmen = beurteilerZahl(p.id);
|
||
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,
|
||
/* ==== SIEHT ER ES SCHON? (30.09.2026) =====================
|
||
|
||
VanVan aus dem Support: Sie hatte bewertet, und beim Modi
|
||
kam nichts an. Der Grund ist die Regel unten in
|
||
`meine-karte`: Ein Punkt erscheint bei ihm erst, wenn
|
||
ALLE Beurteiler gesetzt haben.
|
||
|
||
Die Regel bleibt -- eine einzelne Meinung soll bei einem
|
||
Menschen nicht als Urteil des Teams ankommen. Was fehlte,
|
||
war die Rueckmeldung an die, die schon gesetzt haben:
|
||
VanVan hat getan, was sie tun sollte, und nichts auf
|
||
ihrem Bildschirm sagte ihr, dass es liegen bleibt.
|
||
|
||
GEZAEHLT WIRD GEGEN DIESELBE LISTE wie dort, nicht gegen
|
||
eine eigene -- sonst sagen die beiden Seiten irgendwann
|
||
Verschiedenes ueber denselben Punkt. */
|
||
sieht_er: (() => {
|
||
const wieViele = (m ? 1 : 0)
|
||
+ fremde.filter((f) => f.schluessel === pt.schluessel).length;
|
||
return { da: wieViele, noetig: noetigeStimmen };
|
||
})(),
|
||
/* DER SCHRITT ZU DIESEM PUNKT. Er steht an der Karte und
|
||
nicht auf einer eigenen Liste: Wer die Beobachtung sieht,
|
||
soll im selben Blick sehen, ob daraus etwas geworden ist. */
|
||
schritt: schritte.get(pt.schluessel) || null,
|
||
/* IST ER DIESEM MENSCHEN ZUGETRAGEN? (25.09.2026) Der Punkt
|
||
bleibt sichtbar -- die Marke sagt, dass er ihm als Aufgabe
|
||
gegeben wurde. */
|
||
zugetragen: zugeteilt.has(pt.schluessel),
|
||
};
|
||
}),
|
||
}));
|
||
|
||
/* 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,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
/* WIE DIESER MENSCH GEKOMMEN IST (17.09.2026).
|
||
|
||
Die Talentkarte sagt seit heute, wer aus ihr geworden ist. Die
|
||
Gegenrichtung fehlte: Wer hier eine Karte aufschlaegt, sieht
|
||
Namen und Punkte -- aber nicht, dass derselbe Mensch vier Wochen
|
||
lang beobachtet wurde, wer ihn eingearbeitet hat und was dabei
|
||
aufgefallen ist.
|
||
|
||
Das ist keine Nebensaechlichkeit. Beim ersten Beurteilen ist
|
||
genau das die Frage: „Woher kommt der eigentlich?“ -- und wer sie
|
||
nicht beantwortet bekommt, faengt bei null an, obwohl vier Wochen
|
||
Beobachtung vorliegen.
|
||
|
||
NUR DER HINWEIS, NICHT DIE DATEN. Die Merkmale aus dem Trichter
|
||
hierher zu kopieren waere bequem und falsch: Es sind zwei
|
||
verschiedene Kataloge mit zwei verschiedenen Fragen (taugt er? /
|
||
wie entwickelt er sich?). Eine Zahl aus dem einen im anderen
|
||
sieht aus wie eine Beurteilung und ist keine. Hier steht der
|
||
WEG dorthin -- lesen kann man ihn selbst. */
|
||
const herkunft = db().prepare(
|
||
`SELECT e.id, e.titel, s.stufe, s.seit, b.name AS buddy_name
|
||
FROM talent_stufe s
|
||
JOIN eintraege e ON e.id = s.eintrag_id
|
||
LEFT JOIN personen b ON b.id = s.buddy_id
|
||
WHERE s.person_id = ? AND e.bereich = 'talente'
|
||
ORDER BY s.seit DESC LIMIT 1`).get(p.id);
|
||
|
||
res.json({
|
||
person: { id: p.id, name: p.name, rolle_name: ROLLEN_NAME[p.rolle] ?? p.rolle },
|
||
/* Null, wenn niemand die Karte verknuepft hat -- und das ist der
|
||
Normalfall fuer alle, die vor dem 17.09. ins Team kamen. Eine
|
||
erfundene Herkunft waere schlimmer als keine. */
|
||
herkunft: herkunft ? {
|
||
eintrag_id: herkunft.id,
|
||
titel: herkunft.titel,
|
||
seit: herkunft.seit,
|
||
buddy_name: herkunft.buddy_name || null,
|
||
} : null,
|
||
stufen: ENTWICKLUNG_STUFEN,
|
||
erwartung: ENTWICKLUNG_ERWARTUNG,
|
||
bloecke,
|
||
abweichungen,
|
||
/* „X VON Y" ZAEHLT DAS ZUGETRAGENE (25.09.2026).
|
||
|
||
Filipe: „es sollen nur die menge angezeigt werden die wir
|
||
zutragen und erledigte dan auch nur die die erledigt wurden
|
||
von den zugetragenen."
|
||
|
||
Vorher stand hier `ENTWICKLUNG_FREMD.length` -- alle 68, fuer
|
||
jeden dieselbe Zahl, egal wie viel ihm ueberhaupt aufgetragen
|
||
wurde. „0 von 68" sagt bei jemandem mit drei Aufgaben nichts
|
||
und entmutigt bei jedem.
|
||
|
||
UND DIE LINKE ZAHL EBENSO: Gezaehlt wird, was von den
|
||
ZUGETRAGENEN erledigt ist. Wer frueher zu einem Punkt gesetzt
|
||
hat, der ihm nie zugetragen wurde, haette sonst „13 von 3"
|
||
bekommen -- eine Zahl, die groesser ist als ihr Ganzes, und
|
||
die niemand mehr erklaeren kann. */
|
||
gesetzt: bloecke.reduce((n, b) => n
|
||
+ b.punkte.filter((pt) => pt.zugetragen && pt.meins).length, 0),
|
||
gesamt: zugeteilt.size,
|
||
/* Wie viele es insgesamt GAEBE -- die Oberflaeche sagt damit
|
||
„12 von 68 ausgewaehlt", ohne die Liste selbst zu kennen. */
|
||
gesamt_katalog: ENTWICKLUNG_FREMD.length,
|
||
darf_zuteilen: true,
|
||
});
|
||
} catch (f) {
|
||
console.error("[entwicklung] Person:", f?.message);
|
||
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
||
}
|
||
});
|
||
|
||
/* =====================================================================
|
||
AUS EINER BEOBACHTUNG WIRD EIN SCHRITT (15.09.2026)
|
||
|
||
Filipe: „ich will die noch viel besser, perfektionniert […] so
|
||
einfach wie möglich für jeden."
|
||
|
||
---------------------------------------------------------------------
|
||
WAS GEFEHLT HAT
|
||
|
||
Die Karte konnte bisher genau eines: festhalten, dass etwas hakt --
|
||
mit Anlass, das war schon richtig. Danach passierte nichts. Beim
|
||
nächsten Öffnen stand dieselbe Beobachtung da, nur älter.
|
||
|
||
Die Quellen zur laufenden Entwicklungsbegleitung sagen übereinstimmend
|
||
zweierlei: weg von der Bewertung, hin zum Gespräch -- und ein
|
||
Entwicklungsplan wirkt dann, wenn er an einer ECHTEN Aufgabe hängt.
|
||
Nicht „daran arbeiten wir“, sondern etwas, das auf dem Aufgabenbrett
|
||
steht, einen Verantwortlichen hat und irgendwann erledigt ist.
|
||
|
||
Genau dieses Muster gibt es im Haus schon: Aus einer Idee auf einem
|
||
Brett wird eine Aufgabe. Hier ist es dasselbe, nur mit einem anderen
|
||
Ausgangspunkt -- und deshalb sieht es auch gleich aus.
|
||
|
||
---------------------------------------------------------------------
|
||
UND DANN SCHLIESST SICH DER KREIS
|
||
|
||
Ist der Schritt erledigt, sagt die Karte das und fragt dieselbe Frage
|
||
noch einmal. Das ist der Rhythmus, den die Quellen meinen: nicht
|
||
einmal im Jahr eine Bewertung, sondern eine Runde, die zu Ende geht.
|
||
|
||
Quellen:
|
||
https://www.quantumworkplace.com/future-of-work/continuous-performance-management
|
||
https://www.aihr.com/blog/continuous-performance-management/
|
||
https://www.deel.com/blog/implement-continuous-performance-management-system/
|
||
===================================================================== */
|
||
|
||
/** Wie lange ein Schritt Zeit bekommt. Vorschlag, kein Gesetz --
|
||
* auf dem Aufgabenbrett lässt sich die Frist ändern wie bei jeder
|
||
* anderen Aufgabe auch. Zwei Wochen sind nah genug, dass man sich
|
||
* erinnert, und weit genug, dass sich etwas ändern kann. */
|
||
export const SCHRITT_TAGE = 14;
|
||
|
||
/** Wie viele Menschen urteilen ueber diese Person?
|
||
*
|
||
* AN EINER STELLE (30.09.2026). Dieselbe Frage wird an zwei Enden
|
||
* gestellt: `meine-karte` entscheidet damit, ob ein Punkt bei dem
|
||
* Menschen ueberhaupt erscheint, und die Karte der Leitung sagt
|
||
* damit, ob er ihn schon sieht. Zwei Abschriften waeren die, die
|
||
* irgendwann Verschiedenes ueber denselben Punkt sagen.
|
||
*
|
||
* ALLE AUSSER IHM SELBST: Wer sich selbst einschaetzt, ist keine
|
||
* zweite Meinung. */
|
||
function beurteilerZahl(personId) {
|
||
return db().prepare(
|
||
"SELECT COUNT(*) AS n FROM personen WHERE rolle IN ('admin','hand') AND aktiv = 1 AND id <> ?")
|
||
.get(personId)?.n || 0;
|
||
}
|
||
|
||
/** Die Schritte zu den Punkten einer Person — einer je Punkt.
|
||
*
|
||
* ES IST IMMER DER JÜNGSTE. Wer denselben Punkt ein zweites Mal
|
||
* angeht, soll den neuen Schritt sehen und nicht den von vor einem
|
||
* halben Jahr. */
|
||
function schritteVon(personId) {
|
||
const zeilen = db().prepare(
|
||
`SELECT id, titel, status, frist, erledigt_am, aus_punkt, erstellt
|
||
FROM aufgaben
|
||
WHERE verantwortlich_id = ? AND aus_punkt IS NOT NULL
|
||
ORDER BY id DESC`).all(personId);
|
||
const nach = new Map();
|
||
for (const z of zeilen) if (!nach.has(z.aus_punkt)) nach.set(z.aus_punkt, z);
|
||
return nach;
|
||
}
|
||
|
||
/* =====================================================================
|
||
WELCHE PUNKTE GELTEN FUER DIESEN MENSCHEN -- LESEN UND SETZEN
|
||
=====================================================================
|
||
|
||
Filipe: „ich will dass die rechte hand und ich diese aufgaben die es
|
||
da gibt, individuel aussuchen koennen wer welche aufgabe bekommt."
|
||
|
||
ZWEI WEGE UND NICHT EINER MIT SCHALTER: Lesen liefert den GANZEN
|
||
Katalog mit einem Haken an dem, was gilt -- man waehlt nur aus, was
|
||
man sieht. Die Karte selbst liefert weiterhin nur das Zugeteilte;
|
||
die zwei Fragen sind verschieden und bleiben getrennt.
|
||
|
||
GESETZT WIRD DIE GANZE LISTE AUF EINMAL, nicht Punkt fuer Punkt. Wer
|
||
zwoelf Haken setzt und dabei die Verbindung verliert, haette sonst
|
||
sieben gesetzte und fuenf verlorene -- und saehe nicht, welche. So
|
||
ist es eine Entscheidung, und sie gilt ganz oder gar nicht.
|
||
===================================================================== */
|
||
entwicklungRouter.get("/workspace/api/entwicklung/person/:id/punkte",
|
||
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 dabei = zugeteiltFuer(id);
|
||
res.json({
|
||
person: { id: p.id, name: p.name, rolle_name: ROLLEN_NAME[p.rolle] ?? p.rolle },
|
||
bloecke: katalogFuer(req.person, false).map((b) => ({
|
||
schluessel: b.schluessel, name: b.name, text: b.text,
|
||
punkte: b.punkte.map((pt) => ({
|
||
schluessel: pt.schluessel, titel: pt.titel, text: pt.text || null,
|
||
stufe: pt.stufe, dabei: dabei.has(pt.schluessel),
|
||
})),
|
||
})),
|
||
});
|
||
} catch (f) {
|
||
console.error("[entwicklung] Punkte lesen:", f?.message);
|
||
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
||
}
|
||
});
|
||
|
||
/* ==== EINEN EINZELNEN PUNKT ZUTRAGEN (25.09.2026) ==================
|
||
|
||
Filipe: „ich will sofort über diese aufgaben da spezifisch zuteilen
|
||
kann bitte. bei allen personnen."
|
||
|
||
DAS AUSWAHLFENSTER BLEIBT -- es ist der Weg, wenn man jemanden neu
|
||
einrichtet und zwoelf Punkte auf einmal vergibt. Dieser Weg hier ist
|
||
der andere Fall, und er ist der haeufigere: Man liest eine
|
||
Beobachtung, denkt „das soll er machen", und will es in dem Moment
|
||
erledigen -- nicht ein Fenster aufmachen, in einer Liste von 68
|
||
denselben Punkt suchen und wieder zumachen.
|
||
|
||
EIN PUNKT, EIN AUFRUF. Anders als beim Fenster, das die ganze Liste
|
||
setzt: Hier geht es um EINE Entscheidung, und die soll auch dann
|
||
ankommen, wenn zwei Leute gleichzeitig an derselben Karte sitzen.
|
||
Wuerde dieser Weg die ganze Liste schicken, loeschte er die
|
||
Zutragung, die der andere eine Sekunde vorher gemacht hat.
|
||
|
||
`INSERT OR IGNORE` / `DELETE`: Zweimal zutragen ist kein Fehler,
|
||
zweimal wegnehmen auch nicht. Wer zweimal tippt (oder zwei Fenster
|
||
offen hat), bekommt denselben Zustand, nicht einen Fehler.
|
||
================================================================== */
|
||
entwicklungRouter.put("/workspace/api/entwicklung/person/:id/punkt/:schluessel",
|
||
gleicheHerkunft, nurLeitung, express.json({ limit: "4kb" }), (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 schluessel = String(req.params.schluessel || "");
|
||
/* NUR EIN SCHLUESSEL, DEN ES WIRKLICH GIBT -- sonst liesse sich
|
||
ueber diesen Weg beliebiger Text in die Tabelle schreiben, der
|
||
danach in keiner Liste steht und auch nicht wegzubekommen
|
||
ist. Dieselbe Pruefung wie beim Fenster. */
|
||
if (!ENTWICKLUNG_FREMD.some((pt) => pt.schluessel === schluessel)) {
|
||
/* EIN SATZ, KEINE KENNUNG (25.09.2026, gefunden von
|
||
pruef-meldungen). Das Haus verlangt zu jeder Kennung einen
|
||
Satz in meldung.js -- sonst steht auf dem Bildschirm ein
|
||
Wort mit Unterstrich. Diesen Fall trifft ein Mensch nie (die
|
||
Oberfläche schickt nur Schlüssel aus ihrer eigenen Liste),
|
||
aber „trifft nie ein" ist genau die Begründung, mit der
|
||
solche Stellen stehen bleiben, bis sie doch jemand sieht. */
|
||
return res.status(400).json({
|
||
fehler: "Diesen Punkt gibt es nicht – lade die Seite neu.",
|
||
});
|
||
}
|
||
const an = req.body?.an === true;
|
||
if (an) {
|
||
db().prepare(`INSERT INTO entwicklung_zuteilung
|
||
(person_id, schluessel, seit, von_id) VALUES (?,?,?,?)
|
||
ON CONFLICT(person_id, schluessel) DO NOTHING`)
|
||
.run(id, schluessel, jetzt(), req.person.id);
|
||
} else {
|
||
db().prepare(
|
||
"DELETE FROM entwicklung_zuteilung WHERE person_id = ? AND schluessel = ?")
|
||
.run(id, schluessel);
|
||
}
|
||
|
||
protokolliere(an ? "entwicklung_punkt_zugetragen" : "entwicklung_punkt_zurueck", {
|
||
personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req),
|
||
detail: `${p.name}: ${schluessel}`.slice(0, 120),
|
||
});
|
||
/* DIE NEUE ZAHL KOMMT MIT. Die Oberflaeche zeigt „3 von 12
|
||
erledigt" -- rechnete sie selbst weiter, waere sie bei zwei
|
||
offenen Fenstern falsch, und niemand saehe, warum. */
|
||
const gesamt = db().prepare(
|
||
"SELECT COUNT(*) AS n FROM entwicklung_zuteilung WHERE person_id = ?").get(id).n;
|
||
res.json({ ok: true, zugetragen: an, gesamt });
|
||
} catch (f) {
|
||
console.error("[entwicklung] Punkt zutragen:", f?.message);
|
||
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
||
}
|
||
});
|
||
|
||
entwicklungRouter.put("/workspace/api/entwicklung/person/:id/punkte",
|
||
gleicheHerkunft, nurLeitung, express.json({ limit: "32kb" }), (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" });
|
||
}
|
||
/* NUR SCHLUESSEL, DIE ES WIRKLICH GIBT. Ohne diesen Abgleich
|
||
liesse sich ueber diesen Weg beliebiger Text in die Tabelle
|
||
schreiben -- und er stuende danach in keiner Liste, waere aber
|
||
auch nicht wegzubekommen. */
|
||
const erlaubt = new Set(ENTWICKLUNG_FREMD.map((pt) => pt.schluessel));
|
||
const wunsch = Array.isArray(req.body?.punkte) ? req.body.punkte : null;
|
||
if (!wunsch) return res.status(400).json({ fehler: "ungueltig" });
|
||
const neu = [...new Set(wunsch.map(String).filter((s) => erlaubt.has(s)))];
|
||
|
||
const nun = jetzt();
|
||
/* EINE TRANSAKTION. Zwischen „alles weg" und „neu eintragen"
|
||
darf es keinen Moment geben, in dem die Person gar nichts hat
|
||
-- sonst saehe ein gleichzeitiger Abruf eine leere Karte, und
|
||
bei einem Fehler dazwischen bliebe sie leer. */
|
||
db().exec("BEGIN");
|
||
try {
|
||
db().prepare("DELETE FROM entwicklung_zuteilung WHERE person_id = ?").run(id);
|
||
const rein = db().prepare(`INSERT INTO entwicklung_zuteilung
|
||
(person_id, schluessel, seit, von_id) VALUES (?,?,?,?)`);
|
||
for (const s of neu) rein.run(id, s, nun, req.person.id);
|
||
db().exec("COMMIT");
|
||
} catch (f) {
|
||
db().exec("ROLLBACK");
|
||
throw f;
|
||
}
|
||
|
||
protokolliere("entwicklung_punkte_zugeteilt", {
|
||
personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req),
|
||
detail: `${p.name}: ${neu.length} Punkte`.slice(0, 120),
|
||
});
|
||
res.json({ ok: true, zugeteilt: neu.length });
|
||
} catch (f) {
|
||
console.error("[entwicklung] Punkte setzen:", f?.message);
|
||
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
||
}
|
||
});
|
||
|
||
entwicklungRouter.post("/workspace/api/entwicklung/person/:id/:schluessel/schritt",
|
||
gleicheHerkunft, nurLeitung, express.json({ limit: "8kb" }), (req, res) => {
|
||
try {
|
||
const id = nummer(req.params.id);
|
||
const schluessel = String(req.params.schluessel || "");
|
||
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 punkt = ENTWICKLUNG_FREMD.find((x) => x.schluessel === schluessel);
|
||
if (!punkt) return res.status(404).json({ fehler: "nicht_gefunden" });
|
||
|
||
/* NUR AUS EINEM „DA HAKT ES“ WIRD EIN SCHRITT -- und nur aus dem
|
||
EIGENEN. Aus „läuft“ einen Schritt zu machen hieße, jemandem
|
||
Arbeit zu geben für etwas, das gut läuft; aus der Beobachtung
|
||
eines anderen hieße, in seinem Namen zu handeln. */
|
||
const stand = db().prepare(
|
||
`SELECT stufe, anlass FROM entwicklung_stand
|
||
WHERE person_id = ? AND schluessel = ? AND von_id = ?`)
|
||
.get(id, schluessel, req.person.id);
|
||
if (!stand || stand.stufe !== "hakt") {
|
||
return res.status(400).json({ fehler: "kein_hakt" });
|
||
}
|
||
|
||
/* EIN OFFENER SCHRITT REICHT. Zwei Aufgaben zum selben Punkt
|
||
wären zwei Erinnerungen an dieselbe Sache -- und die zweite
|
||
macht die erste wertlos. */
|
||
const offen = db().prepare(
|
||
`SELECT id FROM aufgaben
|
||
WHERE verantwortlich_id = ? AND aus_punkt = ?
|
||
AND status NOT IN ('erledigt','abgebrochen')`).get(id, schluessel);
|
||
if (offen) return res.status(400).json({ fehler: "schon_da", aufgabe_id: offen.id });
|
||
|
||
const frist = new Date(Date.now() + SCHRITT_TAGE * 86400000).toISOString().slice(0, 10);
|
||
const jetztIso = jetzt();
|
||
db().prepare(`INSERT INTO aufgaben
|
||
(titel, beschreibung, status, prioritaet, verantwortlich_id,
|
||
frist, erstellt, erstellt_von, aus_punkt)
|
||
VALUES (?,?,'offen','mittel',?,?,?,?,?)`)
|
||
.run(
|
||
punkt.titel,
|
||
/* DER ANLASS WANDERT MIT. Ohne ihn stünde auf dem Brett ein
|
||
Satz ohne Anlass, und die Aufgabe wäre ein Vorwurf. */
|
||
`${punkt.text}\n\nWoran es aufgefallen ist: ${stand.anlass || "–"}`,
|
||
id, frist, jetztIso, req.person.id, schluessel);
|
||
const neu = db().prepare("SELECT last_insert_rowid() AS id").get().id;
|
||
|
||
protokolliere("entwicklung_schritt", {
|
||
personId: req.person.id, ip: echteIp(req),
|
||
/* KEIN ANLASS IM PROTOKOLL. Dass ein Schritt entstanden ist,
|
||
gehört hinein; woran jemand etwas gemerkt hat, nicht. */
|
||
detail: `${punkt.titel} für ${p.name}`.slice(0, 120),
|
||
});
|
||
res.status(201).json({ id: neu, titel: punkt.titel, frist });
|
||
} catch (f) {
|
||
console.error("[entwicklung] Schritt:", 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.",
|
||
});
|
||
}
|
||
|
||
/* WAS VORHER DASTAND -- VOR dem Schreiben gelesen.
|
||
Danach ist es weg: Die Tabelle ueberschreibt. Genau deshalb
|
||
gibt es die Spur, und genau deshalb steht diese Zeile hier
|
||
oben und nicht unten. */
|
||
const vorher = db().prepare(
|
||
`SELECT stufe FROM entwicklung_stand
|
||
WHERE person_id = ? AND schluessel = ? AND von_id = ?`)
|
||
.get(id, schluessel, req.person.id)?.stufe || null;
|
||
|
||
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());
|
||
|
||
/* ERST SPEICHERN, DANN MITSCHREIBEN. Die Reihenfolge ist die
|
||
Aussage: Wenn die Spur scheitert, fehlt eine Zeile in einer
|
||
Auswertung. Wenn das Speichern scheitert, ist die Arbeit weg. */
|
||
spurLegen({
|
||
personId: id, schluessel, vonId: req.person.id, stufe, vorher,
|
||
});
|
||
|
||
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 eigene Karte ------------------------------------------
|
||
|
||
Filipes Frage aus dem Plan, Punkt 2: "Sieht die Person ihre eigene
|
||
Entwicklungskarte?" -- und die Antwort war ja, aber unter zwei
|
||
Bedingungen. Beide sind hier gebaut, und beide sind der Grund, warum
|
||
es ueberhaupt geht:
|
||
|
||
ERST, WENN ALLE GESETZT HABEN. Eine halbe Karte ist schlimmer als
|
||
keine: Wer sieht, dass einer "da hakt es" gesetzt hat und der andere
|
||
noch nichts, liest daraus einen Streit, den es nicht gibt.
|
||
|
||
WER "ALLE" IST, wird gerechnet, nicht angenommen: alle aktiven
|
||
Personen, die das Team fuehren -- OHNE die Person selbst. Sonst
|
||
muesste die rechte Hand sich erst selbst beurteilen, um zu sehen,
|
||
was DogFather ueber sie denkt.
|
||
|
||
OHNE NAMEN UND OHNE ANLASS. Sie sieht "2x Laeuft" oder "1x Laeuft,
|
||
1x Waechst gerade" -- nicht, wer was gesetzt hat, und nicht, was
|
||
danebengeschrieben steht. Der Anlass ist fuer ein Gespraech
|
||
aufgeschrieben, nicht zum Lesen ohne den Menschen dazu. Genau
|
||
deshalb steht auf der Seite der Satz, dass man danach fragen soll.
|
||
|
||
UND WER GAR KEINE KARTE HAT, bekommt das gesagt. DogFather steht
|
||
nicht in KARTEN_ROLLEN -- eine leere Seite waere fuer ihn ein
|
||
Fehler, ein Satz ist eine Auskunft.
|
||
------------------------------------------------------------------- */
|
||
entwicklungRouter.get("/workspace/api/entwicklung/meine-karte", (req, res) => {
|
||
try {
|
||
if (!KARTEN_ROLLEN.includes(req.person.rolle)) {
|
||
return res.json({ hat_karte: false });
|
||
}
|
||
|
||
/* Wer ueber diese Person urteilt -- alle ausser ihr selbst.
|
||
DIE ZAHL KOMMT AUS `beurteilerZahl()`, die Liste von hier: Die
|
||
Karte der Leitung stellt dieselbe Frage, und zwei Abschriften
|
||
waeren die, die irgendwann Verschiedenes sagen. */
|
||
const beurteiler = db().prepare(
|
||
"SELECT id FROM personen WHERE rolle IN ('admin','hand') AND aktiv = 1 AND id <> ?")
|
||
.all(req.person.id).map((z) => z.id);
|
||
|
||
const staende = db().prepare(
|
||
"SELECT schluessel, von_id, stufe, am FROM entwicklung_stand WHERE person_id = ?")
|
||
.all(req.person.id).filter((z) => beurteiler.includes(z.von_id));
|
||
|
||
const nachPunkt = new Map();
|
||
for (const z of staende) {
|
||
if (!nachPunkt.has(z.schluessel)) nachPunkt.set(z.schluessel, []);
|
||
nachPunkt.get(z.schluessel).push(z);
|
||
}
|
||
|
||
/* ==== WELCHE PUNKTE SIND SEINE AUFGABE? (30.09.2026) =========
|
||
|
||
VanVan aus dem Support: „dann sieht der Modi zwar die
|
||
Auswertung im Bereich Entwicklung, aber bei ihm taucht nichts
|
||
im Bereich eure Aufgaben unten in der Karte auf."
|
||
|
||
Nachgemessen war es genau so: Seine Karte lieferte alle 68
|
||
Punkte des Katalogs und kein Wort darueber, welche davon ihm
|
||
zugetragen wurden. Dabei ist das die eigentliche Auskunft --
|
||
die Bewertung ist die Folge, nicht der Gegenstand. Wer nicht
|
||
weiss, was seine Aufgaben sind, kann sie auch nicht
|
||
angehen. */
|
||
const meineAufgaben = zugeteiltFuer(req.person.id);
|
||
|
||
const bloecke = ENTWICKLUNG_BLOECKE
|
||
.filter((b) => !b.nurSelbst)
|
||
.map((b) => ({
|
||
schluessel: b.schluessel, name: b.name, text: b.text,
|
||
punkte: (ENTWICKLUNG_PUNKTE[b.schluessel] || []).map((pt) => {
|
||
const liste = nachPunkt.get(pt.schluessel) || [];
|
||
/* VOLLSTAENDIG heisst: jeder Beurteiler hat gesetzt. */
|
||
const fertig = beurteiler.length > 0 && liste.length >= beurteiler.length;
|
||
/* DIE ZUTEILUNG HAENGT NICHT AN DER BEWERTUNG. Sie geht
|
||
auch dann mit, wenn noch niemand etwas gesetzt hat --
|
||
sonst saehe er seine Aufgabe erst, wenn sie schon
|
||
beurteilt ist, und das ist die falsche Reihenfolge. */
|
||
const zugetragen = meineAufgaben.has(pt.schluessel);
|
||
if (!fertig) {
|
||
return {
|
||
schluessel: pt.schluessel, titel: pt.titel, stufe: pt.stufe,
|
||
offen: true, zugetragen,
|
||
/* WIE VIELE NOCH FEHLEN. „Noch nichts fertig" allein
|
||
sieht aus wie ein Fehler; „einer von zwei hat
|
||
gesetzt" ist eine Auskunft. */
|
||
davon_da: liste.length,
|
||
};
|
||
}
|
||
const zaehler = {};
|
||
for (const z of liste) zaehler[z.stufe] = (zaehler[z.stufe] || 0) + 1;
|
||
return {
|
||
schluessel: pt.schluessel, titel: pt.titel, stufe: pt.stufe, offen: false,
|
||
zugetragen,
|
||
davon_da: liste.length,
|
||
/* Nach der Reihenfolge des Katalogs, nicht nach Haeufigkeit
|
||
-- sonst stuende bei einer Abweichung mal das eine, mal
|
||
das andere zuerst, und das liest sich wie eine Wertung. */
|
||
antworten: ENTWICKLUNG_STUFEN
|
||
.filter((x) => zaehler[x.schluessel])
|
||
.map((x) => ({ stufe: x.schluessel, name: x.name, anzahl: zaehler[x.schluessel] })),
|
||
einig: new Set(liste.map((z) => z.stufe)).size === 1,
|
||
am: liste.map((z) => z.am).sort().at(-1) || null,
|
||
};
|
||
}),
|
||
}));
|
||
|
||
const alle = bloecke.flatMap((b) => b.punkte);
|
||
res.json({
|
||
hat_karte: true,
|
||
beurteiler: beurteiler.length,
|
||
fertig: alle.filter((x) => !x.offen).length,
|
||
gesamt: alle.length,
|
||
/* WIE VIELE PUNKTE SEINE AUFGABE SIND. Die Zahl steht oben an
|
||
der Karte -- „2 Aufgaben" ist etwas anderes als „0 von 68",
|
||
und das zweite hat Filipe schon einmal zu Recht gestoert. */
|
||
meine_aufgaben: alle.filter((x) => x.zugetragen).length,
|
||
stufen: ENTWICKLUNG_STUFEN,
|
||
erwartung: ENTWICKLUNG_ERWARTUNG,
|
||
bloecke,
|
||
});
|
||
} catch (f) {
|
||
console.error("[entwicklung] Meine Karte:", 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
|
||
===================================================================== */
|
||
|
||
/** Ein Tagesdatum in Ortszeit, YYYY-MM-DD.
|
||
*
|
||
* NICHT toISOString().slice(0,10) -- das rechnet in UTC, und zwischen
|
||
* Mitternacht und zwei Uhr morgens liefert es in Deutschland den
|
||
* Vortag. Genau diese Zeile steht als offener Befund in pruef-struktur. */
|
||
function tagIn(d = new Date()) {
|
||
return new Intl.DateTimeFormat("sv-SE", { timeZone: "Europe/Berlin" }).format(d);
|
||
}
|
||
|
||
/** Wie viele Tage liegen zwischen heute und einem Tagesdatum?
|
||
* Negativ heisst: vorbei. */
|
||
function tageBis(tag) {
|
||
if (!tag) return null;
|
||
const heute = Date.parse(tagIn() + "T12:00:00Z");
|
||
const ziel = Date.parse(tag + "T12:00:00Z");
|
||
if (!Number.isFinite(ziel)) return null;
|
||
return Math.round((ziel - heute) / 86400000);
|
||
}
|
||
|
||
/** Der Übergang: ein Buddy und ein Datum — mehr braucht die Probe nicht.
|
||
*
|
||
* ER WIRD ABGELEITET, NICHT GESPEICHERT. Ein Satz wie „die erste
|
||
* Schicht war vor drei Tagen" in der Datenbank waere ab morgen falsch.
|
||
* Aus zwei Feldern wird hier jedes Mal neu gerechnet, was gerade gilt. */
|
||
function uebergangVon(stand) {
|
||
if (!stand?.erste_schicht && !stand?.buddy_id) return null;
|
||
const buddy = stand.buddy_id
|
||
? db().prepare("SELECT name, rolle, aktiv FROM personen WHERE id = ?").get(stand.buddy_id)
|
||
: null;
|
||
const bis = tageBis(stand.erste_schicht);
|
||
return {
|
||
buddy_id: stand.buddy_id || null,
|
||
buddy_name: buddy?.name || null,
|
||
/* WER GEGANGEN IST, IST KEIN BUDDY MEHR -- und zwar auf beiden
|
||
Wegen, auf denen jemand gehen kann.
|
||
|
||
Der erste Entwurf fragte "`buddy_id` gesetzt UND die Person weg".
|
||
Damit war der zweite Weg blind: Wird ein Mensch GELOESCHT, setzt
|
||
ON DELETE SET NULL die Spalte auf NULL -- die Bedingung war
|
||
falsch, die Karte sah unauffaellig aus, und eine laufende Probe
|
||
stand ohne jeden Buddy da. Gefunden hat das die Pruefung, nicht
|
||
das Lesen; sie loescht die Person wirklich.
|
||
|
||
Richtig ist die Frage, auf die es ankommt: Gibt es jemanden, der
|
||
gerade einarbeitet? */
|
||
buddy_weg: !buddy?.aktiv,
|
||
erste_schicht: stand.erste_schicht || null,
|
||
bis_schicht: bis,
|
||
/* Drei Lagen, drei Saetze -- derselbe Grund wie bei der Ampel:
|
||
ein Satz fuer alle ergibt bei der Haelfte falsches Deutsch. */
|
||
satz: !stand.erste_schicht ? null
|
||
: bis > SCHICHT_WEIT_TAGE
|
||
? `Erste Schicht in ${bis} Tagen. Das ist weit weg – bis dahin hört man nichts `
|
||
+ "voneinander, und genau da springen die meisten ab."
|
||
: bis > 1 ? `Erste Schicht in ${bis} Tagen.`
|
||
: bis === 1 ? "Erste Schicht ist morgen."
|
||
: bis === 0 ? "Erste Schicht ist heute."
|
||
: bis === -1 ? "Erste Schicht war gestern."
|
||
: `Erste Schicht war vor ${-bis} Tagen.`,
|
||
};
|
||
}
|
||
|
||
/** Die Karte eines Kandidaten -- Stufe, Standzeit, was zu tun ist. */
|
||
function talentKarte(e, merkmale) {
|
||
const stand = db().prepare(
|
||
`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,
|
||
s.person_id, w.name AS person_name, w.aktiv AS person_aktiv,
|
||
s.zustaendig_id, s.zustaendig_seit, z.name AS zustaendig_name
|
||
FROM talent_stufe s
|
||
LEFT JOIN personen p ON p.id = s.beendet_von
|
||
LEFT JOIN personen w ON w.id = s.person_id
|
||
LEFT JOIN personen z ON z.id = s.zustaendig_id
|
||
WHERE s.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. */
|
||
/* 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,
|
||
/* DER ÜBERGANG. Er steht an der Karte und nicht in einem eigenen
|
||
Abruf: Er aendert sich mit jedem Tag, und was man extra holen
|
||
muss, ist beim Holen schon veraltet. */
|
||
uebergang: uebergangVon(stand),
|
||
/* DER VORLAGENTEXT -- gebaut aus den Merkmalen, die wirklich
|
||
angeklickt wurden. Er kommt MIT der Karte und nicht ueber einen
|
||
eigenen Weg: Er aendert sich mit jedem Klick, und ein Text, den
|
||
man extra abrufen muss, ist beim Abrufen schon veraltet. */
|
||
ansprechtext: ansprechtext(e.titel, gesetzt),
|
||
/* WER WAS GESEHEN HAT (17.09.2026).
|
||
|
||
Filipe: "analysiert werden von vanvan und mir."
|
||
|
||
`von_id` stand seit dem Bau in der Tabelle und wurde nie
|
||
ausgeliefert -- man sah, DASS ein Merkmal gesetzt ist, nicht von
|
||
wem. Bei zwei Leuten, die unabhaengig beobachten, ist genau das
|
||
die Auskunft: Was ihr BEIDE gesehen habt, ist mehr wert als was
|
||
einer von euch gesehen hat. */
|
||
gesehen_von: Object.fromEntries(merkmale
|
||
.filter((m) => m.eintrag_id === e.id && m.von_name)
|
||
.map((m) => [m.schluessel, m.von_name])),
|
||
/* DIE PROBE. Sie steht an der Karte und nicht hinter einem eigenen
|
||
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. */
|
||
/* WER DARAUS GEWORDEN IST (17.09.2026).
|
||
|
||
`aktiv` kommt mit: Ein Zugang, der abgeschaltet wurde, soll an
|
||
der Karte nicht aussehen wie einer, der laeuft. Die Karte bleibt
|
||
trotzdem stehen -- sie erzaehlt, wie jemand gekommen ist, und das
|
||
bleibt wahr, auch wenn er wieder geht. */
|
||
/* WER SICH KUEMMERT -- oder niemand. `null` ist hier die wichtigere
|
||
Antwort von beiden: Eine Karte, um die sich niemand kuemmert,
|
||
soll das sagen. */
|
||
zustaendig: stand?.zustaendig_id && stand?.zustaendig_name ? {
|
||
id: stand.zustaendig_id,
|
||
name: stand.zustaendig_name,
|
||
seit: stand.zustaendig_seit,
|
||
tage: tageSeit(stand.zustaendig_seit),
|
||
} : null,
|
||
person: stand?.person_id && stand?.person_name ? {
|
||
id: stand.person_id,
|
||
name: stand.person_name,
|
||
aktiv: stand.person_aktiv === 1,
|
||
} : null,
|
||
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,
|
||
};
|
||
}
|
||
|
||
/* =====================================================================
|
||
DIE PROBEZEIT EINES KANDIDATEN
|
||
|
||
Sie wird nur fuer die Stufe "probe" (und danach) gefuellt. An einem
|
||
Kandidaten, der noch beobachtet wird, waere eine leere Zwoelferliste
|
||
nur Laerm -- und Laerm, den man dreimal wegliest, liest man auch
|
||
dann weg, wenn er gilt.
|
||
===================================================================== */
|
||
function probeVon(eintragId, stufe) {
|
||
if (stufe !== "probe" && stufe !== "imteam") return null;
|
||
const zeilen = db().prepare(`
|
||
SELECT p.schluessel, p.am, p.notiz, x.name AS von_name
|
||
FROM talent_probe p
|
||
LEFT JOIN personen x ON x.id = p.von_id
|
||
WHERE p.eintrag_id = ?`).all(eintragId);
|
||
const stand = probeStand(zeilen.map((z) => z.schluessel));
|
||
const je = new Map(zeilen.map((z) => [z.schluessel, z]));
|
||
return {
|
||
...stand,
|
||
schritte: PROBE_ALLE.map((s) => ({
|
||
...s,
|
||
wer_name: PROBE_WER[s.wer] || s.wer,
|
||
fertig: je.has(s.schluessel),
|
||
am: je.get(s.schluessel)?.am || null,
|
||
von: je.get(s.schluessel)?.von_name || null,
|
||
notiz: je.get(s.schluessel)?.notiz || null,
|
||
})),
|
||
fragen: PROBE_FRAGEN,
|
||
};
|
||
}
|
||
|
||
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 m.eintrag_id, m.schluessel, x.name AS von_name
|
||
FROM talent_merkmal m
|
||
LEFT JOIN personen x ON x.id = m.von_id
|
||
WHERE m.eintrag_id IN (${eintraege.map(() => "?").join(", ")})`)
|
||
.all(...eintraege.map((e) => e.id))
|
||
: [];
|
||
|
||
/* 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] || [] })),
|
||
warnzeichen: TALENT_WARNZEICHEN,
|
||
/* WER BUDDY SEIN KANN -- in der Reihenfolge des Hauses, nicht in
|
||
der der Datenbank. Sie kommt mit der Lage, damit die Auswahl
|
||
keine zweite Liste braucht, die man pflegen muesste. */
|
||
buddys: db().prepare(
|
||
`SELECT id, name, rolle FROM personen
|
||
WHERE aktiv = 1 AND rolle IN (${BUDDY_ROLLEN.map(() => "?").join(", ")})
|
||
ORDER BY ${ROLLEN_SORTIERUNG}, name COLLATE NOCASE`).all(...BUDDY_ROLLEN),
|
||
erster_tag: ERSTER_TAG,
|
||
schicht_weit_tage: SCHICHT_WEIT_TAGE,
|
||
/* WELCHEN ZUGANG DER LETZTE SCHRITT ANLEGT -- Schluessel und
|
||
Wort. Beides kommt von hier, damit im Browser kein fester Text
|
||
dazu steht; siehe ZUGANG_ROLLE oben. */
|
||
zugang: { rolle: ZUGANG_ROLLE, name: ROLLEN_NAME[ZUGANG_ROLLE] ?? ZUGANG_ROLLE },
|
||
/* WOMIT EIN NEUER ANFAENGT (17.09.2026).
|
||
|
||
Filipe: „wie talente bewertet werden, aufgaben bekommen,
|
||
analysiert werden von vanvan und mir, alles moegliche."
|
||
|
||
Der Trichter endete bisher beim Zugang. Wer im Team ist, hat
|
||
einen Code -- und keine einzige Aufgabe. Um ihm welche zu
|
||
geben, musste man auf eine andere Seite, die Person dort
|
||
heraussuchen, Kategorie und Stufe waehlen und uebernehmen.
|
||
Vier Schritte, die niemand macht, waehrend er gerade jemanden
|
||
aufnimmt. Genau dieses Muster hat heute schon einmal zu einer
|
||
Karte auf „Im Team“ ohne Menschen darin gefuehrt.
|
||
|
||
ES BLEIBT EIN ANGEBOT, KEINE AUTOMATIK. Filipes Entscheidung
|
||
bei der Live-Checkliste gilt hier genauso: „vierzehn Aufgaben,
|
||
die von selbst erscheinen, ueberrumpeln, und was ungefragt
|
||
Dinge anlegt, ist schwer wieder loszuwerden." Deshalb steht
|
||
hier nur, WAS es gaebe -- angelegt wird auf Klick, eine
|
||
Kategorie auf einmal.
|
||
|
||
KEIN ROLLENNAME, nirgends. Die Kategorien heissen „chat“,
|
||
„team“, „technik“ -- aus keiner davon laesst sich schliessen,
|
||
fuer wen sie gedacht sind. Wer den Katalog nicht bekommen
|
||
darf, bekommt `null` und die Seite baut nichts; ein leeres
|
||
Feld sagt nichts, ein Feld mit sprechendem Namen saehe jeder
|
||
im Netzwerkprotokoll seines eigenen Browsers. */
|
||
start_kategorien: kategorienFuer(req.person).length
|
||
? MODI_KATEGORIEN.map((k) => ({
|
||
wert: k.wert, name: k.name, text: k.text,
|
||
anzahl: MODI_AUFGABEN_FLACH
|
||
.filter((v) => v.kategorie === k.wert && v.stufe === "neu").length,
|
||
})).filter((k) => k.anzahl > 0)
|
||
: null,
|
||
/* WAS SIE SCHON HABEN -- je Person, in EINER Abfrage.
|
||
|
||
Ohne diese Angabe legt man beim zweiten Hinsehen dasselbe noch
|
||
einmal an; der Knopf sagt dann „6 Aufgaben“ und meint die
|
||
siebte bis zwoelfte. Der Server verhindert Doppelte nicht --
|
||
er soll es auch nicht, denn dieselbe Aufgabe ein zweites Mal
|
||
kann gewollt sein. Sichtbar muss es trotzdem sein. */
|
||
schon_vergeben: (() => {
|
||
const ids = karten.map((k) => k.person?.id).filter(Boolean);
|
||
if (!ids.length) return {};
|
||
const platz = ids.map(() => "?").join(", ");
|
||
const zeilen = db().prepare(
|
||
`SELECT verantwortlich_id AS wer, COUNT(*) AS n
|
||
FROM aufgaben
|
||
WHERE verantwortlich_id IN (${platz}) AND status NOT IN ('erledigt','abgebrochen')
|
||
GROUP BY verantwortlich_id`).all(...ids);
|
||
return Object.fromEntries(zeilen.map((z) => [z.wer, z.n]));
|
||
})(),
|
||
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) => ({
|
||
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" });
|
||
}
|
||
});
|
||
|
||
/* =====================================================================
|
||
EINEN SCHRITT DER PROBE ABHAKEN (17.09.2026)
|
||
|
||
EIN KNOPF, ZWEI RICHTUNGEN -- wie bei den Merkmalen. Ein zweiter
|
||
Klick nimmt zurueck; ein eigener Weg zum Abwaehlen waere die Stelle,
|
||
an der die Rechtepruefung irgendwann fehlt.
|
||
|
||
NUR AUF DER STUFE "PROBE". Wer einen Schritt abhakt, bevor die Probe
|
||
laeuft, haekchen einen Zeitraum ab, den es nicht gibt -- und am Tag
|
||
30 stuende eine volle Liste da, die nichts bedeutet.
|
||
===================================================================== */
|
||
entwicklungRouter.put("/workspace/api/talente/:id/probe/:schluessel", gleicheHerkunft,
|
||
express.json({ limit: "8kb" }), (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 (!PROBE_NACH_SCHLUESSEL.has(schluessel)) {
|
||
return res.status(404).json({ fehler: "nicht_gefunden" });
|
||
}
|
||
const stand = db().prepare("SELECT stufe FROM talent_stufe WHERE eintrag_id = ?").get(id);
|
||
if (stand?.stufe !== "probe") {
|
||
return res.status(409).json({
|
||
fehler: "Das gehört in die Probezeit – die läuft für ihn gerade nicht.",
|
||
});
|
||
}
|
||
|
||
const schon = db().prepare(
|
||
"SELECT 1 AS x FROM talent_probe WHERE eintrag_id = ? AND schluessel = ?")
|
||
.get(id, schluessel);
|
||
if (schon) {
|
||
db().prepare("DELETE FROM talent_probe WHERE eintrag_id = ? AND schluessel = ?")
|
||
.run(id, schluessel);
|
||
} else {
|
||
const notiz = String(req.body?.notiz ?? "").trim().slice(0, 400) || null;
|
||
db().prepare(
|
||
"INSERT INTO talent_probe (eintrag_id, schluessel, von_id, am, notiz)"
|
||
+ " VALUES (?,?,?,?,?)")
|
||
.run(id, schluessel, req.person.id, jetzt(), notiz);
|
||
}
|
||
res.json({ ok: true, fertig: !schon });
|
||
} catch (f) {
|
||
console.error("[talente] Probe:", f?.message);
|
||
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
||
}
|
||
});
|
||
|
||
/* =====================================================================
|
||
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" });
|
||
}
|
||
});
|
||
|
||
/* =====================================================================
|
||
ICH KUEMMERE MICH (17.09.2026)
|
||
|
||
Dieselbe Frage wie bei den Anfragen, nur teurer: Ein Kandidat, fuer
|
||
den sich niemand zustaendig fuehlt, bleibt auf seiner Stufe liegen.
|
||
Die Standzeit sagt, DASS etwas liegt -- nicht, wer es aufheben
|
||
sollte.
|
||
|
||
EIN KNOPF, ZWEI RICHTUNGEN. Und eine fremde Uebernahme wird
|
||
abgewiesen statt ueberschrieben: Sonst nimmt einer dem anderen den
|
||
Kandidaten aus der Hand, ohne dass es jemand merkt.
|
||
===================================================================== */
|
||
entwicklungRouter.put("/workspace/api/talente/:id/kuemmern", gleicheHerkunft,
|
||
(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 stand = db().prepare(
|
||
"SELECT zustaendig_id FROM talent_stufe WHERE eintrag_id = ?").get(id);
|
||
if (stand?.zustaendig_id && stand.zustaendig_id !== req.person.id) {
|
||
const wer = db().prepare("SELECT name FROM personen WHERE id = ?")
|
||
.get(stand.zustaendig_id);
|
||
return res.status(409).json({
|
||
fehler: `Darum kümmert sich schon ${wer?.name || "jemand"}.`,
|
||
});
|
||
}
|
||
|
||
const nehmen = !stand?.zustaendig_id;
|
||
const nun = jetzt();
|
||
if (stand) {
|
||
db().prepare(`UPDATE talent_stufe SET zustaendig_id = ?, zustaendig_seit = ?
|
||
WHERE eintrag_id = ?`)
|
||
.run(nehmen ? req.person.id : null, nehmen ? nun : null, id);
|
||
} else {
|
||
/* Noch keine Stufenzeile -- die entsteht erst beim ersten
|
||
Schritt. Wer sich vorher kuemmern will, soll das koennen:
|
||
„Ich sehe ihn mir an“ ist der haeufigste erste Schritt
|
||
ueberhaupt, und er faellt sonst durch. */
|
||
db().prepare(`INSERT INTO talent_stufe
|
||
(eintrag_id, stufe, seit, von_id, zustaendig_id, zustaendig_seit)
|
||
VALUES (?, 'aufgefallen', ?, ?, ?, ?)`)
|
||
.run(id, nun, req.person.id, req.person.id, nun);
|
||
}
|
||
protokolliere(nehmen ? "talent_uebernommen" : "talent_abgegeben", {
|
||
personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req),
|
||
detail: `#${id}`,
|
||
});
|
||
res.json({ ok: true, zustaendig: nehmen });
|
||
} catch (f) {
|
||
console.error("[talente] Kuemmern:", 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
|
||
* spaetere Aendern. Zwei Abschriften derselben Regeln waeren zwei
|
||
* Regeln, und die zweite ist immer die, die etwas durchlaesst. */
|
||
function uebergangPruefen(koerper, { pflicht }) {
|
||
const fehlt = [];
|
||
const buddyId = nummer(koerper?.buddy_id);
|
||
const b = buddyId
|
||
? db().prepare("SELECT id, aktiv, rolle FROM personen WHERE id = ?").get(buddyId) : null;
|
||
if (buddyId && (!b || !b.aktiv || !BUDDY_ROLLEN.includes(b.rolle))) fehlt.push("buddy_unbekannt");
|
||
else if (!buddyId && pflicht) fehlt.push("buddy");
|
||
|
||
const roh = String(koerper?.erste_schicht || "").trim();
|
||
if (roh) {
|
||
if (!/^\d{4}-\d{2}-\d{2}$/.test(roh)) fehlt.push("datum_unlesbar");
|
||
else {
|
||
const bis = tageBis(roh);
|
||
/* VERGANGENHEIT IST KEIN TERMIN. Wer eine Schicht nachtraegt, die
|
||
schon war, meint etwas anderes -- naemlich, dass die Probe
|
||
laeuft. Dann gehoert das Datum von damals hinein, nicht heute:
|
||
deshalb sind bis zu sieben Tage rueckwaerts erlaubt. */
|
||
if (bis === null) fehlt.push("datum_unlesbar");
|
||
else if (bis < -7) fehlt.push("datum_zu_alt");
|
||
else if (bis > SCHICHT_MAX_TAGE) fehlt.push("datum_zu_weit");
|
||
}
|
||
} else if (pflicht) fehlt.push("erste_schicht");
|
||
|
||
return fehlt.length ? { fehlt } : { buddyId: buddyId || null, schicht: roh || null };
|
||
}
|
||
|
||
/** Buddy oder Datum ändern, ohne die Stufe anzufassen.
|
||
*
|
||
* Ein eigener Weg, weil es ein eigener Vorgang ist: Ein Buddy hoert
|
||
* auf, ein Termin verschiebt sich. Wer dafuer die Stufe neu setzen
|
||
* muesste, setzt dabei `seit` zurueck -- und die Standzeit, die genau
|
||
* diese Verschleppung sichtbar macht, faengt wieder bei null an. */
|
||
entwicklungRouter.put("/workspace/api/talente/:id/uebergang", gleicheHerkunft, (req, res) => {
|
||
try {
|
||
const id = nummer(req.params.id);
|
||
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" });
|
||
const stand = db().prepare("SELECT stufe FROM talent_stufe WHERE eintrag_id = ?").get(id);
|
||
if (!stand) return res.status(404).json({ fehler: "nicht_gefunden" });
|
||
|
||
const p = uebergangPruefen(req.body, { pflicht: stand.stufe === "probe" });
|
||
if (p.fehlt) return res.status(400).json({ fehler: "uebergang_offen", fehlt: p.fehlt });
|
||
|
||
db().prepare("UPDATE talent_stufe SET buddy_id = ?, erste_schicht = ? WHERE eintrag_id = ?")
|
||
.run(p.buddyId, p.schicht, id);
|
||
protokolliere("talent_uebergang", {
|
||
personId: req.person.id, ip: echteIp(req),
|
||
detail: `${e.titel}: Buddy/erste Schicht geaendert`.slice(0, 120),
|
||
});
|
||
res.json({ ok: true });
|
||
} catch (f) {
|
||
console.error("[talente] Uebergang:", 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" });
|
||
|
||
/* AUF DIE PROBE KOMMT NIEMAND OHNE BUDDY UND DATUM.
|
||
|
||
In der Stufenbeschreibung stand „fester Buddy“ seit dem ersten
|
||
Tag -- ein Feld dafuer gab es nicht. Ein Versprechen ohne Feld
|
||
ist eines, an das sich nach zwei Wochen niemand erinnert, und
|
||
genau an dieser Stelle verlieren Teams die Leute, die sie gerade
|
||
gewonnen haben.
|
||
|
||
EINE ABSAGE MIT GRUND, nicht ein „ging nicht“: Die Oberflaeche
|
||
fragt beides vorher ab, aber die Regel gehoert hierher. Was nur
|
||
im Browser steht, gilt nur fuer den, der den Browser benutzt. */
|
||
const p = stufe === "probe"
|
||
? uebergangPruefen(req.body, { pflicht: true })
|
||
: uebergangPruefen(req.body, { pflicht: false });
|
||
if (p.fehlt) return res.status(400).json({ fehler: "uebergang_offen", fehlt: p.fehlt });
|
||
|
||
/* COALESCE, NICHT ÜBERSCHREIBEN: Wer aus der Probe ins Team geht,
|
||
schickt Buddy und Datum nicht noch einmal mit -- und die beiden
|
||
sollen bleiben, wo sie sind. Sie sind die Geschichte dieser
|
||
Probe, nicht ein Formularfeld. */
|
||
/* WER AUS DEM TALENT GEWORDEN IST (17.09.2026).
|
||
|
||
Der letzte Schritt legt einen Zugang an. Bis heute war das eine
|
||
Einbahnstrasse: Die Person entstand, ihr Code wurde einmal
|
||
gezeigt -- und die Karte wusste danach nicht, wer daraus wurde.
|
||
|
||
GEPRUEFT WIRD, OB ES DIE PERSON GIBT, und nicht nur, ob eine
|
||
Zahl ankam. Eine erfundene Nummer wuerde die Karte sonst auf
|
||
jemanden zeigen lassen, den es nicht gibt -- und das faellt erst
|
||
auf, wenn jemand darauf klickt.
|
||
|
||
NUR SETZEN, NIE LOESCHEN: Kommt kein `person_id` mit (jeder
|
||
andere Stufenwechsel), bleibt die vorhandene stehen. Sonst
|
||
verloere die Karte ihre Person, sobald jemand sie auf eine
|
||
fruehere Stufe zuruecksetzt. */
|
||
let personId = null;
|
||
if (nummer(req.body?.person_id)) {
|
||
const wer = db().prepare("SELECT id FROM personen WHERE id = ? AND aktiv = 1")
|
||
.get(nummer(req.body.person_id));
|
||
if (!wer) return res.status(400).json({ fehler: "Diesen Zugang gibt es nicht." });
|
||
personId = wer.id;
|
||
}
|
||
|
||
db().prepare(`INSERT INTO talent_stufe
|
||
(eintrag_id, stufe, seit, von_id, notiz, buddy_id, erste_schicht, person_id)
|
||
VALUES (?,?,?,?,?,?,?,?)
|
||
ON CONFLICT(eintrag_id) DO UPDATE SET
|
||
stufe = excluded.stufe, seit = excluded.seit,
|
||
von_id = excluded.von_id, notiz = excluded.notiz,
|
||
buddy_id = COALESCE(excluded.buddy_id, talent_stufe.buddy_id),
|
||
erste_schicht = COALESCE(excluded.erste_schicht, talent_stufe.erste_schicht),
|
||
person_id = COALESCE(excluded.person_id, talent_stufe.person_id)`)
|
||
.run(id, stufe, jetzt(), req.person.id, notiz || null, p.buddyId, p.schicht, personId);
|
||
|
||
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" });
|
||
}
|
||
});
|