From d845d91d708b483ade8502ca1fc9f50c454c7c3f Mon Sep 17 00:00:00 2001 From: Dogfather Date: Fri, 28 Aug 2026 10:57:42 +0200 Subject: [PATCH] Workspace: Scout-CRM -- Pipeline, Follow-ups und Uebergabe /workspace/scouting.html. Erster Baustein aus Phase 3 und der Bereich, in dem Scouts bisher praktisch nichts hatten. Der Merksatz von Seite 15 ist hier die Sicherheitsregel, nicht nur eine Beschreibung: "Scouts sehen ihre Pipeline -- die Admin-Rolle die Gesamtuebersicht -- Creator keine Scout-internen Daten." - Creator bekommen 404, nicht 403, und zwar auf Schnittstelle UND Seite. Sie sollen nicht einmal erfahren, dass es den Bereich gibt. - Ein Scout sieht ausschliesslich die eigene Pipeline. Geprueft: Patrick bekommt auf Sams Lead 404 beim Lesen, Aendern und Loeschen. - Das Management sieht alles, kann nach Scout filtern und Leads einem Scout zuordnen. Pipeline als gruppierte Liste, nicht als Board: Sechs Spalten waeren auf dem Handy unbedienbar, und Scouts arbeiten unterwegs. Jede Stufe traegt eine eigene Farbe an der linken Kante, die von kuehl (neu entdeckt) nach gruen (uebergeben) laeuft -- die Richtung der Pipeline wird sichtbar, ohne Ampel-Geblinke. Jede Karte hat genau EINEN naheliegenden Schritt ("Weiter zu ..."), der Rest steckt im aufklappbaren Teil. Der Weiter-Knopf traegt bewusst nur die Farbe seiner Stufe statt des vollen Farbverlaufs, sonst waere die Seite ein Streifenmuster aus identischen Leuchtbalken. Faellige Follow-ups stehen als eigene Leiste ganz oben. Das ist der Teil, der ohne System am ehesten untergeht -- nicht der Kontakt selbst, sondern das Nachfassen. Uebergabe ("Creator-Onboarding starten", Seite 15): Aus einem uebergebenen Lead legt das Management mit einem Klick eine echte Person mit Zugangscode an. Der Code wird genau einmal angezeigt, wie in der Personenverwaltung. Die Qualifizierung des Scouts (Plattform, Handle, Potenzial, LIVE-Aktivitaet) wandert dabei automatisch ins Creator-Profil -- sonst muesste das Management abtippen, was laengst dasteht. Geprueft: Lead -> Person -> Profil, alle drei verbunden, zweiter Versuch wird abgelehnt. Ein uebergebener Lead ist Teil der Betreuungsgeschichte -- den loescht nur das Management, nicht der Scout (403). Nebenbei: .knopf-still gab es im Baukasten noch gar nicht, die Zweit- knoepfe waren nackte Systemknoepfe. Jetzt sauber definiert. --- server/index.js | 2 + server/workspace-scouts.js | 306 +++++++++++++++++++++++++ server/workspace.js | 28 +++ workspace/assets/css/scouting.css | 216 ++++++++++++++++++ workspace/assets/js/scouting.js | 366 ++++++++++++++++++++++++++++++ workspace/assets/js/start.js | 2 +- workspace/scouting.html | 130 +++++++++++ 7 files changed, 1049 insertions(+), 1 deletion(-) create mode 100644 server/workspace-scouts.js create mode 100644 workspace/assets/css/scouting.css create mode 100644 workspace/assets/js/scouting.js create mode 100644 workspace/scouting.html diff --git a/server/index.js b/server/index.js index b8de6fd7..abcacb09 100644 --- a/server/index.js +++ b/server/index.js @@ -21,6 +21,7 @@ import { kalenderRouter } from "./workspace-kalender.js"; import { dateienRouter } from "./workspace-dateien.js"; import { bereicheRouter } from "./workspace-bereiche.js"; import { reportRouter } from "./workspace-reports.js"; +import { scoutRouter } from "./workspace-scouts.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); const SITE_DIR = join(__dirname, ".."); @@ -82,6 +83,7 @@ app.use(kalenderRouter); app.use(dateienRouter); app.use(bereicheRouter); app.use(reportRouter); +app.use(scoutRouter); /* Sicherheits-Fix 20.08.2026 (Audit vor dem geplanten öffentlichen Start morgen). SITE_DIR ist der GESAMTE Repo-Ordner (join(__dirname, "..")) — express.static liefert diff --git a/server/workspace-scouts.js b/server/workspace-scouts.js new file mode 100644 index 00000000..e7effa8b --- /dev/null +++ b/server/workspace-scouts.js @@ -0,0 +1,306 @@ +/* ===================================================================== + workspace-scouts.js — Scout-CRM (Konzept, Seite 15). + + Pipeline vom ersten Kontakt bis zur Uebergabe, Qualifizierung und + Follow-ups. Der Merksatz der Seite ist hier die Sicherheitsregel: + "Scouts sehen ihre Pipeline -- die Admin-Rolle die Gesamtuebersicht -- + Creator keine Scout-internen Daten." + + Creator bekommen deshalb 404, nicht 403: Sie sollen nicht einmal + erfahren, dass es diesen Bereich gibt. + + Besonderheit am Ende der Pipeline: Aus einem uebergebenen Lead kann + das Management mit einem Klick einen Creator anlegen -- "Creator- + Onboarding starten" aus dem Deck. Der Zugangscode wird dabei einmal + angezeigt, wie in der Personenverwaltung. + ===================================================================== */ + +import express from "express"; +import { db, protokolliere, echteIp, sitzungLesen, personAnlegen } from "./workspace.js"; + +export const scoutRouter = express.Router(); + +/* Reihenfolge ist die Pipeline aus dem Konzept. */ +export const STUFEN = { + neu: "Neu entdeckt", + angesprochen: "Angesprochen", + gespraech: "Gespräch geplant", + interessiert: "Interessiert", + uebergeben: "Übergeben", + abgelehnt: "Abgelehnt", +}; +const WEITER = { neu: "angesprochen", angesprochen: "gespraech", gespraech: "interessiert", interessiert: "uebergeben" }; +const PRIORITAETEN = ["hoch", "mittel", "niedrig"]; +const NAME_MAX = 120; +const TEXT_MAX = 3000; + +const jetzt = () => new Date().toISOString(); + +function angemeldet(req, res, next) { + const person = sitzungLesen(req); + if (!person) return res.status(401).json({ fehler: "nicht_angemeldet" }); + if (person.rolle === "creator") return res.status(404).json({ fehler: "nicht_gefunden" }); + req.person = person; + next(); +} + +function gleicheHerkunft(req, res, next) { + const herkunft = req.get("origin"); + if (!herkunft) return next(); + let erlaubt; + try { erlaubt = new URL(herkunft).host === req.get("host"); } catch { erlaubt = false; } + if (!erlaubt) return res.status(403).json({ fehler: "fremde_herkunft" }); + next(); +} + +scoutRouter.use("/workspace/api/leads", angemeldet); + +function sichtbar(person) { + if (person.rolle === "admin") return { wo: "1=1", werte: [] }; + return { wo: "l.scout_id = ?", werte: [person.id] }; +} + +const SPALTEN = ` + l.id, l.name, l.plattform, l.handle, l.status, l.prioritaet, + l.aktivitaet, l.potenzial, l.notizen, l.letzte_nachricht, l.naechster_followup, + l.scout_id, l.erstellt, l.geaendert, l.uebergeben_am, l.creator_id, + ps.name AS scout_name, pc.name AS creator_name`; + +const VERBUND = ` + FROM leads l + LEFT JOIN personen ps ON ps.id = l.scout_id + LEFT JOIN personen pc ON pc.id = l.creator_id`; + +/* ---------- Liste ------------------------------------------------------- */ + +scoutRouter.get("/workspace/api/leads", (req, res) => { + try { + const { wo, werte } = sichtbar(req.person); + const heute = jetzt().slice(0, 10); + res.json({ + stufen: STUFEN, + heute, + leads: db().prepare(` + SELECT ${SPALTEN} ${VERBUND} + WHERE ${wo} + ORDER BY + CASE l.status WHEN 'neu' THEN 0 WHEN 'angesprochen' THEN 1 + WHEN 'gespraech' THEN 2 WHEN 'interessiert' THEN 3 + WHEN 'uebergeben' THEN 4 ELSE 5 END, + CASE l.prioritaet WHEN 'hoch' THEN 0 WHEN 'mittel' THEN 1 ELSE 2 END, + CASE WHEN l.naechster_followup IS NULL THEN 1 ELSE 0 END, + l.naechster_followup, l.id DESC`).all(...werte), + }); + } catch (fehler) { + console.error("[workspace] Leads lesen:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + +/* ---------- Prüfen ------------------------------------------------------- */ + +function pruefe(körper, { neu }) { + const fehler = []; + const aus = {}; + const text = (feld, max, pflicht = false) => { + if (!neu && körper[feld] === undefined) return; + const t = String(körper[feld] ?? "").trim(); + if (pflicht && t.length < 2) { fehler.push("Name fehlt."); return; } + if (t.length > max) { fehler.push(`Feld ${feld} ist zu lang.`); return; } + aus[feld] = t || null; + }; + text("name", NAME_MAX, neu); + text("plattform", 60); + text("handle", 120); + text("aktivitaet", TEXT_MAX); + text("potenzial", TEXT_MAX); + text("notizen", TEXT_MAX); + + if (körper.status !== undefined) { + if (!Object.hasOwn(STUFEN, körper.status)) fehler.push("Unbekannte Stufe."); + else aus.status = körper.status; + } + if (körper.prioritaet !== undefined) { + if (!PRIORITAETEN.includes(körper.prioritaet)) fehler.push("Unbekannte Priorität."); + else aus.prioritaet = körper.prioritaet; + } + for (const feld of ["letzte_nachricht", "naechster_followup"]) { + if (körper[feld] === undefined) continue; + const d = String(körper[feld] ?? "").trim(); + if (!d) { aus[feld] = null; continue; } + if (!/^\d{4}-\d{2}-\d{2}$/.test(d) || Number.isNaN(Date.parse(d))) fehler.push("Datum ist ungültig."); + else aus[feld] = d; + } + return { aus, fehler }; +} + +/* ---------- Anlegen und Ändern ------------------------------------------- */ + +scoutRouter.post("/workspace/api/leads", gleicheHerkunft, (req, res) => { + try { + const { aus, fehler } = pruefe(req.body || {}, { neu: true }); + if (fehler.length) return res.status(400).json({ fehler: fehler.join(" ") }); + + /* Ein Scout legt immer in die eigene Pipeline. Das Management darf + einen Lead einem Scout zuordnen. */ + let scoutId = req.person.id; + if (req.person.rolle === "admin") { + const w = req.body?.scout_id; + if (w) { + const z = Number(w); + if (!Number.isInteger(z) || z < 1) return res.status(400).json({ fehler: "Ungültige Zuordnung." }); + if (!db().prepare("SELECT 1 FROM personen WHERE id = ? AND rolle = 'scout'").get(z)) { + return res.status(400).json({ fehler: "Scout existiert nicht." }); + } + scoutId = z; + } else scoutId = null; + } + + const { lastInsertRowid } = db().prepare(` + INSERT INTO leads + (name, plattform, handle, status, prioritaet, aktivitaet, potenzial, + notizen, letzte_nachricht, naechster_followup, scout_id, erstellt, erstellt_von) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`).run( + aus.name, aus.plattform ?? null, aus.handle ?? null, + aus.status ?? "neu", aus.prioritaet ?? "mittel", + aus.aktivitaet ?? null, aus.potenzial ?? null, aus.notizen ?? null, + aus.letzte_nachricht ?? null, aus.naechster_followup ?? null, + scoutId, jetzt(), req.person.id); + + protokolliere("lead_angelegt", { + personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req), + detail: `#${lastInsertRowid} ${aus.name}`.slice(0, 120), + }); + res.status(201).json({ id: Number(lastInsertRowid) }); + } catch (fehler) { + console.error("[workspace] Lead anlegen:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + +function holen(req, id) { + const { wo, werte } = sichtbar(req.person); + return db().prepare(`SELECT l.* ${VERBUND} WHERE ${wo} AND l.id = ?`).get(...werte, id); +} + +scoutRouter.patch("/workspace/api/leads/:id", gleicheHerkunft, (req, res) => { + try { + const id = Number(req.params.id); + if (!Number.isInteger(id)) return res.status(400).json({ fehler: "ungueltig" }); + const lead = holen(req, id); + if (!lead) return res.status(404).json({ fehler: "nicht_gefunden" }); + + const { aus, fehler } = pruefe(req.body || {}, { neu: false }); + if (fehler.length) return res.status(400).json({ fehler: fehler.join(" ") }); + const felder = Object.keys(aus); + if (!felder.length) return res.status(400).json({ fehler: "nichts_zu_aendern" }); + + const setz = felder.map((f) => `${f} = ?`); + const daten = felder.map((f) => aus[f]); + /* Der Zeitpunkt der Uebergabe wird festgehalten -- er ist der + Uebergang aus der Scout-Arbeit in die Betreuung. */ + if (aus.status === "uebergeben" && lead.status !== "uebergeben") { + setz.push("uebergeben_am = ?"); daten.push(jetzt()); + } + setz.push("geaendert = ?"); daten.push(jetzt()); + + db().prepare(`UPDATE leads SET ${setz.join(", ")} WHERE id = ?`).run(...daten, id); + protokolliere("lead_geaendert", { + personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req), + detail: `#${id} ${felder.join(",")}`.slice(0, 120), + }); + res.json({ ok: true }); + } catch (fehler) { + console.error("[workspace] Lead ändern:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + +scoutRouter.delete("/workspace/api/leads/:id", gleicheHerkunft, (req, res) => { + try { + const id = Number(req.params.id); + if (!Number.isInteger(id)) return res.status(400).json({ fehler: "ungueltig" }); + const lead = holen(req, id); + if (!lead) return res.status(404).json({ fehler: "nicht_gefunden" }); + /* Ein übergebener Lead ist Teil der Betreuungsgeschichte -- den + räumt nur das Management weg. */ + if (req.person.rolle !== "admin" && lead.status === "uebergeben") { + return res.status(403).json({ fehler: "Übergebene Leads löscht nur das Management." }); + } + db().prepare("DELETE FROM leads WHERE id = ?").run(id); + protokolliere("lead_geloescht", { + personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req), + detail: `#${id} ${lead.name}`.slice(0, 120), + }); + res.json({ ok: true }); + } catch (fehler) { + console.error("[workspace] Lead löschen:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + +/* ---------- Creator-Onboarding starten ------------------------------------ + "Übergabe: Infos vollständig, Gespräch dokumentiert, Termin setzen, + Creator-Onboarding starten" (Deck, Seite 15). Aus einem übergebenen + Lead entsteht hier eine echte Person mit Zugangscode -- der Code wird + genau einmal zurückgegeben und danach nie wieder. */ + +scoutRouter.post("/workspace/api/leads/:id/onboarding", gleicheHerkunft, (req, res) => { + try { + if (req.person.rolle !== "admin") return res.status(403).json({ fehler: "nicht_erlaubt" }); + const id = Number(req.params.id); + if (!Number.isInteger(id)) return res.status(400).json({ fehler: "ungueltig" }); + + const lead = holen(req, id); + if (!lead) return res.status(404).json({ fehler: "nicht_gefunden" }); + if (lead.creator_id) return res.status(409).json({ fehler: "Für diesen Lead gibt es schon einen Creator." }); + if (lead.status !== "uebergeben") { + return res.status(400).json({ fehler: "Erst übergeben, dann das Onboarding starten." }); + } + + const name = String(req.body?.name ?? lead.name).trim().slice(0, 60); + if (name.length < 2) return res.status(400).json({ fehler: "Name fehlt." }); + if (db().prepare("SELECT 1 FROM personen WHERE lower(name) = lower(?) AND aktiv = 1").get(name)) { + return res.status(409).json({ fehler: "Diesen Namen gibt es schon." }); + } + + const neu = personAnlegen(name, "creator", { ...req.person, ip: echteIp(req) }); + db().prepare("UPDATE leads SET creator_id = ?, geaendert = ? WHERE id = ?") + .run(neu.id, jetzt(), id); + + /* Die Angaben des Scouts wandern ins Creator-Profil -- sonst müsste + das Management sie abtippen, obwohl sie längst da sind. */ + const handles = [lead.plattform, lead.handle].filter(Boolean).join(" · ") || null; + if (handles || lead.potenzial || lead.aktivitaet) { + db().prepare(` + INSERT INTO profile (person_id, handles, nische, live_zeiten, geaendert, geaendert_von) + VALUES (?,?,?,?,?,?)`).run( + neu.id, handles, lead.potenzial ?? null, lead.aktivitaet ?? null, + jetzt(), req.person.id); + } + + protokolliere("onboarding_gestartet", { + personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req), + detail: `Lead #${id} -> Creator ${name}`.slice(0, 120), + }); + res.status(201).json({ id: neu.id, name: neu.name, rolle: neu.rolle, code: neu.code }); + } catch (fehler) { + console.error("[workspace] Onboarding:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + +/* Scout-Liste für die Zuordnung durch das Management. */ +scoutRouter.get("/workspace/api/leads-scouts", angemeldet, (req, res) => { + try { + if (req.person.rolle !== "admin") { + return res.json({ scouts: [{ id: req.person.id, name: req.person.name }] }); + } + res.json({ + scouts: db().prepare( + "SELECT id, name FROM personen WHERE rolle = 'scout' AND aktiv = 1 ORDER BY name").all(), + }); + } catch { + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); diff --git a/server/workspace.js b/server/workspace.js index d0ea7848..cdd13d05 100644 --- a/server/workspace.js +++ b/server/workspace.js @@ -222,6 +222,33 @@ export function db() { ); CREATE INDEX IF NOT EXISTS idx_eintraege_bereich ON eintraege (bereich, creator_id); + /* Scout-CRM: die Pipeline vom ersten Fund bis zur Uebergabe. + creator_id ist gesetzt, sobald aus einem uebergebenen Lead eine + echte Person angelegt wurde -- damit bleibt nachvollziehbar, wer + einen Creator gefunden hat, auch Jahre spaeter. */ + CREATE TABLE IF NOT EXISTS leads ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + plattform TEXT, + handle TEXT, + status TEXT NOT NULL DEFAULT 'neu' + CHECK (status IN ('neu','angesprochen','gespraech','interessiert','uebergeben','abgelehnt')), + prioritaet TEXT NOT NULL DEFAULT 'mittel' + CHECK (prioritaet IN ('hoch','mittel','niedrig')), + aktivitaet TEXT, + potenzial TEXT, + notizen TEXT, + letzte_nachricht TEXT, + naechster_followup TEXT, + scout_id INTEGER REFERENCES personen(id) ON DELETE SET NULL, + creator_id INTEGER REFERENCES personen(id) ON DELETE SET NULL, + erstellt TEXT NOT NULL, + erstellt_von INTEGER REFERENCES personen(id) ON DELETE SET NULL, + geaendert TEXT, + uebergeben_am TEXT + ); + CREATE INDEX IF NOT EXISTS idx_leads_scout ON leads (scout_id, status); + CREATE TABLE IF NOT EXISTS versuche ( ip TEXT NOT NULL, zeitpunkt TEXT NOT NULL @@ -359,6 +386,7 @@ const GESCHUETZT = { "/workspace/dateien.html": null, "/workspace/bereich.html": ["admin", "creator"], "/workspace/report.html": ["admin", "creator"], + "/workspace/scouting.html": ["admin", "scout"], }; workspaceRouter.use((req, res, next) => { diff --git a/workspace/assets/css/scouting.css b/workspace/assets/css/scouting.css new file mode 100644 index 00000000..7d94711b --- /dev/null +++ b/workspace/assets/css/scouting.css @@ -0,0 +1,216 @@ +/* =================================================================== + Scout-CRM. + + Die Pipeline hat eine Richtung, das soll man sehen: Jede Stufe traegt + links eine farbige Kante, die von kuehl (neu entdeckt) zu warm/gruen + (uebergeben) laeuft. Gedeckt gehalten -- kein Ampel-Geblinke. + =================================================================== */ + +.steuerung { display: flex; gap: 14px; flex-wrap: wrap; align-items: flex-end; } +.steuerung select { + min-width: 160px; padding: 9px 12px; + background: rgba(255, 255, 255, .045); + border: 1px solid var(--rand); border-radius: var(--radius-klein); + color: var(--text); font: inherit; +} + +/* ---------- Fällige Follow-ups ------------------------------------------ */ + +.faellig { + margin-bottom: 26px; padding: 14px 18px; + background: rgba(255, 190, 120, .07); + border: 1px solid rgba(255, 190, 120, .3); + border-radius: var(--radius); +} +.faellig__titel { + margin: 0 0 8px; font-size: .74rem; font-weight: 700; + letter-spacing: .08em; text-transform: uppercase; color: #f0c48a; +} +.faellig__liste { display: flex; flex-wrap: wrap; gap: 8px 16px; margin: 0; padding: 0; list-style: none; } +.faellig__liste li { display: flex; align-items: baseline; gap: 7px; } +.faellig__knopf { + padding: 0; background: none; border: 0; cursor: pointer; + color: var(--text); font: inherit; font-weight: 600; + border-bottom: 1px solid rgba(240, 196, 138, .45); +} +.faellig__knopf:hover { color: #f0c48a; } + +/* ---------- Stufen -------------------------------------------------------- */ + +.stufe { margin-bottom: 30px; } +.stufe__kopf { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; } +.stufe__titel { + margin: 0; font-size: .78rem; font-weight: 700; + letter-spacing: .08em; text-transform: uppercase; color: var(--akzent); +} +.stufe__zahl { + min-width: 22px; padding: 1px 7px; text-align: center; + font-size: .74rem; font-weight: 700; color: var(--text-still); + background: rgba(255, 255, 255, .06); + border-radius: 999px; +} +.stufe__karten { display: grid; gap: 10px; } + +/* Farbverlauf entlang der Pipeline. */ +.stufe[data-stufe="neu"] { --stufe-farbe: #4c7fa8; } +.stufe[data-stufe="angesprochen"] { --stufe-farbe: #5b8fd4; } +.stufe[data-stufe="gespraech"] { --stufe-farbe: #7d84e0; } +.stufe[data-stufe="interessiert"] { --stufe-farbe: #8ab8a0; } +.stufe[data-stufe="uebergeben"] { --stufe-farbe: #79d1a2; } +.stufe[data-stufe="abgelehnt"] { --stufe-farbe: #55606f; } +.stufe[data-stufe="abgelehnt"] .kk { opacity: .62; } +.stufe[data-stufe="abgelehnt"] .kk:hover { opacity: 1; } + +/* ---------- Karte --------------------------------------------------------- */ + +.kk { + position: relative; + padding: 15px 18px 15px 21px; + background: rgba(255, 255, 255, .03); + border: 1px solid var(--rand); + border-radius: var(--radius-klein); + transition: border-color .18s ease, background .18s ease, opacity .18s ease; +} +.kk::before { + content: ""; position: absolute; left: 0; top: 10px; bottom: 10px; width: 3px; + border-radius: 0 3px 3px 0; + background: var(--stufe-farbe, var(--akzent)); + opacity: .8; +} +.kk:hover { border-color: rgba(255, 255, 255, .16); } +.kk[data-faellig="ja"] { border-color: rgba(255, 190, 120, .34); } + +.kk__kopf { display: flex; gap: 14px; align-items: flex-start; justify-content: space-between; flex-wrap: wrap; } +.kk__name { margin: 0; font-size: 1.02rem; font-weight: 650; } +.kk__unter { margin: 2px 0 0; font-size: .82rem; color: var(--text-still); } + +.kk__marken { display: flex; gap: 6px; flex-wrap: wrap; } +.marke-punkt, .marke-still, .marke-fertig { + padding: 3px 9px; border-radius: 999px; + font-size: .71rem; font-weight: 600; letter-spacing: .02em; + border: 1px solid var(--rand); color: var(--text-still); +} +.marke-punkt[data-prio="hoch"] { + color: #ffb4b9; border-color: rgba(255, 154, 162, .38); background: rgba(255, 154, 162, .1); +} +.marke-punkt[data-prio="mittel"] { color: var(--text-leise); } +.marke-fertig { + color: #79d1a2; border-color: rgba(121, 209, 162, .35); background: rgba(121, 209, 162, .09); +} + +.kk__info { display: flex; flex-wrap: wrap; gap: 6px 14px; margin-top: 10px; font-size: .78rem; } +.kk__still { color: var(--text-still); } +.kk__followup { color: var(--text-leise); } +.kk__followup[data-faellig="ja"] { color: #f0c48a; font-weight: 600; } + +.kk__knoepfe { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin-top: 13px; } + +/* Der Weiter-Schritt wiederholt sich auf jeder Karte. Als voller + Farbverlauf waere die Seite ein Streifenmuster -- er traegt deshalb + nur die Farbe seiner Stufe und wird erst beim Zeigen kraeftiger. */ +.kk__knoepfe .knopf--weiter { + background: color-mix(in srgb, var(--stufe-farbe) 15%, transparent); + border: 1px solid color-mix(in srgb, var(--stufe-farbe) 45%, transparent); + color: color-mix(in srgb, var(--stufe-farbe) 60%, #ffffff); + font-weight: 600; +} +.kk__knoepfe .knopf--weiter:hover { + filter: none; + background: color-mix(in srgb, var(--stufe-farbe) 26%, transparent); + border-color: color-mix(in srgb, var(--stufe-farbe) 70%, transparent); + color: #fff; +} + +/* Zurueckhaltender Zweitknopf -- gab es im Baukasten noch nicht. */ +.knopf-still { + padding: 7px 13px; cursor: pointer; + background: transparent; + border: 1px solid var(--rand); border-radius: var(--radius-klein); + color: var(--text-still); font: inherit; font-size: .82rem; + transition: color .16s ease, border-color .16s ease, background .16s ease; +} +.knopf-still:hover { + color: var(--text); + border-color: rgba(255, 255, 255, .22); + background: rgba(255, 255, 255, .04); +} + +/* Das Onboarding ist der einzige Schritt, der eine echte Person anlegt -- + der Knopf soll sich deshalb von den anderen abheben. */ +.knopf--gold { + background: linear-gradient(100deg, rgba(240, 196, 138, .92), rgba(224, 160, 110, .92)); + color: #241608; + border: 0; +} +.knopf--gold:hover { filter: brightness(1.07); } + +/* ---------- Aufgeklappt ---------------------------------------------------- */ + +.kk__detail { + margin-top: 15px; padding-top: 15px; + border-top: 1px solid var(--rand); +} +.kk__zeile { margin: 0 0 9px; font-size: .87rem; color: var(--text-leise); } +.kk__schild { + display: block; margin-bottom: 1px; + font-size: .7rem; font-weight: 700; letter-spacing: .07em; + text-transform: uppercase; color: var(--text-still); +} +.kk__notiz { + width: 100%; margin: 4px 0 14px; padding: 10px 12px; + background: rgba(255, 255, 255, .04); + border: 1px solid var(--rand); border-radius: var(--radius-klein); + color: var(--text); font: inherit; resize: vertical; +} +.kk__raster { + display: grid; gap: 12px; margin-bottom: 14px; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); +} +.kk__raster input, .kk__raster select { + width: 100%; padding: 9px 11px; + background: rgba(255, 255, 255, .05); + border: 1px solid var(--rand); border-radius: var(--radius-klein); + color: var(--text); font: inherit; +} +.kk__notiz:focus, .kk__raster input:focus, .kk__raster select:focus { + outline: none; border-color: rgba(63, 189, 245, .65); + box-shadow: 0 0 0 3px rgba(63, 189, 245, .14); +} +.knopf-still--warn { color: #ffb4b9; border-color: rgba(255,154,162,.28); } +.knopf-still--warn:hover { color: #ffd0d3; border-color: rgba(255,154,162,.5); background: rgba(255,154,162,.08); } + +.leer-hinweis { + padding: 30px; text-align: center; color: var(--text-still); font-size: .9rem; + border: 1px dashed var(--rand); border-radius: var(--radius); +} + +/* ---------- Codefenster ---------------------------------------------------- */ + +.schleier { + position: fixed; inset: 0; z-index: 60; + display: grid; place-items: center; padding: 22px; + background: rgba(3, 5, 10, .78); + backdrop-filter: blur(5px); +} +.code-fenster { + width: min(440px, 100%); padding: 26px 28px; + background: #0b111b; + border: 1px solid rgba(63, 189, 245, .3); + border-radius: var(--radius); + box-shadow: 0 24px 60px -20px rgba(0, 0, 0, .8); +} +.code-fenster__titel { margin: 3px 0 10px; font-size: 1.2rem; font-weight: 700; } +.code-fenster__text { margin: 0 0 16px; font-size: .85rem; color: var(--text-leise); } +.code-fenster__code { + margin: 0 0 20px; padding: 15px; + font-family: ui-monospace, "Cascadia Mono", Consolas, monospace; + font-size: 1.4rem; font-weight: 700; letter-spacing: .16em; text-align: center; + word-break: break-all; + background: rgba(63, 189, 245, .09); + border: 1px solid rgba(63, 189, 245, .28); + border-radius: var(--radius-klein); +} + +@media (prefers-reduced-motion: reduce) { + .kk { transition: none; } +} diff --git a/workspace/assets/js/scouting.js b/workspace/assets/js/scouting.js new file mode 100644 index 00000000..01a0693a --- /dev/null +++ b/workspace/assets/js/scouting.js @@ -0,0 +1,366 @@ +/* =================================================================== + Scout-CRM (Konzept, Seite 15). + + Die Pipeline ist nach Stufen gruppiert, nicht als breites Board -- + auf dem Handy waere ein Board mit sechs Spalten unbedienbar, und ein + Scout arbeitet ohnehin meistens unterwegs. + + Jede Karte hat genau einen naheliegenden naechsten Schritt ("Weiter + zu ..."), weil die Pipeline eine Richtung hat. Alles andere steckt + im aufklappbaren Teil, damit die Liste ruhig bleibt. + =================================================================== */ +(() => { + 'use strict'; + + const $ = (id) => document.getElementById(id); + const WEITER = { + neu: 'angesprochen', angesprochen: 'gespraech', + gespraech: 'interessiert', interessiert: 'uebergeben', + }; + + let ich = null; + let stufen = {}; + let heute = ''; + let daten = []; + let offen = new Set(); + + const el = (tag, klasse, text) => { + const k = document.createElement(tag); + if (klasse) k.className = klasse; + if (text !== undefined) k.textContent = text; + return k; + }; + const melde = (t) => { $('fehler').textContent = t || ''; }; + const datum = (iso) => (iso ? iso.slice(0, 10).split('-').reverse().join('.') : ''); + + async function hole(pfad, optionen = {}) { + const a = await fetch(pfad, { credentials: 'same-origin', ...optionen }); + if (a.status === 401) { location.assign('/workspace/'); throw new Error('abgemeldet'); } + if (a.status === 404 && !pfad.includes('/leads/')) { + location.assign('/workspace/start.html'); throw new Error('kein Zugriff'); + } + return a; + } + + async function schicke(pfad, methode, körper) { + const a = await hole(pfad, { + method: methode, + headers: körper ? { 'Content-Type': 'application/json' } : undefined, + body: körper ? JSON.stringify(körper) : undefined, + }); + if (!a.ok) { + melde((await a.json().catch(() => ({}))).fehler || 'Das hat nicht geklappt.'); + return null; + } + melde(''); + return a.json().catch(() => ({})); + } + + /* ---------- Karte -------------------------------------------------------- */ + + function zeile(schild, wert) { + const p = el('p', 'kk__zeile'); + p.append(el('span', 'kk__schild', schild), document.createTextNode(wert)); + return p; + } + + function karte(l) { + const k = el('article', 'kk'); + k.dataset.prio = l.prioritaet; + const fällig = l.naechster_followup && l.naechster_followup <= heute + && l.status !== 'uebergeben' && l.status !== 'abgelehnt'; + if (fällig) k.dataset.faellig = 'ja'; + + /* Kopf */ + const kopf = el('div', 'kk__kopf'); + const links = el('div'); + links.append(el('h3', 'kk__name', l.name)); + const unter = [l.plattform, l.handle].filter(Boolean).join(' · '); + if (unter) links.append(el('p', 'kk__unter', unter)); + kopf.append(links); + + const marken = el('div', 'kk__marken'); + const prio = el('span', 'marke-punkt', l.prioritaet === 'hoch' ? 'Hoch' + : l.prioritaet === 'niedrig' ? 'Niedrig' : 'Mittel'); + prio.dataset.prio = l.prioritaet; + marken.append(prio); + if (l.scout_name && ich.rolle === 'admin') marken.append(el('span', 'marke-still', l.scout_name)); + if (l.creator_name) marken.append(el('span', 'marke-fertig', 'Creator: ' + l.creator_name)); + kopf.append(marken); + k.append(kopf); + + /* Kurzinfo */ + const info = el('div', 'kk__info'); + if (l.naechster_followup) { + const f = el('span', 'kk__followup', 'Follow-up ' + datum(l.naechster_followup)); + if (fällig) f.dataset.faellig = 'ja'; + info.append(f); + } + if (l.letzte_nachricht) info.append(el('span', 'kk__still', 'zuletzt geschrieben ' + datum(l.letzte_nachricht))); + if (l.uebergeben_am) info.append(el('span', 'kk__still', 'übergeben ' + datum(l.uebergeben_am))); + if (info.childNodes.length) k.append(info); + + /* Knöpfe: ein naheliegender Schritt, alles andere aufklappbar. */ + const knöpfe = el('div', 'kk__knoepfe'); + const ziel = WEITER[l.status]; + if (ziel) { + const w = el('button', 'knopf knopf--klein knopf--weiter', 'Weiter zu „' + stufen[ziel] + '"'); + w.type = 'button'; + w.addEventListener('click', () => stufeSetzen(l, ziel)); + knöpfe.append(w); + } + if (l.status === 'uebergeben' && ich.rolle === 'admin' && !l.creator_id) { + const o = el('button', 'knopf knopf--klein knopf--gold', 'Creator-Onboarding starten'); + o.type = 'button'; + o.addEventListener('click', () => onboarding(l, o)); + knöpfe.append(o); + } + const mehr = el('button', 'knopf-still', offen.has(l.id) ? 'Weniger' : 'Details'); + mehr.type = 'button'; + mehr.addEventListener('click', () => { + if (offen.has(l.id)) offen.delete(l.id); else offen.add(l.id); + zeichne(); + }); + knöpfe.append(mehr); + k.append(knöpfe); + + if (!offen.has(l.id)) return k; + + /* ---------- Aufgeklappt: Qualifizierung und Pflege ---------- */ + const d = el('div', 'kk__detail'); + + if (l.aktivitaet) d.append(zeile('Aktivität & LIVE-Fokus', l.aktivitaet)); + if (l.potenzial) d.append(zeile('Potenzial & Stil', l.potenzial)); + + const notiz = el('textarea', 'kk__notiz'); + notiz.rows = 3; + notiz.maxLength = 3000; + notiz.placeholder = 'Notizen zum Gespräch – was wurde besprochen, was ist offen?'; + notiz.value = l.notizen || ''; + d.append(el('label', 'feld-schild', 'Notizen'), notiz); + + const raster = el('div', 'kk__raster'); + const mach = (schild, typ, wert, aufbau) => { + const box = el('div', 'feld'); + box.append(el('label', 'feld-schild', schild)); + const f = typ === 'select' ? el('select') : el('input'); + if (typ !== 'select') f.type = typ; + if (aufbau) aufbau(f); + f.value = wert || ''; + box.append(f); + raster.append(box); + return f; + }; + const fNachricht = mach('Zuletzt geschrieben', 'date', l.letzte_nachricht); + const fFollowup = mach('Nächster Follow-up', 'date', l.naechster_followup); + const fPrio = mach('Priorität', 'select', l.prioritaet, (f) => { + for (const [w, t] of [['hoch', 'Hoch'], ['mittel', 'Mittel'], ['niedrig', 'Niedrig']]) { + const o = document.createElement('option'); o.value = w; o.textContent = t; f.append(o); + } + }); + const fStufe = mach('Stufe', 'select', l.status, (f) => { + for (const [w, t] of Object.entries(stufen)) { + const o = document.createElement('option'); o.value = w; o.textContent = t; f.append(o); + } + }); + d.append(raster); + + const unten = el('div', 'kk__knoepfe'); + const sp = el('button', 'knopf knopf--klein', 'Speichern'); + sp.type = 'button'; + sp.addEventListener('click', async () => { + sp.disabled = true; + const r = await schicke('/workspace/api/leads/' + l.id, 'PATCH', { + notizen: notiz.value, letzte_nachricht: fNachricht.value, + naechster_followup: fFollowup.value, prioritaet: fPrio.value, status: fStufe.value, + }); + sp.disabled = false; + if (r) await laden(); + }); + unten.append(sp); + + if (l.status !== 'abgelehnt' && l.status !== 'uebergeben') { + const ab = el('button', 'knopf-still', 'Passt nicht'); + ab.type = 'button'; + ab.addEventListener('click', () => stufeSetzen(l, 'abgelehnt')); + unten.append(ab); + } + const wg = el('button', 'knopf-still knopf-still--warn', 'Löschen'); + wg.type = 'button'; + wg.addEventListener('click', async () => { + if (!confirm(`„${l.name}" wirklich aus der Pipeline entfernen?`)) return; + if (await schicke('/workspace/api/leads/' + l.id, 'DELETE')) { offen.delete(l.id); await laden(); } + }); + unten.append(wg); + d.append(unten); + + k.append(d); + return k; + } + + async function stufeSetzen(l, status) { + if (await schicke('/workspace/api/leads/' + l.id, 'PATCH', { status })) await laden(); + } + + async function onboarding(l, knopf) { + if (!confirm(`Aus „${l.name}" jetzt einen Creator mit eigenem Zugang anlegen?`)) return; + knopf.disabled = true; + const r = await schicke('/workspace/api/leads/' + l.id + '/onboarding', 'POST', { name: l.name }); + knopf.disabled = false; + if (!r) return; + $('code-wert').textContent = r.code; + $('code-schleier').hidden = false; + $('code-zu').focus(); + await laden(); + } + + /* ---------- Zeichnen ----------------------------------------------------- */ + + function zeichne() { + const ziel = $('pipeline'); + ziel.textContent = ''; + ziel.setAttribute('aria-busy', 'false'); + + const scout = $('f-scout').value; + const liste = scout ? daten.filter((l) => String(l.scout_id) === scout) : daten; + + /* Follow-ups, die heute oder frueher faellig sind. */ + const fällig = liste.filter((l) => l.naechster_followup && l.naechster_followup <= heute + && l.status !== 'uebergeben' && l.status !== 'abgelehnt'); + $('faellig').hidden = fällig.length === 0; + const fl = $('faellig-liste'); + fl.textContent = ''; + for (const l of fällig) { + const li = el('li'); + const b = el('button', 'faellig__knopf', l.name); + b.type = 'button'; + b.addEventListener('click', () => { + offen.add(l.id); zeichne(); + document.getElementById('lead-' + l.id)?.scrollIntoView({ block: 'center', behavior: 'smooth' }); + }); + li.append(b, el('span', 'kk__still', datum(l.naechster_followup))); + fl.append(li); + } + + if (!liste.length) { + ziel.append(el('p', 'leer-hinweis', + 'Noch niemand in der Pipeline. „Neuer Kontakt" legt den ersten an.')); + return; + } + + for (const [wert, name] of Object.entries(stufen)) { + const drin = liste.filter((l) => l.status === wert); + if (!drin.length) continue; + const gruppe = el('section', 'stufe'); + gruppe.dataset.stufe = wert; + const kopf = el('div', 'stufe__kopf'); + kopf.append(el('h2', 'stufe__titel', name), el('span', 'stufe__zahl', String(drin.length))); + gruppe.append(kopf); + const box = el('div', 'stufe__karten'); + for (const l of drin) { + const k = karte(l); + k.id = 'lead-' + l.id; + box.append(k); + } + gruppe.append(box); + ziel.append(gruppe); + } + } + + async function laden() { + try { + const a = await hole('/workspace/api/leads'); + if (!a.ok) { melde('Pipeline konnte nicht geladen werden.'); return; } + const d = await a.json(); + stufen = d.stufen; heute = d.heute; daten = d.leads; + zeichne(); + } catch { /* umgeleitet */ } + } + + /* ---------- Neuer Kontakt ------------------------------------------------ */ + + $('neu-auf').addEventListener('click', () => { + $('neu-block').hidden = false; + $('neu-auf').hidden = true; + $('n-name').focus(); + }); + $('neu-zu').addEventListener('click', () => { + $('neu-block').hidden = true; + $('neu-auf').hidden = false; + $('neu-form').reset(); + $('n-fehler').textContent = ''; + }); + + $('neu-form').addEventListener('submit', async (e) => { + e.preventDefault(); + $('n-fehler').textContent = ''; + const knopf = $('n-speichern'); + knopf.disabled = true; + const a = await hole('/workspace/api/leads', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: $('n-name').value, plattform: $('n-plattform').value, + handle: $('n-handle').value, prioritaet: $('n-prio').value, + aktivitaet: $('n-aktivitaet').value, potenzial: $('n-potenzial').value, + naechster_followup: $('n-followup').value, + scout_id: $('n-scout').value || null, + }), + }).catch(() => null); + knopf.disabled = false; + if (!a) return; + if (!a.ok) { + $('n-fehler').textContent = (await a.json().catch(() => ({}))).fehler || 'Ging nicht.'; + return; + } + $('neu-zu').click(); + await laden(); + }); + + /* ---------- Codefenster --------------------------------------------------- */ + + $('code-zu').addEventListener('click', () => { + $('code-schleier').hidden = true; + $('code-wert').textContent = '—'; + }); + $('code-kopieren').addEventListener('click', async () => { + try { + await navigator.clipboard.writeText($('code-wert').textContent); + $('code-kopieren').textContent = 'Kopiert ✓'; + setTimeout(() => { $('code-kopieren').textContent = 'Kopieren'; }, 2200); + } catch { /* ohne Zwischenablage bleibt der Code lesbar stehen */ } + }); + + /* ---------- Start --------------------------------------------------------- */ + + (async () => { + try { + const a = await hole('/workspace/api/ich'); + if (!a.ok) { location.assign('/workspace/'); return; } + ich = await a.json(); + } catch { return; } + if (ich.rolle === 'creator') { location.assign('/workspace/start.html'); return; } + $('wer').textContent = ich.name + ' · ' + ich.rolle; + + if (ich.rolle === 'admin') { + $('unterzeile').textContent = 'Gesamtübersicht über alle Scouts.'; + try { + const { scouts } = await (await hole('/workspace/api/leads-scouts')).json(); + for (const s of scouts) { + for (const wo of [$('f-scout'), $('n-scout')]) { + const o = document.createElement('option'); + o.value = String(s.id); o.textContent = s.name; + wo.append(o); + } + } + $('scoutwahl-block').hidden = false; + $('n-scout-block').hidden = false; + } catch { return; } + $('f-scout').addEventListener('change', zeichne); + } else { + $('unterzeile').textContent = 'Deine Pipeline – nur du und das Management sehen sie.'; + } + + await laden(); + })(); +})(); diff --git a/workspace/assets/js/start.js b/workspace/assets/js/start.js index a568126d..2289e1c4 100644 --- a/workspace/assets/js/start.js +++ b/workspace/assets/js/start.js @@ -30,7 +30,7 @@ { name: 'Schutz & Regeln', phase: 2, rollen: ['admin', 'creator'], ziel: 'bereich.html?b=schutz' }, { name: 'Reports & Review', phase: 2, rollen: ['admin', 'creator'], ziel: 'report.html' }, { name: 'Calls', phase: 3, rollen: ['admin', 'creator', 'scout'] }, - { name: 'Scout-Pipeline', phase: 3, rollen: ['admin', 'scout'] }, + { name: 'Scout-Pipeline', phase: 3, rollen: ['admin', 'scout'], ziel: 'scouting.html' }, { name: 'Automationen', phase: 4, rollen: ['admin'] }, ]; diff --git a/workspace/scouting.html b/workspace/scouting.html new file mode 100644 index 00000000..a6f0bf51 --- /dev/null +++ b/workspace/scouting.html @@ -0,0 +1,130 @@ + + + + + +Scouting · Creator Workspace + + + + + + + + + + + +
+ +

Creator Workspace · Scouting

+
+ … + +
+
+ +
+ +
+
+

Scout-CRM

+

Pipeline

+

Vom ersten Fund bis zur Übergabe – nachvollziehbar statt im Kopf.

+
+
+ + +
+
+ + + + + + + + +

Pipeline wird geladen …

+ +
+ + + + + + + +