Scout-CRM
+Pipeline
+Vom ersten Fund bis zur Übergabe – nachvollziehbar statt im Kopf.
+Follow-up fällig
+Neuer Kontakt
+ +Pipeline wird geladen …
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 @@ + + +
+ + +Creator Workspace · Scouting
+Scout-CRM
+Vom ersten Fund bis zur Übergabe – nachvollziehbar statt im Kopf.
+Follow-up fällig
+Pipeline wird geladen …
Onboarding gestartet
++ Dieser Code wird genau einmal angezeigt und ist danach nicht mehr + abrufbar. Gib ihn persönlich weiter, nicht über einen offenen Kanal. +
+—
+