/* ===================================================================== workspace-team.js — die Arbeitslage von Managern und Scouts. Wunsch Filipe, 09.09.2026: *"so eine kategorie wie über die creator will ich dass nur für die spicy und dogfather rolle auch über manager und scouts gibt ... ich will dass es so ultra krass gut ist dass die spicy und dogfather rolle einen kompletten teil haben mit daten über die arbeit von den manager und scout. keine geheimen sachen also termine, chats und geheime dateien soll auch so bleiben dass keiner."* --------------------------------------------------------------------- DREI ENTSCHEIDUNGEN, DIE ALLES ANDERE TRAGEN 1. WAS HIER NICHT VORKOMMT, UND ZWAR GAR NICHT. Keine Termine, keine Chats, keine Dateien -- weder Inhalte noch Zählungen. Ausdrücklicher Wunsch, und er ist auch der richtige: Ein Kalender verrät, wann jemand nicht da war; ein Chatzähler verrät, mit wem jemand oft spricht. Beides ist Privatsache und beantwortet keine Frage, die eine Agentur wirklich hat. Die Abfragen unten rühren `termine`, `chat_*` und `dateien` nicht an. Das ist keine Zusicherung im Kommentar, sondern eine Eigenschaft des Codes -- pruef-team.mjs liest diese Datei und schlägt an, wenn eine dieser Tabellen darin auftaucht. 2. SEGMENTIEREN, NICHT MITTELN. Aus der Recherche: Ein Durchschnitt versteckt genau die Person, bei der es klemmt. Zwei Scouts mit je 6 Creators und einer mit 18 ergeben einen Schnitt von 10 -- und der sagt nichts. Deshalb wird hier JEDE Person einzeln ausgegeben, und die Oberfläche markiert die Ausreißer. 3. ES IST EINE ARBEITSLAGE, KEINE ÜBERWACHUNG. Auch das steht so in der Fachliteratur: Der Zweck ist, Stellen zu finden, an denen Unterstützung fehlt -- nicht, Leute zu messen. Deshalb sind die Kennzahlen so gewählt, dass sie auf ZUSTÄNDE zeigen (unbetreute Creator, überfällige Aufgaben, liegengebliebene Kontakte) und nicht auf Anwesenheit oder Fleiß. --------------------------------------------------------------------- WER DARF HINEIN Nur `spicy` und `admin`. Und Spicy Media sieht DogFathers zweiten Zugang nicht -- dieselbe zentrale Regel wie überall (`verborgeneIds`), nicht eine eigene Fassung davon. ===================================================================== */ import express from "express"; import { db, sitzungLesen, verborgeneIds, heuteLokal, ROLLEN_SORTIERUNG, } from "./workspace.js"; export const teamRouter = express.Router(); /* Wer diesen ganzen Bereich sehen darf. EINE Menge, an EINER Stelle -- die Seite fragt dieselbe über /workspace/api/ich ab. */ export const TEAM_ROLLEN = new Set(["spicy", "admin"]); function nurLeitung(req, res, next) { const person = sitzungLesen(req); if (!person) return res.status(401).json({ fehler: "nicht_angemeldet" }); /* 404 und nicht 403: Was man nicht sehen darf, soll es fuer den Fragenden gar nicht geben. Ein 403 bestaetigt, dass es den Bereich gibt -- und genau das ist die Auskunft, die man nicht geben will. */ if (!TEAM_ROLLEN.has(person.rolle)) return res.status(404).json({ fehler: "nicht_gefunden" }); req.person = person; next(); } teamRouter.use("/workspace/api/team", nurLeitung); const zahl = (x) => Number(x) || 0; /* Tage zwischen zwei Zeitstempeln, auf eine Stelle gerundet. */ function tageZwischen(a, b) { if (!a || !b) return null; const d = (new Date(b) - new Date(a)) / 86400000; return Number.isFinite(d) && d >= 0 ? Math.round(d * 10) / 10 : null; } /* ===================================================================== DIE ARBEITSLAGE ===================================================================== */ teamRouter.get("/workspace/api/team", (req, res) => { try { const d = db(); const heute = heuteLokal(); const weg = new Set(verborgeneIds(req.person)); /* ---- 1. Wer ist überhaupt da ---------------------------------- */ const leute = d.prepare(` SELECT id, name, rolle, aktiv, erstellt, letzter_login, bild FROM personen WHERE rolle IN ('manager','scout') ORDER BY ` + ROLLEN_SORTIERUNG + ", name").all() .filter((p) => !weg.has(p.id)); /* ---- 2. Wen betreut wer --------------------------------------- */ /* EIN Creator kann nur EINEN Betreuer haben (betreuung ist so gebaut). Ein Manager bekommt zusätzlich die Creator, die an seinen Scouts hängen -- das ist seine mittelbare Verantwortung, und ohne sie sähe ein Manager mit fünf Scouts aus wie jemand ohne Arbeit. */ const betreut = d.prepare(` SELECT b.betreuer_id AS wer, p.id AS creator_id, p.name AS creator, p.aktiv FROM betreuung b JOIN personen p ON p.id = b.creator_id`).all(); const scoutsVon = d.prepare(` SELECT z.manager_id AS wer, p.id AS scout_id, p.name AS scout FROM scout_zuteilung z JOIN personen p ON p.id = z.scout_id`).all(); const direkt = new Map(); /* betreuer_id -> [creator] */ for (const z of betreut) { if (!direkt.has(z.wer)) direkt.set(z.wer, []); direkt.get(z.wer).push({ id: z.creator_id, name: z.creator, aktiv: !!z.aktiv }); } const meineScouts = new Map(); /* manager_id -> [scout] */ for (const z of scoutsVon) { if (!meineScouts.has(z.wer)) meineScouts.set(z.wer, []); meineScouts.get(z.wer).push({ id: z.scout_id, name: z.scout }); } /* ---- 3. Aufgaben, nach Creator gruppiert ---------------------- */ /* Eine Abfrage fuer alle -- nicht eine je Person. Bei zwoelf Leuten waeren das zwoelf Abfragen fuer dieselbe Tabelle. */ const aufgabenJeCreator = new Map(); for (const z of d.prepare(` SELECT creator_id, SUM(CASE WHEN status IN ('offen','arbeit','review') THEN 1 ELSE 0 END) AS laufend, SUM(CASE WHEN status IN ('offen','arbeit','review') AND frist IS NOT NULL AND frist < ? THEN 1 ELSE 0 END) AS ueberfaellig, SUM(CASE WHEN status = 'erledigt' THEN 1 ELSE 0 END) AS erledigt, SUM(CASE WHEN status = 'abgebrochen' THEN 1 ELSE 0 END) AS abgebrochen, SUM(CASE WHEN status = 'erledigt' AND erledigt_am >= ? THEN 1 ELSE 0 END) AS erledigt30 FROM aufgaben WHERE creator_id IS NOT NULL GROUP BY creator_id`).all(heute, vorTagen(30))) { aufgabenJeCreator.set(z.creator_id, z); } /* Durchlaufzeit: von der Anlage bis zum Abhaken. Nur die letzten neunzig Tage -- eine Zahl aus dem letzten Jahr beschreibt eine Arbeitsweise, die es vielleicht nicht mehr gibt. */ const durchlauf = new Map(); for (const z of d.prepare(` SELECT creator_id, erstellt, erledigt_am FROM aufgaben WHERE status = 'erledigt' AND erledigt_am IS NOT NULL AND erledigt_am >= ?`) .all(vorTagen(90))) { const t = tageZwischen(z.erstellt, z.erledigt_am); if (t === null) continue; if (!durchlauf.has(z.creator_id)) durchlauf.set(z.creator_id, []); durchlauf.get(z.creator_id).push(t); } /* ---- 4. Fortschritt der betreuten Creator --------------------- */ const startcheckJeCreator = new Map(); for (const z of d.prepare(` SELECT creator_id, COUNT(*) AS gesamt, SUM(CASE WHEN bewertung IS NOT NULL AND bewertung <> '' AND bewertung <> 'offen' THEN 1 ELSE 0 END) AS erledigt FROM startcheck GROUP BY creator_id`).all()) { startcheckJeCreator.set(z.creator_id, z); } const punkteJeCreator = new Map(); for (const z of d.prepare(` SELECT creator_id, COUNT(*) AS gesamt, SUM(CASE WHEN status = 'offen' THEN 1 ELSE 0 END) AS offen FROM eintraege WHERE creator_id IS NOT NULL GROUP BY creator_id`).all()) { punkteJeCreator.set(z.creator_id, z); } /* ---- 5. Zahlen der betreuten Creator (30 Tage) ---------------- */ const leistungJeCreator = new Map(); for (const z of d.prepare(` SELECT creator_id, SUM(diamanten) AS diamanten, SUM(dauer_min) AS minuten, COUNT(*) AS tage, MAX(tag) AS letzter FROM leistung WHERE tag >= ? GROUP BY creator_id`).all(vorTagen(30))) { leistungJeCreator.set(z.creator_id, z); } /* ---- 6. Die Pipeline der Scouts ------------------------------- */ const leadsJeScout = new Map(); for (const z of d.prepare(` SELECT scout_id, COUNT(*) AS gesamt, /* DIE STATUS STEHEN IN DER TABELLE, NICHT IN MEINEM KOPF. Erster Anlauf war ('neu','kontakt','gespraech') -- "kontakt" gibt es gar nicht, und die Datenbank hat es mit einer CHECK-Bedingung sofort abgelehnt. Gut so: Ohne die Bedingung waere "offen" still zu klein gewesen, und niemand haette es gemerkt. */ SUM(CASE WHEN status IN ('neu','angesprochen','gespraech','interessiert') THEN 1 ELSE 0 END) AS offen, SUM(CASE WHEN status = 'uebergeben' THEN 1 ELSE 0 END) AS uebergeben, SUM(CASE WHEN status = 'abgelehnt' THEN 1 ELSE 0 END) AS abgelehnt, SUM(CASE WHEN naechster_followup IS NOT NULL AND naechster_followup < ? AND status NOT IN ('uebergeben','abgelehnt') THEN 1 ELSE 0 END) AS faellig FROM leads WHERE scout_id IS NOT NULL GROUP BY scout_id`).all(heute)) { leadsJeScout.set(z.scout_id, z); } /* Wie lange von der Anlage bis zur Übergabe? Das ist die Zahl, die eine Pipeline wirklich beschreibt -- eine Menge offener Kontakte sagt nichts, wenn keiner davon je ankommt. */ const uebergabeJeScout = new Map(); for (const z of d.prepare(` SELECT scout_id, erstellt, uebergeben_am FROM leads WHERE uebergeben_am IS NOT NULL AND scout_id IS NOT NULL`).all()) { const t = tageZwischen(z.erstellt, z.uebergeben_am); if (t === null) continue; if (!uebergabeJeScout.has(z.scout_id)) uebergabeJeScout.set(z.scout_id, []); uebergabeJeScout.get(z.scout_id).push(t); } /* ---- 7. Alles zu einer Zeile je Person zusammensetzen --------- */ const mittel = (liste) => (liste && liste.length ? Math.round((liste.reduce((s, x) => s + x, 0) / liste.length) * 10) / 10 : null); const zeilen = leute.map((p) => { const eigene = direkt.get(p.id) || []; const scouts = meineScouts.get(p.id) || []; /* Mittelbare Creator: die der eigenen Scouts. Ohne Doppelung, falls ein Manager einen Creator auch direkt betreut. */ const mittelbar = []; const schon = new Set(eigene.map((c) => c.id)); for (const s of scouts) { for (const c of direkt.get(s.id) || []) { if (!schon.has(c.id)) { schon.add(c.id); mittelbar.push(c); } } } const alleCreator = [...eigene, ...mittelbar]; const summe = (karte, feld) => alleCreator .reduce((s, c) => s + zahl((karte.get(c.id) || {})[feld]), 0); const zeiten = alleCreator.flatMap((c) => durchlauf.get(c.id) || []); const scGesamt = summe(startcheckJeCreator, "gesamt"); const scFertig = summe(startcheckJeCreator, "erledigt"); const lead = leadsJeScout.get(p.id) || {}; return { id: p.id, name: p.name, rolle: p.rolle, aktiv: !!p.aktiv, seit: p.erstellt, letzter_login: p.letzter_login, bild: p.bild ? `/workspace/api/steckbrief/bild/${p.bild}` : null, creator: eigene.map((c) => ({ id: c.id, name: c.name, aktiv: c.aktiv })), creator_mittelbar: mittelbar.length, scouts: scouts.map((s) => s.name), aufgaben: { laufend: summe(aufgabenJeCreator, "laufend"), ueberfaellig: summe(aufgabenJeCreator, "ueberfaellig"), erledigt30: summe(aufgabenJeCreator, "erledigt30"), abgebrochen: summe(aufgabenJeCreator, "abgebrochen"), durchlauf_tage: mittel(zeiten), }, fortschritt: { startcheck_gesamt: scGesamt, startcheck_fertig: scFertig, punkte_gesamt: summe(punkteJeCreator, "gesamt"), punkte_offen: summe(punkteJeCreator, "offen"), }, zahlen: { diamanten30: summe(leistungJeCreator, "diamanten"), minuten30: summe(leistungJeCreator, "minuten"), livetage30: summe(leistungJeCreator, "tage"), }, pipeline: p.rolle === "scout" ? { gesamt: zahl(lead.gesamt), offen: zahl(lead.offen), uebergeben: zahl(lead.uebergeben), abgelehnt: zahl(lead.abgelehnt), faellig: zahl(lead.faellig), uebergabe_tage: mittel(uebergabeJeScout.get(p.id)), } : null, }; }); /* ---- 8. Die Lücken ------------------------------------------- */ /* Das ist der wichtigste Teil der ganzen Seite. Eine Kennzahl sagt, wie es läuft; eine Lücke sagt, wo etwas fehlt -- und nur das Zweite kann man heute abstellen. */ const alleBetreut = new Set(betreut.map((z) => z.creator_id)); const ohneBetreuung = d.prepare(` SELECT id, name FROM personen WHERE rolle = 'creator' AND aktiv = 1 ORDER BY name`) .all().filter((c) => !alleBetreut.has(c.id) && !weg.has(c.id)); const zugeteilteScouts = new Set(scoutsVon.map((z) => z.scout_id)); const scoutsOhneManager = leute .filter((p) => p.rolle === "scout" && p.aktiv && !zugeteilteScouts.has(p.id)) .map((p) => ({ id: p.id, name: p.name })); const ohneCreator = zeilen .filter((z) => z.aktiv && !z.creator.length && !z.creator_mittelbar) .map((z) => ({ id: z.id, name: z.name, rolle: z.rolle })); res.json({ stand: heute, leute: zeilen, luecken: { creator_ohne_betreuung: ohneBetreuung, scouts_ohne_manager: scoutsOhneManager, ohne_zuteilung: ohneCreator, }, /* Was hier ausdruecklich NICHT drinsteht -- die Oberflaeche sagt es dem Betrachter, damit niemand danach sucht. */ ausgenommen: ["Termine", "Chats", "Dateien"], }); } catch (fehler) { console.error("[workspace] Team-Lage:", fehler?.message); res.status(503).json({ fehler: "nicht_verfuegbar" }); } }); /** Ein Datum vor N Tagen, in Ortszeit als "JJJJ-MM-TT". * NICHT toISOString(): Das liefert UTC und damit nachts den Vortag -- * derselbe Fehler, der im Haus schon zweimal eine Zahl verschoben hat. */ function vorTagen(n) { const d = new Date(Date.now() - n * 86400000); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}` + `-${String(d.getDate()).padStart(2, "0")}`; }