Files
dogfather-universe/server/workspace-manager-ziele.js
T
DogFatherGitandClaude Opus 5 f6d2437985 Manager-Ziele zu Ende gebaut -- und dabei zwei Loecher gefunden
Filipe: „perfektioniere alles jetzt sofort, es muss ready sein."

Die Vorlage Punkt fuer Punkt gegen das Gebaute gehalten, nicht gegen
meine eigene Liste von heute Mittag. Vier Punkte standen noch offen,
und auf dem Weg dorthin sind zwei Fehler aufgefallen, nach denen
niemand gesucht hat.

DIE ZWEI FEHLER ZUERST -- beide gefunden durch Messen, nicht Denken

1. EINE GELOESCHTE PERSON HAETTE IHRE ZAHLEN MITGENOMMEN.
   `mz_eintrag.person_id` stand auf ON DELETE CASCADE. Die Vorlage
   sagt aber: „Wer die Rolle verliert, sieht die Kachel nicht mehr;
   die Daten bleiben fuer den DogFather erhalten." Mit CASCADE waere
   genau das nicht wahr gewesen -- `DELETE FROM personen` haette den
   Monatsverlauf eines Menschen lautlos mitgenommen.

   Jetzt SET NULL, und Name und Rolle stehen zusaetzlich als Text am
   Eintrag (dieselbe Bauweise wie bei support_meldungen). Die Rolle
   ist nicht Zierde: Ohne sie wuerde ein abgeschlossener Monat
   rueckwirkend an den Zielzahlen einer anderen Rolle gemessen.

   Der Umbau laeuft auf dem Bestand von heute Mittag -- Spaltenliste
   AUS PRAGMA abgeleitet, nicht gepflegt, und geprueft werden Zeilen
   UND Spalten. Am 11.09.2026 hat genau so ein Umbau drei Spalten mit
   Inhalt verloren, ohne Fehlermeldung, bei unveraenderter Zeilenzahl.

2. MEIN EIGENER SPERR-TRIGGER HAETTE DAS LOESCHEN BLOCKIERT.
   ON DELETE SET NULL ist kein Loeschen, sondern ein UPDATE auf
   person_id. Der Trigger sah eine Aenderung an einem abgeschlossenen
   Monat und brach ab -- `DELETE FROM personen` waere damit
   gescheitert, an einer Stelle, die mit Monatszielen nichts zu tun
   hat. Erlaubt ist jetzt genau eine Aenderung an einem alten Monat:
   dem Eintrag seinen Besitzer zu nehmen. Als BEDINGUNG und nicht als
   `UPDATE OF <spaltenliste>` -- eine Liste muesste jemand pflegen.

   Weil `CREATE TRIGGER IF NOT EXISTS` eine geaenderte Fassung nicht
   erneuert, wird die alte am INHALT erkannt und ersetzt. Eine
   Fassungsnummer muesste jemand hochzaehlen, und das wird vergessen.

DIE VIER OFFENEN PUNKTE DER VORLAGE

  04  Jede Aufgabenzeile hat ihr eigenes Zeichen -- aus dem Haus
      (`window.Bereiche`), nicht neu gezeichnet: Trichter, Bildschirm,
      Buch, Rahmen. Das Statuszeichen bleibt daneben; ein eingefaerbtes
      Aufgabenzeichen allein traegt die Stufe nicht.
  05  „Farbiger Rand + Badge": Eine Kachel, an der eine Warnung haengt,
      traegt jetzt einen feinen Saum -- JEDE Kachel, nicht nur diese.
      Eine Regel, die nur an einer Stelle gilt, wird beim naechsten Mal
      vergessen.
  08  Die Team-Tabelle ist sortierbar: jede Spalte ein Knopf (kein
      anklickbares <th> -- das erreicht die Tastatur nicht), mit
      aria-sort, und sortiert wird nach ANTEIL statt nach nackter Zahl.
      Dazu eine Ampel-Spalte mit Wort. Auf dem Handy verschwindet die
      Kopfzeile im Kartenmodus, deshalb steht das Sortieren zusaetzlich
      in der Leiste -- sonst waere es auf einem Telefon nicht
      vorhanden.
  09  Wer die Rolle verliert, steht weiter in der Uebersicht, als
      „nicht mehr dabei" und mit der Rolle von damals. Wer geloescht
      wurde, erscheint als zusammengefasste Zeile unter dem
      mitgeschriebenen Namen.

WAS DER SAUM MICH GELEHRT HAT

Er stand zuerst in start.css und war wirkungslos -- der Browser
lieferte weiter den Faseschatten. Der Grund steht seit dem 25.09.2026
in module.css: `:is()` uebernimmt die Spezifitaet seines staerksten
Arguments, und `.gruppe[data-gruppe]` macht die ganze Modulliste
(0,2,0) -- genau so stark wie `.kachel[data-warn="ja"]`, bei
Gleichstand gewinnt die zuletzt geladene Datei. Dieselbe Falle wie
damals bei den Fokusringen, dieselbe Antwort: Was gegen die Modulform
gewinnen muss, gehoert in die Datei mit der Modulform. Gemerkt habe
ich es nur, weil die Bildmessung den errechneten Schatten AUSGIBT
statt ein Bild zu machen.

