Files
dogfather-universe/workspace/assets/js/scouting.js
T
DogFatherGit d845d91d70 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.
2026-08-28 10:57:42 +02:00

367 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ===================================================================
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();
})();
})();