Filipe mit der Vorlage „Prompt · Kachel Agentur-Aufgaben" (02.10.2026):
Scouts, Manager, DogFather und Spicy Media bekommen vier Pflichten je
Monat -- Creator rekrutieren, Manager-Meeting, Schulung oder Community
Talk, Werbung auf TikTok -- mit Fortschritt, Ampel, Warnungen und
einem Monatsschnitt, der nichts loescht.
DREI ENTSCHEIDUNGEN, DIE VON DER VORLAGE ABWEICHEN -- alle abgestimmt:
1. DIE KACHEL HEISST „Manager-Ziele", nicht „Agentur-Aufgaben".
Es gibt bereits eine Kachel „Agentur" und eine „Aufgaben". Eine
dritte mit beiden Woertern im Namen waere auf einem Handy nicht
mehr auseinanderzuhalten.
2. DIE ZIELZAHLEN GELTEN JE ROLLE, und DogFather UND Spicy Media
duerfen sie aendern. Ein Scout muss nicht dieselbe Zahl schaffen
wie die Leitung.
3. DIE SCOUT-PIPELINE IST ANGEBUNDEN, in beide Richtungen: Vorschlaege
aus uebergebenen Leads, Namensvorschlaege beim Tippen, die
Verbindung bleibt am Eintrag gespeichert. Aber NICHTS zaehlt von
selbst -- gezaehlt wird nur, was ein Mensch bestaetigt hat. Ein
Zaehler, der sich allein fuellt, ist einer, dem niemand glaubt.
WAS ANDERS GEBAUT IST, ALS ES NAHELAG
DAS ZIEL WIRD PRO MONAT EINGEFROREN (`mz_ziel` hat den Monat im
Schluessel). Laege nur ein aktueller Wert in `einstellungen`, schriebe
jede spaetere Aenderung rueckwirkend den ganzen Verlauf um: Ein Monat,
der mit 2/2 abgeschlossen war, staende nach einer Erhoehung auf 4
ploetzlich als „nicht erreicht" da. Ein Verlauf, der sich rueckwirkend
aendert, ist keiner. Es gibt deshalb gar keinen Weg, den laufenden
Monat umzuschreiben -- gespeichert wird immer in den naechsten.
DIE SPERRE VERGANGENER MONATE SITZT IN DER DATENBANK, nicht im Code
(drei Trigger). Die Ausnahme fuer DogFather laesst sich in SQLite
nicht ueber die Sitzung abfragen, also ist sie ein sichtbarer Vorgang:
`mz_freigabe` wird fuer die eine Handlung geoeffnet, im `finally`
wieder geschlossen und verfaellt nach zwei Minuten von selbst. Jede
Korrektur steht mit Name und Zeit im Protokoll.
DER LAUFENDE MONAT STEHT IN EINER TABELLE (`mz_lage`), nicht in
`strftime(...,'localtime')`. Sonst entschiede die Zeitzone des Servers,
und am Monatsersten zwischen 00:00 und 02:00 griffe die Sperre fuer
den falschen Monat. Gerechnet wird durchgehend in Europe/Berlin
(identisch mit dem Europe/Luxembourg der Vorlage, aber dieselbe
Zeitrechnung wie der Rest des Hauses).
KEIN ZWEITER ZAEHLER FUER DIE KACHELWAND. Rand und Abzeichen auf der
Startseite kommen aus `workspace-hinweise.js` und damit aus derselben
Rechnung wie die Seite (`standFuer`). Zwei Rechnungen ueber dieselbe
Sache laufen auseinander, und zwar lautlos.
KEIN IMPORTKREIS ZU workspace-push.js. Die Erinnerungen entstehen hier
als Liste (`zielRufe`), verschickt werden sie im vorhandenen
Fuenf-Minuten-Takt. Der Tag steht im Merkmal -- dadurch geht pro
Person hoechstens EINE Meldung am Tag heraus, obwohl der Lauf
288-mal stattfindet.
GETRENNTE HAEUSER: Auf crew.dogfather-universe.com gibt es diese
Kachel nicht, auch nicht fuer DogFather. Gemessen, nicht angenommen.
GEPRUEFT (141 Pruefungen, 0 Fehler) -- mit Gegenproben zu jeder Sperre
* Vier Rollen kommen herein, drei bekommen 404 (nicht 403), und die
ANZAHL steht in der Bedingung. „Alle abgewiesen" waere auf einer
leeren Liste wahr.
* Die Ampel wird mit EINGESETZTEN Tagen gemessen, nie gegen die
Wanduhr -- diese Pruefung sagt am 16. November dasselbe wie heute.
(gate-oeffnung.mjs im Shop war gruen, bis der Kalender sie
ueberholte.)
* Die Datenbank lehnt einen Eintrag im Vormonat selbst ab; danach
wird nachgewiesen, dass die Freigabe nur EINMAL gewirkt hat.
* Neun Absagen mit dem jeweils richtigen Grund -- und eine
Instagram-Adresse, die durchgehen MUSS, weil sonst nur bewiesen
waere, dass die Pruefung streng ist, nicht dass sie richtig ist.
* Am 7. des Monats ist Ruhe: Ohne diese Zeile bewiese der
Erinnerungs-Block nur, dass immer etwas kommt.
ZWEI BEFUNDE KAMEN AUS DER MESSUNG, NICHT AUS DEM NACHDENKEN
* Beim Aufklappen einer Zeile wurde die ganze Liste neu gebaut --
der angeklickte Knopf existierte danach nicht mehr, der Fokus
sprang an den Seitenanfang. Gefunden hat es die Bildmessung, der
die Schaltflaeche unter der Hand wegbrach.
* Zwei meiner Messungen waren falsch, nicht der Code: Der
Haus-Test schickte den Keks nicht mit (401 statt 404), und
`Response.text()` entfernt ein BOM beim Dekodieren -- der Export
hatte eines, die Pruefung sah es nur nicht. Jetzt wird in Bytes
gemessen.
Kachelton 47 (#7368ff) ist mit tools/kachel-farbe-einzeln.mjs gegen
alle 46 vorhandenen gerechnet, nicht ausgesucht: Abstand 0,0899,
Kontrast 4,61:1. Beruehrziele, waagerechtes Schieben und
Schriftgroessen sind am Bildschirm bei 412 px und 1280 px nachgemessen.
Co-Authored-By: Claude Opus 5 <[email protected]>
1154 lines
45 KiB
JavaScript
1154 lines
45 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 noch bearbeitbar ist, entscheidet der Server und
|
||
nicht der Browser — sonst wäre der graue Knopf die ganze
|
||
Sicherung. */
|
||
bearbeitbar: 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;
|
||
}
|
||
|
||
const info = db().prepare(
|
||
`INSERT INTO mz_eintrag
|
||
(person_id, aufgabe, monat, datum, name, name_klein, link, link_klein,
|
||
art, notiz, lead_id, doppelt_bei, erstellt)
|
||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`).run(
|
||
req.person.id, 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 leute = db().prepare(
|
||
`SELECT id, name, rolle FROM personen
|
||
WHERE aktiv = 1 AND rolle IN (${PFLICHT_ROLLEN.map(() => "?").join(",")})
|
||
ORDER BY rolle, name COLLATE NOCASE`).all(...PFLICHT_ROLLEN);
|
||
|
||
return leute.map((p) => {
|
||
const s = standFuer(p, monat);
|
||
return {
|
||
id: p.id, name: p.name, rolle: p.rolle,
|
||
rollenName: ROLLEN_NAME[p.rolle] || p.rolle,
|
||
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,
|
||
})),
|
||
};
|
||
});
|
||
}
|
||
|
||
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", "Pflichtig",
|
||
...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) {
|
||
zeilen.push([
|
||
p.name, p.rollenName, p.pflichtig ? "ja" : "nein",
|
||
...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 [];
|
||
}
|
||
}
|