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]>
1245 lines
49 KiB
JavaScript
1245 lines
49 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.
|
||
|
||
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 [];
|
||
}
|
||
}
|