GEFUNDEN ALS FEHLALARM, GEBLIEBEN IST EIN ECHTER FUND.
pruef-struktur meldete:
assets/js/manager-ziele.js: Schnittstelle gibt es nicht
-> /workspace/api/manager-ziele
Nachgesehen statt geglaubt: Die Seite ruft diese Adresse NIE auf. In
Zeile 31 steht `const BASIS = '/workspace/api/manager-ziele'`, benutzt
wird ausschliesslich `${BASIS}/stand`, `${BASIS}/eintraege`,
`${BASIS}/eintrag/${id}`.
DER EIGENTLICHE FUND LAG EINE EBENE TIEFER. Der SERVER registriert
seine Routen genauso:
const BASIS = "/workspace/api/manager-ziele";
managerZieleRouter.get(`${BASIS}/stand`, ...)
Die Sammelregel verlangte aber, dass ein Routenpfad direkt mit
`/workspace/` beginnt. ALLE DREIZEHN Routen dieses Moduls fehlten
damit in der Liste -- gemessen, nicht geschaetzt. Die Wache war also
nicht zu streng, sie war UNVOLLSTAENDIG: Zu diesen Routen konnte sie
gar nichts sagen, weder dass es sie gibt noch dass es sie nicht gibt.
Und weil die Aufrufe dorthin ebenfalls zusammengesetzt sind, ist es
nie aufgefallen -- ausser an dieser einen nackten Konstanten.
Server-Routen eingelesen: 401 -> 414
GEAENDERT
1. Einfache Praefix-Konstanten werden je Datei aufgeloest. Mehr
nicht: Wer seinen Pfad aus drei Variablen zusammensetzt, bleibt
unauffindbar -- und das ist richtig so, denn geraten wird hier
nicht. Ohne bekannten Wert wird GAR NICHTS eingetragen; eine
geratene Route waere schlimmer als eine fehlende, weil sie einen
toten Aufruf als lebendig durchgehen liesse.
2. Eine nackte Praefix-Konstante im Browser gilt als Praefix, wenn
es Routen DARUNTER gibt. Abgeleitet, nicht aufgezaehlt -- eine
Ausnahmeliste mit „manager-ziele" darin waere die naechste, die
niemand pflegt.
GEGENPROBEN IN BEIDE RICHTUNGEN, weil die neue Regel etwas
durchlaesst:
eine Praefix-Konstante wird als Praefix erkannt (Routen darunter)
eine erfundene Adresse OHNE Routen darunter bleibt ein Fund
und eine echte Route bleibt eine echte Route, kein Praefix
DAZU VIER SCHLUSSZEICHEN. Die Wache fuer das deutsche
Anfuehrungszeichen meldete vier Stellen in denselben zwei Dateien:
`„${was}" löschen?` und drei Geschwister. Berichtigt auf `“`.
UND EINE KORREKTUR AN MIR: Ich habe pruef-struktur heute dreimal als
„99 ok, Exitcode 0" protokolliert und dabei zwei rote Zeilen
uebersehen -- mein Filter zeigte nur die ersten drei FEHL-Zeilen und
die Zahl der gruenen. Gefunden habe ich es erst, als dieselbe Pruefung
spaeter Exitcode 1 meldete. Nachgemessen mit `git stash`: Die zwei
Funde stecken auch in HEAD, sie sind also nicht aus meiner laufenden
Arbeit gekommen. Eine Zusammenfassung, die nur die gruenen Zeilen
zaehlt, ist keine.
GEPRUEFT: pruef-struktur 102 ok, 0 Fehler.
Co-Authored-By: Claude Opus 5 <[email protected]>
1482 lines
60 KiB
JavaScript
1482 lines
60 KiB
JavaScript
/* =====================================================================
|
||
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.
|
||
|
||
DASSELBE GILT SEIT DEM 02.10.2026 FÜRS KORRIGIEREN. Die Vorlage
|
||
kannte dort nur DogFather; auf die Rückfrage: „ja spicy und
|
||
dogfather". Beides liegt jetzt auf derselben Menge
|
||
(`siehtAlles`) — wer alle Zahlen sieht und die Ziele setzt, muss
|
||
auch einen Zahlendreher gerade rücken können.
|
||
|
||
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 "";
|
||
}
|
||
|
||
/** Wer fremde Eintraege korrigieren und abgeschlossene Monate oeffnen
|
||
* darf.
|
||
*
|
||
* BEIDE ROLLEN, NICHT NUR DOGFATHER (02.10.2026). Die Vorlage sagte
|
||
* „Ausnahme: DogFather"; Filipe auf die Rueckfrage: „ja spicy und
|
||
* dogfather". Damit gilt hier dieselbe Menge wie bei den Zielzahlen
|
||
* und bei der Team-Uebersicht -- und das ist der eigentliche Grund,
|
||
* es so zu machen: Wer die Zahlen aller sieht und die Ziele setzt,
|
||
* muss auch einen Zahlendreher gerade ruecken koennen. Zwei
|
||
* verschiedene Grenzen fuer „darf alles sehen" und „darf etwas
|
||
* richtigstellen" waeren die Stelle, an der sich spaeter niemand
|
||
* mehr erklaeren kann, warum ein Knopf fehlt.
|
||
*
|
||
* ES IST `siehtAlles` UND KEINE EIGENE LISTE. Eine zweite Aufzaehlung
|
||
* derselben zwei Rollen waere die, die beim naechsten Umbau
|
||
* auseinanderlaeuft.
|
||
*
|
||
* UND ES BLEIBT NACHVOLLZIEHBAR: Jede Korrektur an einem fremden
|
||
* Eintrag und jede Aenderung an einem abgeschlossenen Monat steht
|
||
* mit Name und Zeit im Protokoll -- unveraendert, auch jetzt, wo es
|
||
* zwei Menschen tun koennen statt einem. */
|
||
const darfKorrigieren = (person) => siehtAlles(person);
|
||
|
||
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.
|
||
===================================================================== */
|
||
/** Wann hat sich DIE ROLLE DIESER PERSON zuletzt geändert?
|
||
*
|
||
* BERICHTIGT AM 02.10.2026 -- und das war ein echter Fehler, kein
|
||
* Schönheitsfleck.
|
||
*
|
||
* Hier stand `WHERE person_id = ?`. Im Protokoll steht unter
|
||
* `person_id` aber der, der die Änderung GEMACHT hat (also
|
||
* DogFather), nicht der, dessen Rolle sich geändert hat -- siehe
|
||
* workspace-personen.js: `protokolliere("rolle_geaendert",
|
||
* { personId: req.person.id, … detail: "#<id> <name>: alt -> neu" })`.
|
||
*
|
||
* Die Abfrage hat damit zwei Dinge falsch gemacht, und beide sahen
|
||
* richtig aus:
|
||
*
|
||
* 1. Für den BETROFFENEN fand sie nie etwas -- sein Pflichtbeginn
|
||
* wurde aus dem Anlegedatum gerechnet, als hätte er die Rolle
|
||
* seit jeher.
|
||
* 2. Für DogFather fand sie JEDE Rollenänderung, die er an anderen
|
||
* vorgenommen hat, und schob SEINEN Pflichtbeginn davon. In der
|
||
* echten Datenbank stand deshalb bei ihm „grund: rollenwechsel"
|
||
* -- ein plausibler Wert, der aus der falschen Zeile kam.
|
||
*
|
||
* Gefunden hat es nicht die Prüfung, sondern ein Blick in die Quelle
|
||
* beim Nachlesen, wie das Protokoll geschrieben wird. Das ist genau
|
||
* die Sorte Fehler, vor der die Hausnotiz warnt: Eine Prüfung, die
|
||
* grün ist, weil sie das Falsche misst.
|
||
*
|
||
* JETZT ÜBER DAS `detail`, denn dort steht die betroffene Nummer.
|
||
* Das ist eine Textsuche in einem Protokolltext und damit die
|
||
* schwächste Stelle hier -- deshalb wird das Ergebnis nicht jedes
|
||
* Mal neu gerechnet, sondern in `mz_start` festgehalten. */
|
||
function rollenWechselAm(personId) {
|
||
/* DAS MUSTER WIRD HIER GEBAUT UND NICHT IN SQL ZUSAMMENGESETZT.
|
||
|
||
Hier stand `detail LIKE ('#' || ? || ' %')`, und das hat NIE
|
||
getroffen -- ohne Fehler, ohne Warnung, einfach immer leer.
|
||
Nachgemessen in einer leeren Datenbank:
|
||
|
||
SELECT ('#' || ? || ' %') mit der Zahl 42 -> '#42.0 %'
|
||
|
||
`node:sqlite` bindet eine JavaScript-Zahl als REAL, und SQLite
|
||
macht aus 42.0 beim Verketten den Text „42.0". Gesucht wurde
|
||
also nach „#42.0 ", gespeichert ist „#42 ". Mit derselben Zahl
|
||
als Zeichenkette (`"42"`) stimmt es sofort.
|
||
|
||
Das ist genau die Sorte Fehler, vor der die Hausnotiz warnt: Die
|
||
Abfrage lief, sie war fehlerfrei, und sie war wertlos. Gefunden
|
||
hat sie nicht das Lesen, sondern eine Pruefung, die den ECHTEN
|
||
Weg benutzt hat statt den Protokolltext selbst zu schreiben.
|
||
|
||
`Number.isInteger` davor ist kein Schmuck: Eine Zeichenkette mit
|
||
`%` darin waere sonst ein Platzhalter im Muster. */
|
||
if (!Number.isInteger(Number(personId))) return null;
|
||
try {
|
||
return db().prepare(
|
||
`SELECT zeitpunkt FROM protokoll
|
||
WHERE aktion = 'rolle_geaendert' AND detail LIKE ?
|
||
ORDER BY zeitpunkt DESC LIMIT 1`)
|
||
.get(`#${Number(personId)} %`)?.zeitpunkt || null;
|
||
} catch (fehler) {
|
||
console.error("[manager-ziele] Rollenwechsel suchen:", fehler?.message);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function startMonatFuer(personId) {
|
||
const d = db();
|
||
const da = d.prepare(
|
||
"SELECT erster_monat, gesetzt_am FROM mz_start WHERE person_id = ?").get(personId);
|
||
const wechsel = rollenWechselAm(personId);
|
||
|
||
/* NEU GERECHNET WIRD NUR, WENN SICH SEITHER ETWAS GEÄNDERT HAT.
|
||
Vorher stand hier `if (da) return` -- einmal gesetzt, für immer.
|
||
Damit bekam jemand, der die Rolle verliert und später
|
||
zurückbekommt, KEINEN Schonmonat mehr, obwohl die Vorlage ihm
|
||
genau den zusichert („Wer erst im Laufe des Monats eine der
|
||
Rollen bekommt …"). Es wäre erst in Monaten aufgefallen, und
|
||
dann als „der wird zu Unrecht gemahnt". */
|
||
if (da && !(wechsel && wechsel > da.gesetzt_am)) return da.erster_monat;
|
||
|
||
const p = d.prepare("SELECT erstellt FROM personen WHERE id = ?").get(personId);
|
||
if (!p) return da?.erster_monat || 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.
|
||
|
||
JEDER Rollenwechsel zählt, auch der von Scout zu Manager. Das ist
|
||
eine Entscheidung und keine Nachlässigkeit: Seit die Zielzahlen
|
||
je Rolle gelten, bekommt man mit einer neuen Rolle wirklich neue
|
||
Pflichten -- und Rollen ändert ohnehin nur DogFather, hier ist
|
||
also nichts zu holen. */
|
||
const quelle = wechsel || 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 INTO mz_start (person_id, erster_monat, gesetzt_am, grund)
|
||
VALUES (?,?,?,?)
|
||
ON CONFLICT(person_id) DO UPDATE SET
|
||
erster_monat = excluded.erster_monat,
|
||
gesetzt_am = excluded.gesetzt_am,
|
||
grund = excluded.grund`)
|
||
.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");
|
||
/* WAS DIESE PERSON ZULETZT GEWAEHLT HAT -- abgeleitet aus ihrem
|
||
letzten Eintrag, nicht in einer Einstellung gespeichert.
|
||
|
||
Zweck: Der Schnell-Eintrag kann die Art vorbelegen, und damit
|
||
faellt bei „Werbung" und „Schulung" je ein Handgriff weg. Eine
|
||
eigene Spalte „Lieblingsart" waere ein zweiter Bestand, der
|
||
pflegebeduerftig ist und veralten kann; der letzte Eintrag
|
||
veraltet nie, er ist der letzte Eintrag. */
|
||
const zuletzt = a.arten
|
||
? db().prepare(`SELECT art FROM mz_eintrag
|
||
WHERE person_id = ? AND aufgabe = ? AND art IS NOT NULL
|
||
ORDER BY id DESC LIMIT 1`).get(person.id, a.schluessel)?.art
|
||
: null;
|
||
return {
|
||
schluessel: a.schluessel,
|
||
name: a.name,
|
||
kurz: a.kurz,
|
||
hinweis: a.hinweis,
|
||
feld: a.feld,
|
||
arten: a.arten || null,
|
||
letzteArt: zuletzt || a.arten?.[0]?.wert || 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 die Leitung (DogFather und Spicy Media), nur für eine einzelne
|
||
Handlung, nur zwei Minuten. Die Vorlage sagt „Vergangene Monate
|
||
sind gesperrt und nicht mehr bearbeitbar (Ausnahme: DogFather)";
|
||
seit dem 02.10.2026 gilt sie für beide — 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: darfKorrigieren(req.person),
|
||
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 || darfKorrigieren(req.person))
|
||
&& (monat === monatJetzt() || darfKorrigieren(req.person)),
|
||
});
|
||
} catch (fehler) {
|
||
console.error("[manager-ziele] Eintraege:", fehler?.message);
|
||
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
||
}
|
||
});
|
||
|
||
/** Legt EINEN Eintrag an und gibt zurueck, was daraus wurde.
|
||
*
|
||
* HERAUSGEZOGEN (02.10.2026), weil es den Weg jetzt zweimal gibt:
|
||
* einzeln (Dialog) und als Liste (Schnell-Eintrag). Zwei Fassungen
|
||
* derselben Pruefungen waeren zwei Fassungen, von denen eine beim
|
||
* naechsten Nachschaerfen zurueckbleibt -- und gerade hier geht es
|
||
* um Dublettenschutz und Datumsgrenzen, also genau um das, was
|
||
* niemand doppelt pflegen sollte. */
|
||
function eintragAnlegen(person, rumpf, monat, heute) {
|
||
const gepruefte = eintragPruefen(rumpf, monat, heute);
|
||
if (gepruefte.fehler) return { fehler: gepruefte.fehler };
|
||
const w = gepruefte.werte;
|
||
|
||
const doppel = dublette(person.id, monat, w);
|
||
if (doppel.eigen) {
|
||
return {
|
||
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 uebernommen, wenn er wirklich existiert und zu
|
||
dieser Person gehoert. Eine Nummer in der Adresse ist kein
|
||
Besitznachweis -- dieselbe Luecke, 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 === person.id || l.erstellt_von === person.id);
|
||
if (!l || (!meiner && !siehtAlles(person))) w.lead_id = null;
|
||
}
|
||
|
||
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(
|
||
person.id, person.name || null, 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());
|
||
|
||
/* ZWEIMAL AM SELBEN TAG -- ein Hinweis, keine Sperre (02.10.2026).
|
||
|
||
Seit es den Ein-Klick-Weg gibt, ist ein Doppeltipper die
|
||
wahrscheinlichste Fehleingabe: Der Knopf „Heute eingetragen"
|
||
braucht keine Angabe, also merkt man nichts davon. Zwei Meetings
|
||
an einem Tag sind aber moeglich -- es zu VERBIETEN waere falsch.
|
||
|
||
Also gesagt, was dasteht, und das Zuruecknehmen liegt ohnehin
|
||
daneben. Nur fuer die Aufgaben OHNE eigenes Erkennungsmerkmal;
|
||
Creator und Link haben ihren Dublettenschutz schon. */
|
||
let tageswarnung = null;
|
||
if (!w.name_klein && !w.link_klein) {
|
||
const schonHeute = db().prepare(
|
||
`SELECT COUNT(*) n FROM mz_eintrag
|
||
WHERE person_id = ? AND monat = ? AND aufgabe = ? AND datum = ?`)
|
||
.get(person.id, monat, w.aufgabe, w.datum)?.n ?? 0;
|
||
if (schonHeute > 1) {
|
||
tageswarnung = {
|
||
art: "zweimal_am_tag",
|
||
text: `Achtung: Für diesen Tag stehen jetzt ${schonHeute}. War das Absicht?`,
|
||
};
|
||
}
|
||
}
|
||
|
||
return {
|
||
id: Number(info.lastInsertRowid),
|
||
was: w.name || w.link || null,
|
||
warnung: doppel.fremd
|
||
? { art: "auch_bei", name: doppel.fremd.name,
|
||
text: `Hinweis: ${doppel.fremd.name || "jemand anderes"} hat das diesen Monat auch eingetragen.` }
|
||
: tageswarnung,
|
||
};
|
||
}
|
||
|
||
/* =====================================================================
|
||
DER SCHNELL-EINTRAG (02.10.2026)
|
||
=====================================================================
|
||
|
||
Filipe: „will dass es viel einfacher ist. am besten so wenig wie
|
||
moeglich zu tippen. fertige sachen, bereit um ab zu gehen."
|
||
|
||
GEZAEHLT, WAS EIN EINTRAG VORHER KOSTETE: Knopf „+ Eintragen",
|
||
Dialog, Knopf „Speichern" -- zwei Klicks und ein Fenster fuer die
|
||
Aussage „ich war heute im Meeting". Bei vier Aufgaben im Monat
|
||
sind das acht Klicks und acht Fenster fuer etwas, das keine
|
||
einzige Angabe braucht.
|
||
|
||
DIESER WEG NIMMT EINE LISTE. Damit ist „drei Creator auf einmal"
|
||
ein Vorgang statt drei, und das ist der eigentliche Gewinn: Wer
|
||
drei Namen aus Discord kopiert, hat den Monat in einem Zug
|
||
erledigt.
|
||
|
||
JEDER EINTRAG WIRD EINZELN GEPRUEFT und einzeln beantwortet. Ein
|
||
Abbruch der ganzen Liste, weil ein Name schon dasteht, waere die
|
||
bequeme und die falsche Loesung: Dann weiss man nicht, welcher der
|
||
drei das Problem war, und tippt alles noch einmal.
|
||
===================================================================== */
|
||
managerZieleRouter.post(`${BASIS}/eintraege`, (req, res) => {
|
||
try {
|
||
const heute = tagBerlin();
|
||
const monat = monatJetzt();
|
||
monatSichern(monat);
|
||
|
||
const vorlage = req.body || {};
|
||
const liste = [];
|
||
const namen = Array.isArray(vorlage.namen) ? vorlage.namen : null;
|
||
const links = Array.isArray(vorlage.links) ? vorlage.links : null;
|
||
|
||
/* HOECHSTENS ZWANZIG AUF EINMAL. Nicht aus Angst vor Last --
|
||
zwanzig Zeilen sind nichts --, sondern weil ein versehentlich
|
||
eingefuegter Textblock sonst dreihundert Eintraege erzeugt,
|
||
die jemand einzeln wieder loeschen muss. */
|
||
const GRENZE = 20;
|
||
if (namen) {
|
||
for (const n of namen.slice(0, GRENZE)) liste.push({ ...vorlage, name: n });
|
||
} else if (links) {
|
||
for (const l of links.slice(0, GRENZE)) liste.push({ ...vorlage, link: l });
|
||
} else {
|
||
liste.push({ ...vorlage });
|
||
}
|
||
if (!liste.length) return res.status(400).json({ fehler: "nichts_angegeben" });
|
||
|
||
const angelegt = [], abgelehnt = [], warnungen = [];
|
||
for (const einzeln of liste) {
|
||
const e = eintragAnlegen(req.person, einzeln, monat, heute);
|
||
if (e.fehler) abgelehnt.push({ was: einzeln.name || einzeln.link || null, ...e });
|
||
else {
|
||
angelegt.push({ id: e.id, was: e.was });
|
||
if (e.warnung) warnungen.push(e.warnung);
|
||
}
|
||
}
|
||
|
||
/* 201 nur, wenn wirklich etwas entstanden ist. Ein 201 ueber eine
|
||
leere Liste waere eine Zusage, die nicht stimmt. */
|
||
res.status(angelegt.length ? 201 : 409).json({
|
||
angelegt, abgelehnt, warnungen,
|
||
zuviel: (namen || links || []).length > GRENZE
|
||
? `Nur die ersten ${GRENZE} wurden übernommen.` : null,
|
||
stand: standFuer(req.person, monat),
|
||
});
|
||
} catch (fehler) {
|
||
if (String(fehler?.message || "").includes("monat_gesperrt")) {
|
||
return res.status(409).json({ fehler: "monat_gesperrt" });
|
||
}
|
||
console.error("[manager-ziele] Schnell-Eintrag:", fehler?.message);
|
||
res.status(503).json({ fehler: "nicht_gespeichert" });
|
||
}
|
||
});
|
||
|
||
/** Einen Eintrag anlegen. */
|
||
managerZieleRouter.post(`${BASIS}/eintrag`, (req, res) => {
|
||
try {
|
||
const heute = tagBerlin();
|
||
const monat = monatJetzt();
|
||
monatSichern(monat);
|
||
|
||
const e = eintragAnlegen(req.person, req.body, monat, heute);
|
||
if (e.fehler) {
|
||
const code = e.fehler === "schon_eingetragen" ? 409 : 400;
|
||
return res.status(code).json({ fehler: e.fehler, text: e.text });
|
||
}
|
||
res.status(201).json({
|
||
id: e.id,
|
||
stand: standFuer(req.person, monat),
|
||
warnung: e.warnung,
|
||
});
|
||
} 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 darfEr = darfKorrigieren(req.person);
|
||
if (fremd && !darfEr) 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 && !darfEr) 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 darfEr = darfKorrigieren(req.person);
|
||
if (fremd && !darfEr) return res.status(403).json({ fehler: "nicht_deins" });
|
||
|
||
const altMonat = alt.monat !== monatJetzt();
|
||
if (altMonat && !darfEr) 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 [];
|
||
}
|
||
}
|