Beim Herausschneiden blieb eine Klammer zu viel in start.css stehen --
gefunden von pruef-css-klassen („eine schliessende Klammer ohne
oeffnende"), bevor sie still CSS verschluckt hat.

AUSSERDEM BEHOBEN

  * Spicy Media sah an einer FREMDEN Liste „Bearbeiten" und „Loeschen",
    und der Server antwortete mit 403. Ein Knopf, der nichts tut, ist
    schlimmer als kein Knopf.
  * Klick auf eine Person klappt jetzt alle vier Zeilen auf. Die
    Vorlage verspricht „zeigt deren Eintraege" -- zugeklappt zeigte
    der Klick nur Zahlen.
  * Der CSV-Export kennt drei Staende statt zwei: „pflichtig", „neu,
    noch ohne Pflicht", „nicht mehr dabei". Vorher hiess beides „nein".
  * Das Aufklappen baute die ganze Liste neu und riss den
    angeklickten Knopf weg (Fokus sprang nach oben).

GEPRUEFT: 168 Pruefungen, 0 Fehler (vorher 141)

Neu darunter: der Umbau auf einem echten Alt-Bestand (Zeilen, Spalten,
Inhalt, Indizes, Trigger, und ein zweiter Lauf, der nichts mehr tut),
das Loeschen einer Person mit Eintraegen aus einem abgeschlossenen
Monat -- mit Gegenprobe, dass dieselbe Sperre den INHALT weiterhin
nicht aendern laesst.

Zwei meiner neuen Pruefungen haben zuerst sich selbst gemessen statt
den Code: Eine verglich gegen einen Eintrag, den sie vorher geloescht
hatte (404 sah aus wie ein haltender Riegel), die andere meldete eine
fehlende Spalte, die nur ihr eigener Handeinsatz verursacht hatte.
Beide berichtigt.

Am Bildschirm nachgemessen bei 412 px und 1280 px: kein waagerechtes
Schieben, kein eigenes Beruehrziel unter 40 px, genau EINE Kachel mit
Saum und zwanzig ohne.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-02 17:34:58 +02:00

1245 lines
49 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =====================================================================
MANAGER-ZIELE — vier feste Monatsaufgaben (02.10.2026)
=====================================================================
Vorlage: „Prompt · Kachel Agentur-Aufgaben". Vier Pflichten je Monat,
Fortschritt, Ampel, Warnungen, Monatsschnitt, Team-Übersicht.
---------------------------------------------------------------------
WAS HIER ANDERS IST ALS IN DER VORLAGE — und warum
1. DIE KACHEL HEISST „Manager-Ziele", nicht „Agentur-Aufgaben".
Es gibt im Haus bereits eine Kachel „Agentur" und eine Kachel
„Aufgaben". Eine dritte mit beiden Wörtern im Namen wäre auf
einem Handy nicht mehr auseinanderzuhalten. Entschieden von
Filipe am 02.10.2026.
2. DIE ZIELZAHLEN GELTEN JE ROLLE und sind von DogFather UND Spicy
Media änderbar („dogfather und spicy rolle sollen aber
gleichzeitig es immer ändern können für alle"). Ein Scout muss
nicht dieselbe Zahl schaffen wie DogFather.
3. DIE PIPELINE IST ANGEBUNDEN, in beide Richtungen. Die Vorlage
kennt nur die Handeingabe. Das Haus hat aber ein vollständiges
Scout-CRM (`leads`), und zwei Listen derselben Rekrutierungen
laufen auseinander. Also: Vorschläge aus der Pipeline, Übernahme
mit einem Klick, Verbindung bleibt gespeichert — aber NICHTS
zählt von selbst. Gezählt wird nur, was ein Mensch bestätigt hat.
---------------------------------------------------------------------
KEIN ZWEITER ZÄHLER FÜR DIE KACHELWAND
Die Zahl auf einer Kachel kommt im Haus immer aus den Hinweisen
(`workspace-hinweise.js`), nie aus einer eigenen Rechnung. Dieses
Modul liefert dafür `offeneHinweise()` und rechnet nicht ein
zweites Mal — sonst stünden irgendwann zwei verschiedene Wahrheiten
übereinander auf einem Bildschirm.
---------------------------------------------------------------------
KEIN IMPORT AUS workspace-push.js
Die Erinnerungen entstehen hier als LISTE (`zielRufe`), verschickt
werden sie im Takt von workspace-push.js. Andersherum wäre es ein
Importkreis: push müsste dieses Modul kennen und dieses Modul push.
Über einen Kreis kommen Konstanten als `undefined` an, ohne dass
irgendwo ein Fehler erscheint.
===================================================================== */
import express from "express";
import {
db, sitzungLesen, protokolliere, echteIp, siehtAlles, ROLLEN_NAME,
} from "./workspace.js";
import {
AUFGABEN, AUFGABEN_SCHLUESSEL, PFLICHT_ROLLEN, ERSTER_MONAT,
FREIGABE_SEKUNDEN,
} from "./manager-ziele-tabellen.js";
export const managerZieleRouter = express.Router();
const BASIS = "/workspace/api/manager-ziele";
const jetzt = () => new Date().toISOString();
const NAME_MAX = 120;
const LINK_MAX = 500;
const NOTIZ_MAX = 1000;
const AUFGABE_VON = new Map(AUFGABEN.map((a) => [a.schluessel, a]));
/* =====================================================================
ZEIT — immer Europa/Berlin, nie die Uhr des Servers
=====================================================================
Die Vorlage nennt Europe/Luxembourg. Luxemburg und Berlin haben
dieselbe Zeit und dieselben Umstellungstage (beide MEZ/MESZ nach
EU-Regel), es gibt also keinen Unterschied im Verhalten — und das
ganze übrige Haus rechnet in Europe/Berlin. Zwei Zeitrechnungen in
einem Haus wären die Stelle, an der es später auseinanderläuft.
`getMonth()` oder `new Date().getDate()` kommen hier NICHT vor: Sie
lesen die Zeitzone des Servers, und die ist auf einem gemieteten
Rechner nicht verlässlich deutsch. Ein Monatswechsel, der zwei
Stunden zu früh passiert, verschiebt jede Zählung.
===================================================================== */
const TAG_BERLIN = new Intl.DateTimeFormat("sv-SE", {
timeZone: "Europe/Berlin", year: "numeric", month: "2-digit", day: "2-digit",
});
/** Der heutige Tag in Berlin als 'JJJJ-MM-TT'. */
export function tagBerlin(d = new Date()) {
return TAG_BERLIN.format(d);
}
/** Der laufende Monat als 'JJJJ-MM'. */
export function monatJetzt(d = new Date()) {
return tagBerlin(d).slice(0, 7);
}
/** Wie viele Tage dieser Monat hat. `Date.UTC(j, m, 0)` ist der letzte
* Tag des Vormonats von m+1 — also der letzte des gesuchten. */
export function letzterTagVon(monat) {
const [j, m] = String(monat).split("-").map(Number);
if (!j || !m) return 30;
return new Date(Date.UTC(j, m, 0)).getUTCDate();
}
/** Monat um `n` verschieben. Negativ heißt zurück. */
export function monatPlus(monat, n) {
const [j, m] = String(monat).split("-").map(Number);
const d = new Date(Date.UTC(j, (m - 1) + n, 1));
return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}`;
}
const istMonat = (w) => /^\d{4}-(0[1-9]|1[0-2])$/.test(String(w || ""));
const istDatum = (w) => /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/.test(String(w || ""));
/* =====================================================================
DIE AMPEL
===================================================================== */
/** Die Stufe einer einzelnen Aufgabe.
*
* Reihenfolge der Prüfungen ist die Dringlichkeit: erreicht schlägt
* alles, danach gewinnt Rot vor Orange. Stünde die 16er-Regel zuerst,
* bliebe eine Aufgabe am 28. orange — die schwächere von zwei
* zutreffenden Warnungen.
*
* `ziel = 0` heißt „für diese Rolle nicht gefordert". Das ist kein
* Sonderfall, sondern eine gültige Einstellung: DogFather kann für
* sich selbst z. B. 0 Creator setzen. Eine solche Zeile warnt nie und
* zählt nicht in den Gesamtfortschritt. */
export function stufeFuer(zahl, ziel, tag, letzter) {
if (!(ziel > 0)) return "aus";
if (zahl >= ziel) return "gruen";
const bisEnde = letzter - tag;
if (bisEnde <= 7) return "rot";
if (tag >= 16) return "orange";
return "neutral";
}
/** Der Satz zur Stufe. FARBE NIE ALLEIN — Hausregel: zu jeder Farbe
* gehört ein Text und ein Zeichen, sonst ist sie für einen Teil der
* Menschen gar nicht da. */
export function stufenText(stufe, bisEnde) {
if (stufe === "gruen") return "Ziel erreicht";
if (stufe === "orange") return "Sollte bis Monatsmitte erledigt sein";
if (stufe === "rot") {
if (bisEnde <= 0) return "Dringend – heute ist der letzte Tag";
if (bisEnde === 1) return "Dringend – noch 1 Tag";
return `Dringend – noch ${bisEnde} Tage`;
}
if (stufe === "aus") return "Für deine Rolle nicht gefordert";
return "";
}
const RANG = { rot: 0, orange: 1, neutral: 2, gruen: 3, aus: 4 };
/** Die schlechteste Stufe einer Liste — das ist die Farbe der Kachel. */
export function schlechteste(stufen) {
let beste = "aus";
for (const s of stufen) if (RANG[s] < RANG[beste]) beste = s;
return beste;
}
/* =====================================================================
DER LAUFENDE MONAT UND DIE EINGEFRORENEN ZIELE
===================================================================== */
/** Sagt der Datenbank, welcher Monat läuft — die Trigger lesen das.
*
* Wird vor jedem schreibenden Zugriff gesetzt und nicht einmal beim
* Start: Ein Server, der über den Monatswechsel durchläuft, hätte
* sonst einen Monat lang den falschen Wert stehen, und die Sperre
* griffe am 1. für den gerade begonnenen Monat. */
function lageSetzen(monat = monatJetzt()) {
db().prepare("UPDATE mz_lage SET monat = ? WHERE eins = 1").run(monat);
return monat;
}
/** Das geltende Ziel für (Monat, Rolle, Aufgabe), ohne zu schreiben.
*
* Gesucht wird die jüngste Festlegung, die NICHT NACH diesem Monat
* liegt. Dadurch gilt eine Änderung ab dem Monat, für den sie
* eingetragen wurde, und kein Monat davor ändert sich. Findet sich
* gar nichts, gilt die Vorgabe aus der Vorlage (3/2/2/1). */
function zielGeltend(monat, rolle, aufgabe) {
const z = db().prepare(
`SELECT ziel FROM mz_ziel
WHERE rolle = ? AND aufgabe = ? AND monat <= ?
ORDER BY monat DESC LIMIT 1`).get(rolle, aufgabe, monat);
if (z) return z.ziel;
return AUFGABE_VON.get(aufgabe)?.vorgabe ?? 0;
}
/** Schreibt die Ziele eines Monats fest, falls noch nicht geschehen.
*
* DAS IST DER KERN DER REGEL „Änderungen gelten ab dem nächsten
* Monat": Sobald ein Monat einmal angefasst wurde, stehen seine
* Zahlen. Eine spätere Änderung trägt sich für einen SPÄTEREN Monat
* ein und kann diesen hier nicht mehr erreichen.
*
* `INSERT OR IGNORE` und nicht `REPLACE` — genau deshalb. */
function monatSichern(monat) {
if (!istMonat(monat) || monat < ERSTER_MONAT) return;
const d = db();
const schon = d.prepare("SELECT COUNT(*) n FROM mz_ziel WHERE monat = ?").get(monat)?.n ?? 0;
if (schon >= PFLICHT_ROLLEN.length * AUFGABEN.length) return;
const einfuegen = d.prepare(
`INSERT OR IGNORE INTO mz_ziel (monat, rolle, aufgabe, ziel, gesetzt_von, gesetzt_am)
VALUES (?,?,?,?,NULL,?)`);
const am = jetzt();
for (const rolle of PFLICHT_ROLLEN) {
for (const a of AUFGABEN) {
einfuegen.run(monat, rolle, a.schluessel, zielGeltend(monat, rolle, a.schluessel), am);
}
}
}
/** Das Ziel, wie es für diesen Monat FESTSTEHT. */
function zielFuer(monat, rolle, aufgabe) {
const z = db().prepare(
"SELECT ziel FROM mz_ziel WHERE monat = ? AND rolle = ? AND aufgabe = ?")
.get(monat, rolle, aufgabe);
return z ? z.ziel : zielGeltend(monat, rolle, aufgabe);
}
/* =====================================================================
AB WANN JEMAND PFLICHTIG IST
=====================================================================
Vorlage: „Wer erst im Laufe des Monats eine der Rollen bekommt,
erhält in diesem Monat keine Warnungen; die Pflicht beginnt im
Folgemonat."
„IM LAUFE DES MONATS" HEISST AB DEM 2. Wer am 1. dazukommt, hat den
vollen Monat vor sich; ihm die Pflicht zu erlassen wäre ein
geschenkter Monat ohne Grund. Diese eine Zeile ist der ganze
Unterschied, und sie steht hier, damit man sie findet.
GEMESSEN WIRD EINMAL. Der Rollenwechsel steht im Protokoll, und ein
Protokoll ist ein Log — es darf gekürzt werden. Würde die Pflicht
bei jeder Abfrage daraus gerechnet, verschöbe ein Aufräumlauf
rückwirkend, seit wann jemand verantwortlich ist.
===================================================================== */
function startMonatFuer(personId) {
const d = db();
const da = d.prepare("SELECT erster_monat FROM mz_start WHERE person_id = ?").get(personId);
if (da) return da.erster_monat;
const p = d.prepare("SELECT erstellt FROM personen WHERE id = ?").get(personId);
if (!p) return ERSTER_MONAT;
/* Der jüngste Rollenwechsel schlägt das Anlegedatum: Wer seit einem
Jahr Creator war und gestern Scout wurde, ist nicht seit einem
Jahr pflichtig. */
const wechsel = d.prepare(
`SELECT zeitpunkt FROM protokoll
WHERE person_id = ? AND aktion = 'rolle_geaendert'
ORDER BY zeitpunkt DESC LIMIT 1`).get(personId);
const quelle = wechsel?.zeitpunkt || p.erstellt;
let grund = wechsel ? "rollenwechsel" : "angelegt";
let monat = ERSTER_MONAT;
try {
const tag = tagBerlin(new Date(quelle));
monat = tag.slice(8) === "01" ? tag.slice(0, 7) : monatPlus(tag.slice(0, 7), 1);
} catch {
grund = "unlesbar";
}
if (monat < ERSTER_MONAT) { monat = ERSTER_MONAT; grund += "+erster_monat"; }
d.prepare(`INSERT OR IGNORE INTO mz_start (person_id, erster_monat, gesetzt_am, grund)
VALUES (?,?,?,?)`).run(personId, monat, jetzt(), grund);
return monat;
}
/** Gilt die Pflicht für diese Person in diesem Monat schon? */
function pflichtigIn(person, monat) {
if (!person || !PFLICHT_ROLLEN.includes(person.rolle)) return false;
if (monat < ERSTER_MONAT) return false;
return startMonatFuer(person.id) <= monat;
}
/* =====================================================================
DER STAND
===================================================================== */
/** Zählt die Einträge einer Person je Aufgabe in einem Monat. */
function zaehlen(personId, monat) {
const zeilen = db().prepare(
"SELECT aufgabe, COUNT(*) n FROM mz_eintrag WHERE person_id = ? AND monat = ? GROUP BY aufgabe")
.all(personId, monat);
const raus = Object.create(null);
for (const s of AUFGABEN_SCHLUESSEL) raus[s] = 0;
for (const z of zeilen) raus[z.aufgabe] = z.n;
return raus;
}
/** Der vollständige Stand einer Person in einem Monat.
*
* EINE Rechnung für alles: Kachel, Seite, Erinnerung, Verlauf und
* Team-Übersicht fragen diese Funktion. Vier eigene Rechnungen wären
* vier Gelegenheiten, dass die Zahlen auseinandergehen. */
export function standFuer(person, monat = monatJetzt(), heute = tagBerlin()) {
const letzter = letzterTagVon(monat);
/* Für einen vergangenen Monat ist der „heutige" Tag der letzte:
Sonst stünde ein abgeschlossener Monat auf „neutral, noch Zeit". */
const imMonat = heute.slice(0, 7) === monat;
const tag = imMonat ? Number(heute.slice(8, 10)) : (monat < heute.slice(0, 7) ? letzter : 1);
const bisEnde = letzter - tag;
const zahl = zaehlen(person.id, monat);
const pflichtig = pflichtigIn(person, monat);
const aufgaben = AUFGABEN.map((a) => {
const ziel = zielFuer(monat, person.rolle, a.schluessel);
const n = zahl[a.schluessel] || 0;
/* OHNE PFLICHT KEINE WARNUNG — aber die Zeile bleibt sichtbar und
zählbar. Wer im ersten Monat schon etwas einträgt, soll es
sehen; er soll nur nicht dafür gemahnt werden. */
const stufe = pflichtig ? stufeFuer(n, ziel, tag, letzter)
: (ziel > 0 && n >= ziel ? "gruen" : "neutral");
return {
schluessel: a.schluessel,
name: a.name,
kurz: a.kurz,
hinweis: a.hinweis,
feld: a.feld,
arten: a.arten || null,
einheit: a.einheit,
zahl: n,
ziel,
fehlt: Math.max(0, ziel - n),
stufe,
text: stufenText(stufe, bisEnde),
};
});
const zielSumme = aufgaben.reduce((s, a) => s + a.ziel, 0);
const erledigt = aufgaben.reduce((s, a) => s + Math.min(a.zahl, a.ziel), 0);
const offene = aufgaben.filter((a) => a.ziel > 0 && a.zahl < a.ziel);
return {
monat, heute, tag, letzter, bisEnde, pflichtig,
aufgaben,
gesamt: {
erledigt, ziel: zielSumme,
anteil: zielSumme ? Math.round((erledigt / zielSumme) * 100) : 100,
offen: offene.length,
},
stufe: schlechteste(aufgaben.map((a) => a.stufe)),
};
}
/* =====================================================================
DIE SCHRANKE
===================================================================== */
function angemeldet(req, res, next) {
const person = sitzungLesen(req);
if (!person) return res.status(401).json({ fehler: "nicht_angemeldet" });
/* ZWEI GETRENNTE HÄUSER (Regel vom 24.09.2026). Auf der Adresse von
Team Dogi gibt es diese Kachel nicht — auch nicht für DogFather,
der in beiden Häusern arbeitet. 404 und nicht 403: Was es hier
nicht gibt, soll man auch nicht erahnen können. */
if (person.haus === "crew") return res.status(404).json({ fehler: "nicht_hier" });
/* Creator haben die Kachel nicht. 404 aus demselben Grund wie im
Scout-CRM: Sie sollen nicht einmal erfahren, dass es diesen
Bereich gibt. Die Prüfung ist SERVERSEITIG — die Rechtetafel
sperrt die Seite, diese Zeile die Schnittstelle. Nur eines von
beiden wäre eine Tür, die man bloß kennen muss. */
if (!PFLICHT_ROLLEN.includes(person.rolle)) {
return res.status(404).json({ fehler: "nicht_zustaendig" });
}
req.person = person;
lageSetzen();
monatSichern(monatJetzt());
next();
}
managerZieleRouter.use(BASIS, angemeldet);
function nurLeitung(req, res, next) {
if (!siehtAlles(req.person)) return res.status(403).json({ fehler: "nicht_erlaubt" });
next();
}
/* =====================================================================
PRÜFUNGEN AN EINEM EINTRAG
===================================================================== */
const sauber = (w, max) => {
const t = String(w ?? "").trim().replace(/\s+/g, " ");
return t.slice(0, max);
};
/** Der Name ohne Zierrat — für den Dublettenschutz.
* „@LunaTanzt", „luna tanzt" und „LUNATANZT" sind derselbe Mensch. */
const nameSchluessel = (w) =>
String(w || "").toLowerCase().replace(/^@+/, "").replace(/[\s._-]+/g, "");
/** Der Link ohne Zierrat. Groß/klein, abschließender Schrägstrich und
* Verfolgungsparameter (`?is_from_webapp=…`) machen aus demselben
* Video sonst zwei. */
function linkSchluessel(w) {
try {
const u = new URL(String(w));
return (u.host.replace(/^www\./, "") + u.pathname.replace(/\/+$/, "")).toLowerCase();
} catch {
return String(w || "").trim().toLowerCase();
}
}
/** Ist das eine brauchbare Internetadresse?
*
* Filipe am 02.10.2026: jede gültige Internetadresse, nicht nur
* TikTok. Geprüft wird deshalb nur, dass es wirklich eine Adresse mit
* http(s) und einem Punkt im Namen ist — „tiktok" allein oder
* „javascript:…" sind keine. */
function linkOk(w) {
try {
const u = new URL(String(w).trim());
if (u.protocol !== "http:" && u.protocol !== "https:") return false;
return u.hostname.includes(".") && u.hostname.length >= 4;
} catch {
return false;
}
}
/** Prüft und normalisiert, was aus dem Formular kommt.
* Gibt `{ fehler }` oder `{ werte }` zurück — nie beides. */
function eintragPruefen(rumpf, monat, heute) {
const aufgabe = String(rumpf?.aufgabe || "");
const a = AUFGABE_VON.get(aufgabe);
if (!a) return { fehler: "aufgabe_unbekannt" };
const datum = String(rumpf?.datum || "").trim();
if (!istDatum(datum)) return { fehler: "datum_fehlt" };
/* KEIN DATUM IN DER ZUKUNFT und keines außerhalb des Monats
(Vorlage, Punkt 09). Beide Prüfungen gegen BERLINER Tage, nicht
gegen die Uhr des Servers. */
if (datum > heute) return { fehler: "datum_zukunft" };
if (datum.slice(0, 7) !== monat) return { fehler: "datum_fremder_monat" };
const werte = {
aufgabe, datum,
name: null, name_klein: null,
link: null, link_klein: null,
art: null,
notiz: sauber(rumpf?.notiz, NOTIZ_MAX) || null,
lead_id: null,
};
if (a.feld === "name") {
const name = sauber(rumpf?.name, NAME_MAX);
if (name.length < 2) return { fehler: "name_fehlt" };
werte.name = name;
werte.name_klein = nameSchluessel(name);
if (!werte.name_klein) return { fehler: "name_fehlt" };
}
if (a.feld === "link") {
const link = String(rumpf?.link || "").trim().slice(0, LINK_MAX);
if (!linkOk(link)) return { fehler: "link_ungueltig" };
werte.link = link;
werte.link_klein = linkSchluessel(link);
}
if (a.arten) {
const art = String(rumpf?.art || "");
if (!a.arten.some((x) => x.wert === art)) return { fehler: "art_fehlt" };
werte.art = art;
}
const lead = Number(rumpf?.lead_id);
if (Number.isInteger(lead) && lead > 0) werte.lead_id = lead;
return { werte };
}
/** Sucht Dubletten im selben Monat.
*
* ZWEI VERSCHIEDENE ANTWORTEN, und der Unterschied ist wichtig:
*
* EIGENE Dublette -> wird ABGELEHNT. Denselben Creator zweimal
* selbst einzutragen ist doppelt gezählt, und
* eine Zahl, die man sich selbst verdoppeln
* kann, ist keine Zahl.
* FREMDE Dublette -> wird ANGENOMMEN und vermerkt. Zwei Menschen
* können denselben Creator angesprochen haben;
* das ist eine Sache zum Klären, kein Grund,
* jemandem die Eingabe zu verweigern. DogFather
* sieht es in der Übersicht. */
function dublette(personId, monat, werte, ausser = 0) {
const spalte = werte.name_klein ? "name_klein" : (werte.link_klein ? "link_klein" : null);
if (!spalte) return { eigen: false, fremd: null };
const wert = werte.name_klein || werte.link_klein;
const eigen = db().prepare(
`SELECT id FROM mz_eintrag
WHERE person_id = ? AND monat = ? AND aufgabe = ? AND ${spalte} = ? AND id <> ?`)
.get(personId, monat, werte.aufgabe, wert, ausser);
if (eigen) return { eigen: true, fremd: null };
const fremd = db().prepare(
`SELECT e.person_id, p.name FROM mz_eintrag e
LEFT JOIN personen p ON p.id = e.person_id
WHERE e.person_id <> ? AND e.monat = ? AND e.aufgabe = ? AND e.${spalte} = ?
LIMIT 1`).get(personId, monat, werte.aufgabe, wert);
return { eigen: false, fremd: fremd || null };
}
/* =====================================================================
DIE FREIGABE FÜR ABGESCHLOSSENE MONATE
=====================================================================
Nur DogFather, nur für eine einzelne Handlung, nur zwei Minuten.
Die Vorlage sagt „Vergangene Monate sind gesperrt und nicht mehr
bearbeitbar (Ausnahme: DogFather)" — und der Trigger in der
Datenbank kann keine Sitzung lesen. Also wird die Ausnahme ein
sichtbarer Vorgang: Schloss auf, Handlung, Schloss zu, protokolliert.
`finally` und nicht am Ende des Blocks: Wirft die Handlung, bliebe
das Schloss sonst offen. Eine Sicherung, die bei einem Fehler
aufgeht, ist keine. */
function mitFreigabe(fn) {
const d = db();
const bis = new Date(Date.now() + FREIGABE_SEKUNDEN * 1000).toISOString()
.replace("T", " ").slice(0, 19);
d.prepare("UPDATE mz_freigabe SET offen = 1, bis = ? WHERE eins = 1").run(bis);
try {
return fn();
} finally {
d.prepare("UPDATE mz_freigabe SET offen = 0, bis = NULL WHERE eins = 1").run();
}
}
/* =====================================================================
DIE WEGE
===================================================================== */
/** Der eigene Stand (oder der einer anderen Person, für die Leitung). */
managerZieleRouter.get(`${BASIS}/stand`, (req, res) => {
try {
const monat = istMonat(req.query.monat) ? req.query.monat : monatJetzt();
if (monat < ERSTER_MONAT) {
return res.json({ vorErstemMonat: true, erster: ERSTER_MONAT, monat });
}
monatSichern(monat);
let person = req.person;
if (req.query.person && siehtAlles(req.person)) {
const p = db().prepare("SELECT id, name, rolle FROM personen WHERE id = ?")
.get(Number(req.query.person));
if (!p) return res.status(404).json({ fehler: "unbekannt" });
person = p;
}
const stand = standFuer(person, monat);
res.json({
...stand,
person: { id: person.id, name: person.name, rolle: person.rolle,
rollenName: ROLLEN_NAME[person.rolle] || person.rolle },
eigen: person.id === req.person.id,
darfZiele: siehtAlles(req.person),
darfKorrigieren: req.person.rolle === "admin",
erster: ERSTER_MONAT,
jetztMonat: monatJetzt(),
});
} catch (fehler) {
console.error("[manager-ziele] Stand:", fehler?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
/** Die Einträge einer Aufgabe in einem Monat. */
managerZieleRouter.get(`${BASIS}/eintraege`, (req, res) => {
try {
const monat = istMonat(req.query.monat) ? req.query.monat : monatJetzt();
const aufgabe = String(req.query.aufgabe || "");
if (!AUFGABE_VON.has(aufgabe)) return res.status(400).json({ fehler: "aufgabe_unbekannt" });
let personId = req.person.id;
if (req.query.person && siehtAlles(req.person)) personId = Number(req.query.person);
const zeilen = db().prepare(
`SELECT e.id, e.datum, e.name, e.link, e.art, e.notiz, e.lead_id,
e.erstellt, e.geaendert, e.doppelt_bei,
p.name AS doppelt_name, l.status AS lead_status, l.name AS lead_name
FROM mz_eintrag e
LEFT JOIN personen p ON p.id = e.doppelt_bei
LEFT JOIN leads l ON l.id = e.lead_id
WHERE e.person_id = ? AND e.monat = ? AND e.aufgabe = ?
ORDER BY e.datum DESC, e.id DESC`).all(personId, monat, aufgabe);
res.json({
monat, aufgabe, eintraege: zeilen,
/* Ob die Liste bearbeitbar ist, entscheidet der Server und nicht
der Browser — sonst wäre der graue Knopf die ganze Sicherung.
ZWEI BEDINGUNGEN, UND DIE ERSTE HATTE GEFEHLT: Es muss die
eigene Liste sein (oder man ist DogFather), UND der Monat
muss offen sein (oder man ist DogFather). Ohne die erste sah
Spicy Media an einer fremden Liste „Bearbeiten" und
„Löschen" stehen, und der Server antwortete mit 403 — ein
Knopf, der nichts tut, ist schlimmer als kein Knopf. */
bearbeitbar: (personId === req.person.id || req.person.rolle === "admin")
&& (monat === monatJetzt() || req.person.rolle === "admin"),
});
} catch (fehler) {
console.error("[manager-ziele] Eintraege:", fehler?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
/** Einen Eintrag anlegen. */
managerZieleRouter.post(`${BASIS}/eintrag`, (req, res) => {
try {
const heute = tagBerlin();
const monat = monatJetzt();
monatSichern(monat);
const gepruefte = eintragPruefen(req.body, monat, heute);
if (gepruefte.fehler) return res.status(400).json({ fehler: gepruefte.fehler });
const w = gepruefte.werte;
const doppel = dublette(req.person.id, monat, w);
if (doppel.eigen) {
return res.status(409).json({
fehler: "schon_eingetragen",
text: w.name
? `„${w.name}" steht diesen Monat schon in deiner Liste.`
: "Dieses Video steht diesen Monat schon in deiner Liste.",
});
}
/* Der Lead wird nur übernommen, wenn er wirklich existiert und zu
dieser Person gehört. Eine Nummer in der Adresse ist kein
Besitznachweis — dieselbe Lücke, die am 03.09.2026 bei
`darfCreator` steckte. */
if (w.lead_id) {
const l = db().prepare("SELECT id, scout_id, erstellt_von FROM leads WHERE id = ?")
.get(w.lead_id);
const meiner = l && (l.scout_id === req.person.id || l.erstellt_von === req.person.id);
if (!meiner && !siehtAlles(req.person)) w.lead_id = null;
else if (!l) w.lead_id = null;
}
/* NAME UND ROLLE WERDEN MITGESCHRIEBEN, nicht nur verknuepft.
Zwei Gruende, und beide stehen in der Vorlage: „Wer die Rolle
verliert, sieht die Kachel nicht mehr; die Daten bleiben fuer
den DogFather erhalten." Ohne diese zwei Spalten staende nach
einem Rollenwechsel ein abgeschlossener Monat ploetzlich an den
Zielzahlen der NEUEN Rolle -- und nach einer Loeschung ohne
Namen da. */
const info = db().prepare(
`INSERT INTO mz_eintrag
(person_id, von_name, von_rolle, aufgabe, monat, datum, name, name_klein,
link, link_klein, art, notiz, lead_id, doppelt_bei, erstellt)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`).run(
req.person.id, req.person.name || null, req.person.rolle || null,
w.aufgabe, monat, w.datum, w.name, w.name_klein,
w.link, w.link_klein, w.art, w.notiz, w.lead_id,
doppel.fremd?.person_id ?? null, jetzt());
res.status(201).json({
id: Number(info.lastInsertRowid),
stand: standFuer(req.person, monat),
warnung: doppel.fremd
? { art: "auch_bei", name: doppel.fremd.name,
text: `Hinweis: ${doppel.fremd.name || "jemand anderes"} hat das diesen Monat auch eingetragen.` }
: null,
});
} catch (fehler) {
if (String(fehler?.message || "").includes("monat_gesperrt")) {
return res.status(409).json({ fehler: "monat_gesperrt" });
}
console.error("[manager-ziele] Anlegen:", fehler?.message);
res.status(503).json({ fehler: "nicht_gespeichert" });
}
});
/** Einen Eintrag ändern. */
managerZieleRouter.patch(`${BASIS}/eintrag/:id`, (req, res) => {
try {
const id = Number(req.params.id);
const alt = db().prepare("SELECT * FROM mz_eintrag WHERE id = ?").get(id);
if (!alt) return res.status(404).json({ fehler: "unbekannt" });
const fremd = alt.person_id !== req.person.id;
const istAdmin = req.person.rolle === "admin";
if (fremd && !istAdmin) return res.status(403).json({ fehler: "nicht_deins" });
const heute = tagBerlin();
const gepruefte = eintragPruefen({ ...req.body, aufgabe: alt.aufgabe }, alt.monat, heute);
if (gepruefte.fehler) return res.status(400).json({ fehler: gepruefte.fehler });
const w = gepruefte.werte;
const doppel = dublette(alt.person_id, alt.monat, w, id);
if (doppel.eigen) return res.status(409).json({ fehler: "schon_eingetragen" });
const schreiben = () => db().prepare(
`UPDATE mz_eintrag
SET datum = ?, name = ?, name_klein = ?, link = ?, link_klein = ?,
art = ?, notiz = ?, geaendert = ?, geaendert_von = ?
WHERE id = ?`).run(
w.datum, w.name, w.name_klein, w.link, w.link_klein, w.art, w.notiz,
jetzt(), req.person.id, id);
const altMonat = alt.monat !== monatJetzt();
if (altMonat && !istAdmin) return res.status(409).json({ fehler: "monat_gesperrt" });
if (altMonat) mitFreigabe(schreiben); else schreiben();
if (fremd || altMonat) {
protokolliere("manager_ziele_korrigiert", {
personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req),
detail: `Eintrag ${id} (${alt.aufgabe}, ${alt.monat}) von Person ${alt.person_id}`,
});
}
const wem = db().prepare("SELECT id, name, rolle FROM personen WHERE id = ?")
.get(alt.person_id) || req.person;
res.json({ ok: true, stand: standFuer(wem, alt.monat) });
} catch (fehler) {
if (String(fehler?.message || "").includes("monat_gesperrt")) {
return res.status(409).json({ fehler: "monat_gesperrt" });
}
console.error("[manager-ziele] Aendern:", fehler?.message);
res.status(503).json({ fehler: "nicht_gespeichert" });
}
});
/** Einen Eintrag löschen. */
managerZieleRouter.delete(`${BASIS}/eintrag/:id`, (req, res) => {
try {
const id = Number(req.params.id);
const alt = db().prepare("SELECT * FROM mz_eintrag WHERE id = ?").get(id);
if (!alt) return res.status(404).json({ fehler: "unbekannt" });
const fremd = alt.person_id !== req.person.id;
const istAdmin = req.person.rolle === "admin";
if (fremd && !istAdmin) return res.status(403).json({ fehler: "nicht_deins" });
const altMonat = alt.monat !== monatJetzt();
if (altMonat && !istAdmin) return res.status(409).json({ fehler: "monat_gesperrt" });
const loeschen = () => db().prepare("DELETE FROM mz_eintrag WHERE id = ?").run(id);
if (altMonat) mitFreigabe(loeschen); else loeschen();
if (fremd || altMonat) {
protokolliere("manager_ziele_geloescht", {
personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req),
detail: `Eintrag ${id} (${alt.aufgabe}, ${alt.monat}) von Person ${alt.person_id}`,
});
}
const wem = db().prepare("SELECT id, name, rolle FROM personen WHERE id = ?")
.get(alt.person_id) || req.person;
res.json({ ok: true, stand: standFuer(wem, alt.monat) });
} catch (fehler) {
if (String(fehler?.message || "").includes("monat_gesperrt")) {
return res.status(409).json({ fehler: "monat_gesperrt" });
}
console.error("[manager-ziele] Loeschen:", fehler?.message);
res.status(503).json({ fehler: "nicht_geloescht" });
}
});
/* =====================================================================
DIE BRÜCKE ZUR SCOUT-PIPELINE
===================================================================== */
/** Übergebene Leads dieses Monats, die noch nicht eingetragen sind.
*
* KEIN AUTOMATISMUS. Die Liste schlägt vor, sie zählt nicht. Wer
* einen Lead übernimmt, bestätigt damit die Rekrutierung — und erst
* dann steht sie in der Zahl. Ein Zähler, der sich von selbst füllt,
* ist ein Zähler, dem niemand mehr glaubt. */
managerZieleRouter.get(`${BASIS}/vorschlaege`, (req, res) => {
try {
const monat = monatJetzt();
const zeilen = db().prepare(
`SELECT l.id, l.name, l.handle, l.plattform, l.uebergeben_am
FROM leads l
WHERE l.status = 'uebergeben'
AND l.uebergeben_am IS NOT NULL
AND substr(l.uebergeben_am, 1, 7) = ?
AND (l.scout_id = ? OR l.erstellt_von = ?)
AND NOT EXISTS (SELECT 1 FROM mz_eintrag e
WHERE e.lead_id = l.id AND e.monat = ?)
ORDER BY l.uebergeben_am DESC
LIMIT 25`).all(monat, req.person.id, req.person.id, monat);
res.json({ monat, vorschlaege: zeilen });
} catch (fehler) {
console.error("[manager-ziele] Vorschlaege:", fehler?.message);
res.json({ monat: monatJetzt(), vorschlaege: [] });
}
});
/** Namensvorschläge beim Tippen — aus der eigenen Pipeline. */
managerZieleRouter.get(`${BASIS}/namen`, (req, res) => {
try {
const q = nameSchluessel(req.query.q);
if (q.length < 2) return res.json({ treffer: [] });
const zeilen = db().prepare(
`SELECT id, name, handle, status FROM leads
WHERE (scout_id = ? OR erstellt_von = ?)
AND (replace(replace(lower(name), ' ', ''), '@', '') LIKE ?
OR replace(replace(lower(COALESCE(handle,'')), ' ', ''), '@', '') LIKE ?)
ORDER BY erstellt DESC LIMIT 8`)
.all(req.person.id, req.person.id, `%${q}%`, `%${q}%`);
res.json({ treffer: zeilen });
} catch (fehler) {
console.error("[manager-ziele] Namen:", fehler?.message);
res.json({ treffer: [] });
}
});
/* =====================================================================
DER VERLAUF
===================================================================== */
managerZieleRouter.get(`${BASIS}/verlauf`, (req, res) => {
try {
let person = req.person;
if (req.query.person && siehtAlles(req.person)) {
const p = db().prepare("SELECT id, name, rolle FROM personen WHERE id = ?")
.get(Number(req.query.person));
if (!p) return res.status(404).json({ fehler: "unbekannt" });
person = p;
}
const jetztM = monatJetzt();
const start = startMonatFuer(person.id);
const von = start > ERSTER_MONAT ? start : ERSTER_MONAT;
const monate = [];
let m = monatPlus(jetztM, -1);
/* Höchstens 24 Monate zurück — ein Verlauf, der endlos scrollt,
wird nicht gelesen. Die Daten bleiben trotzdem alle da. */
for (let i = 0; i < 24 && m >= von; i++, m = monatPlus(m, -1)) {
const s = standFuer(person, m);
monate.push({
monat: m,
gesamt: s.gesamt,
aufgaben: s.aufgaben.map((a) => ({
schluessel: a.schluessel, kurz: a.kurz,
zahl: a.zahl, ziel: a.ziel,
erreicht: a.ziel === 0 ? null : a.zahl >= a.ziel,
})),
});
}
res.json({ monate, von, person: { id: person.id, name: person.name } });
} catch (fehler) {
console.error("[manager-ziele] Verlauf:", fehler?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
/* =====================================================================
DIE TEAM-ÜBERSICHT — nur DogFather und Spicy Media
===================================================================== */
/** Alle pflichtigen Personen mit ihrem Stand in einem Monat. */
function teamStand(monat) {
const platzhalter = PFLICHT_ROLLEN.map(() => "?").join(",");
/* WER IN DIESER LISTE STEHT -- und warum es nicht nur die aktiven
vier Rollen sind.
Aus der Vorlage: „Wer die Rolle verliert, sieht die Kachel nicht
mehr; die Daten bleiben fuer den DogFather erhalten." Eine
Abfrage nur auf `aktiv = 1 AND rolle IN (...)` haette genau das
nicht geleistet: Wer im Oktober drei Creator gebracht hat und im
November Creator wird, waere aus dem Oktober verschwunden -- aus
einem abgeschlossenen Monat, den niemand mehr aendern darf.
Deshalb zweiteilig: die heute Pflichtigen UND jeder, der in
diesem Monat etwas eingetragen hat. Und fuer die zweite Gruppe
zaehlt die Rolle VON DAMALS (`von_rolle` am Eintrag), nicht die
von heute -- sonst wuerde ein abgeschlossener Monat rueckwirkend
an den Zielzahlen einer anderen Rolle gemessen. */
const leute = db().prepare(
`SELECT p.id, p.name, p.rolle, p.aktiv,
(SELECT e.von_rolle FROM mz_eintrag e
WHERE e.person_id = p.id AND e.monat = ? AND e.von_rolle IS NOT NULL
LIMIT 1) AS rolle_damals
FROM personen p
WHERE (p.aktiv = 1 AND p.rolle IN (${platzhalter}))
OR EXISTS (SELECT 1 FROM mz_eintrag e
WHERE e.person_id = p.id AND e.monat = ?)
ORDER BY p.rolle, p.name COLLATE NOCASE`)
.all(monat, ...PFLICHT_ROLLEN, monat);
const reihen = leute.map((p) => {
const heutePflichtig = p.aktiv === 1 && PFLICHT_ROLLEN.includes(p.rolle);
const rolle = heutePflichtig ? p.rolle : (p.rolle_damals || p.rolle);
const s = standFuer({ id: p.id, name: p.name, rolle }, monat);
return {
id: p.id, name: p.name, rolle,
rollenName: ROLLEN_NAME[rolle] || rolle,
/* „Nicht mehr dabei" ist etwas anderes als „neu und noch ohne
Pflicht". Beides getrennt zu nennen erspart die Rueckfrage,
warum jemand mit 0/3 nicht rot ist. */
ehemalig: !heutePflichtig,
pflichtig: s.pflichtig,
stufe: s.stufe,
gesamt: s.gesamt,
aufgaben: s.aufgaben.map((a) => ({
schluessel: a.schluessel, zahl: a.zahl, ziel: a.ziel, stufe: a.stufe,
})),
};
});
/* EINE GELOESCHTE PERSON hat keine Zeile in `personen` mehr, ihre
Eintraege aber schon (person_id ist dann NULL). Sie als eigene,
zusammengefasste Zeile zu zeigen ist ehrlicher, als die Zahlen
verschwinden zu lassen -- im Monatsverlauf fehlten sonst
Eintraege, die es wirklich gab. */
const verwaist = db().prepare(
`SELECT COALESCE(von_name, 'Gelöschter Zugang') AS name,
COALESCE(von_rolle, 'scout') AS rolle,
aufgabe, COUNT(*) AS n
FROM mz_eintrag
WHERE person_id IS NULL AND monat = ?
GROUP BY name, rolle, aufgabe`).all(monat);
if (verwaist.length) {
const nachName = new Map();
for (const v of verwaist) {
const schluessel = `${v.name}|${v.rolle}`;
if (!nachName.has(schluessel)) nachName.set(schluessel, {});
nachName.get(schluessel)[v.aufgabe] = v.n;
}
for (const [schluessel, zahlen] of nachName) {
const [name, rolle] = schluessel.split("|");
const aufgaben = AUFGABEN.map((a) => {
const ziel = zielFuer(monat, rolle, a.schluessel);
const zahl = zahlen[a.schluessel] || 0;
return { schluessel: a.schluessel, zahl, ziel, stufe: zahl >= ziel && ziel > 0 ? "gruen" : "aus" };
});
reihen.push({
id: null, name, rolle, rollenName: ROLLEN_NAME[rolle] || rolle,
ehemalig: true, pflichtig: false, stufe: "aus",
gesamt: {
erledigt: aufgaben.reduce((x, a) => x + Math.min(a.zahl, a.ziel), 0),
ziel: aufgaben.reduce((x, a) => x + a.ziel, 0),
anteil: 100, offen: 0,
},
aufgaben,
});
}
}
return reihen;
}
managerZieleRouter.get(`${BASIS}/team`, nurLeitung, (req, res) => {
try {
const monat = istMonat(req.query.monat) ? req.query.monat : monatJetzt();
if (monat < ERSTER_MONAT) return res.json({ monat, leute: [], erster: ERSTER_MONAT });
monatSichern(monat);
res.json({
monat, erster: ERSTER_MONAT, jetztMonat: monatJetzt(),
leute: teamStand(monat),
aufgaben: AUFGABEN.map((a) => ({ schluessel: a.schluessel, kurz: a.kurz, name: a.name })),
});
} catch (fehler) {
console.error("[manager-ziele] Team:", fehler?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
/** Der Monat als CSV.
*
* SEMIKOLON UND BOM, und beides aus demselben Grund: Ein deutsches
* Excel trennt an Semikolons und erkennt UTF-8 nur mit Vorzeichen.
* Eine Datei mit Komma und ohne BOM öffnet sich als eine einzige
* Spalte voller „ü" — technisch richtig und praktisch unbrauchbar. */
managerZieleRouter.get(`${BASIS}/export.csv`, nurLeitung, (req, res) => {
try {
const monat = istMonat(req.query.monat) ? req.query.monat : monatJetzt();
monatSichern(monat);
const leute = teamStand(monat);
const feld = (w) => {
const t = String(w ?? "");
return /[";\n\r]/.test(t) ? `"${t.replace(/"/g, '""')}"` : t;
};
const kopf = ["Name", "Rolle", "Stand",
...AUFGABEN.flatMap((a) => [`${a.kurz} erreicht`, `${a.kurz} Ziel`]),
"Gesamt erledigt", "Gesamt Ziel", "Ampel"];
const zeilen = [kopf.map(feld).join(";")];
for (const p of leute) {
/* DREI STAENDE, NICHT ZWEI. „nein" hiesse sonst dasselbe fuer
jemanden, der gerade erst dazugekommen ist, und fuer jemanden,
der nicht mehr dabei ist -- und wer die Liste spaeter liest,
kann die beiden nicht mehr auseinanderhalten. */
const stand = p.ehemalig ? "nicht mehr dabei"
: (p.pflichtig ? "pflichtig" : "neu, noch ohne Pflicht");
zeilen.push([
p.name, p.rollenName, stand,
...p.aufgaben.flatMap((a) => [a.zahl, a.ziel]),
p.gesamt.erledigt, p.gesamt.ziel, p.stufe,
].map(feld).join(";"));
}
res.setHeader("Content-Type", "text/csv; charset=utf-8");
res.setHeader("Content-Disposition",
`attachment; filename="manager-ziele-${monat}.csv"`);
res.send("" + zeilen.join("\r\n") + "\r\n");
} catch (fehler) {
console.error("[manager-ziele] Export:", fehler?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
/* =====================================================================
DIE ZIELZAHLEN
===================================================================== */
managerZieleRouter.get(`${BASIS}/ziele`, nurLeitung, (req, res) => {
try {
const jetztM = monatJetzt();
const naechster = monatPlus(jetztM, 1);
monatSichern(jetztM);
const bauen = (monat) => PFLICHT_ROLLEN.map((rolle) => ({
rolle, rollenName: ROLLEN_NAME[rolle] || rolle,
ziele: Object.fromEntries(AUFGABEN.map((a) =>
[a.schluessel, monat === jetztM
? zielFuer(monat, rolle, a.schluessel)
: zielGeltend(monat, rolle, a.schluessel)])),
}));
res.json({
jetztMonat: jetztM, naechsterMonat: naechster,
aufgaben: AUFGABEN.map((a) => ({
schluessel: a.schluessel, name: a.name, kurz: a.kurz, vorgabe: a.vorgabe,
})),
jetzt: bauen(jetztM),
naechster: bauen(naechster),
});
} catch (fehler) {
console.error("[manager-ziele] Ziele lesen:", fehler?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
/** Ziele setzen — IMMER für den nächsten Monat.
*
* Aus der Vorlage: „Änderungen gelten ab dem nächsten Monat." Das ist
* hier keine Anzeige, sondern der Weg selbst: Der laufende Monat ist
* bereits festgeschrieben, geschrieben wird in den nächsten. Es gibt
* gar keinen Aufruf, mit dem man den laufenden Monat umschreiben
* könnte — eine Regel, die man nicht umgehen kann, braucht keine
* Disziplin. */
managerZieleRouter.put(`${BASIS}/ziele`, nurLeitung, (req, res) => {
try {
const naechster = monatPlus(monatJetzt(), 1);
const eingabe = req.body?.ziele;
if (!eingabe || typeof eingabe !== "object") {
return res.status(400).json({ fehler: "nichts_angegeben" });
}
const setzen = db().prepare(
`INSERT INTO mz_ziel (monat, rolle, aufgabe, ziel, gesetzt_von, gesetzt_am)
VALUES (?,?,?,?,?,?)
ON CONFLICT(monat, rolle, aufgabe)
DO UPDATE SET ziel = excluded.ziel, gesetzt_von = excluded.gesetzt_von,
gesetzt_am = excluded.gesetzt_am`);
const am = jetzt();
let gesetzt = 0;
for (const rolle of PFLICHT_ROLLEN) {
const je = eingabe[rolle];
if (!je || typeof je !== "object") continue;
for (const a of AUFGABEN) {
if (!(a.schluessel in je)) continue;
const wert = Number(je[a.schluessel]);
if (!Number.isInteger(wert) || wert < 0 || wert > 99) {
return res.status(400).json({ fehler: "ziel_ungueltig", rolle, aufgabe: a.schluessel });
}
setzen.run(naechster, rolle, a.schluessel, wert, req.person.id, am);
gesetzt++;
}
}
if (!gesetzt) return res.status(400).json({ fehler: "nichts_angegeben" });
protokolliere("manager_ziele_gesetzt", {
personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req),
detail: `${gesetzt} Zielzahlen für ${naechster}`,
});
res.json({ ok: true, monat: naechster, gesetzt });
} catch (fehler) {
console.error("[manager-ziele] Ziele setzen:", fehler?.message);
res.status(503).json({ fehler: "nicht_gespeichert" });
}
});
/* =====================================================================
DIE HINWEISE FÜR DIE STARTSEITE
=====================================================================
Liefert, was `workspace-hinweise.js` braucht — und zwar aus
derselben Rechnung wie die Seite selbst (`standFuer`). Die Zahl auf
der Kachel ist damit kein zweiter Zähler, sondern dieselbe Auskunft
an einer anderen Stelle.
===================================================================== */
export function offeneHinweise(person) {
try {
if (!person || person.haus === "crew") return [];
if (!PFLICHT_ROLLEN.includes(person.rolle)) return [];
const monat = monatJetzt();
if (monat < ERSTER_MONAT) return [];
lageSetzen(monat);
monatSichern(monat);
const stand = standFuer(person, monat);
if (!stand.pflichtig || !stand.gesamt.offen) return [];
/* Zwei Hinweise, nicht einer: Eine Warnung steht im Haus immer
oben und ist etwas anderes als „liegt noch an". Wer am 3. des
Monats dieselbe rote Zeile sieht wie am 29., liest sie beim
zweiten Mal nicht mehr. */
const dringend = stand.aufgaben.filter((a) => a.stufe === "rot").length;
const offen = stand.gesamt.offen - dringend;
const raus = [];
if (dringend) {
raus.push({ art: "mz_dringend", stufe: "warnung",
text: "dringend", ziel: "manager-ziele.html", anzahl: dringend });
}
if (offen > 0) {
raus.push({ art: "mz_offen", stufe: "offen",
text: "offen", ziel: "manager-ziele.html", anzahl: offen });
}
return raus;
} catch (fehler) {
console.error("[manager-ziele] Hinweise:", fehler?.message);
return [];
}
}
/* =====================================================================
DIE ERINNERUNGEN
=====================================================================
Diese Funktion VERSCHICKT NICHTS. Sie sagt nur, was heute anstünde;
verschickt wird im Takt von workspace-push.js (siehe Kopf dieser
Datei: kein Importkreis).
DER ANLASS-PLAN kommt wörtlich aus der Vorlage. Dass pro Tag
höchstens EINE Meldung herausgeht, steckt im Merkmal: Es enthält
den Tag, und `benachrichtige` schickt ein Merkmal nie zweimal.
Fallen zwei Anlässe auf denselben Tag (etwa der 1. eines Monats,
der zugleich drei Tage vor Monatsende läge — es gibt keinen solchen
Monat, aber die Rechnung soll auch dann stimmen), gewinnt der
dringendere, und der andere entfällt.
===================================================================== */
export function zielRufe(d = new Date()) {
try {
const heute = tagBerlin(d);
const monat = heute.slice(0, 7);
if (monat < ERSTER_MONAT) return [];
lageSetzen(monat);
monatSichern(monat);
const tag = Number(heute.slice(8, 10));
const letzter = letzterTagVon(monat);
const bisEnde = letzter - tag;
const leute = db().prepare(
`SELECT id, name, rolle FROM personen
WHERE aktiv = 1 AND rolle IN (${PFLICHT_ROLLEN.map(() => "?").join(",")})`)
.all(...PFLICHT_ROLLEN);
const rufe = [];
for (const p of leute) {
const stand = standFuer(p, monat, heute);
if (!stand.pflichtig) continue;
/* ---- Alles geschafft: einmal gratulieren, dann Ruhe ---------- */
if (!stand.gesamt.offen) {
if (stand.gesamt.ziel > 0) {
rufe.push({
personId: p.id, art: "manager_ziele",
titel: "Alles geschafft 🎉",
text: `Du hast diesen Monat alle ${stand.gesamt.ziel} Ziele erreicht. Danke!`,
/* ABSOLUT, nicht relativ: Push-Ziele gehen durch
`zielFuer()` und werden mit dem Pfad verglichen. Die
Hinweise auf der Startseite setzen das "/workspace/"
dagegen selbst davor -- deshalb steht es dort nicht. */
ziel: "/workspace/manager-ziele.html",
merkmal: `mz:${monat}:fertig`,
});
}
continue;
}
/* ---- Was genau fehlt, als Satz ------------------------------- */
const fehlt = stand.aufgaben.filter((a) => a.ziel > 0 && a.zahl < a.ziel)
.map((a) => {
const n = a.ziel - a.zahl;
return `${n} ${n === 1 ? a.einheit[0] : a.einheit[1]}`;
});
const liste = fehlt.length === 1 ? fehlt[0]
: fehlt.slice(0, -1).join(", ") + " und " + fehlt[fehlt.length - 1];
let titel = null;
if (bisEnde <= 2) {
titel = bisEnde <= 0 ? "Letzter Tag des Monats" : `Noch ${bisEnde} Tag${bisEnde === 1 ? "" : "e"}`;
} else if (bisEnde === 7) {
titel = "Dringend – noch 7 Tage";
} else if (tag === 16) {
titel = "Monatsmitte ist vorbei";
} else if (tag === 15) {
titel = "Heute ist Stichtag Monatsmitte";
} else if (tag === 10) {
titel = "Kurze Erinnerung";
} else if (tag === 1) {
titel = "Neuer Monat";
}
if (!titel) continue;
const text = tag === 1
? "Deine Aufgaben starten bei 0. Viel Erfolg!"
: `Dir fehlt noch ${liste}.`;
rufe.push({
personId: p.id, art: "manager_ziele", titel, text,
ziel: "/workspace/manager-ziele.html",
/* DER TAG STEHT IM MERKMAL, nicht der Anlass: So kann pro Tag
nur eine einzige Meldung herausgehen — genau das verlangt
die Vorlage. */
merkmal: `mz:${heute}`,
zahl: stand.gesamt.offen,
});
}
return rufe;
} catch (fehler) {
console.error("[manager-ziele] Rufe:", fehler?.message);
return [];
}
}