Auf Wunsch von Filipe. Betrifft ausschliesslich die Anzeige.
Der Rollenschluessel bleibt "admin". Er steckt in der CHECK-Regel der
Datenbank, in jeder bestehenden Sitzung und in jeder Rechteabfrage --
ihn umzubenennen haette alle drei gebrochen, und bestehende Anmeldungen
waeren ungueltig geworden. Umbenannt wird nur, was man LIEST.
Der Anzeigename steht jetzt an EINER Stelle (ROLLEN_NAME in
workspace.js) und kommt ueber /workspace/api/ich als rolle_name mit.
Stand er in elf Dateien, waere er beim naechsten Mal in zehn davon
geaendert.
Dabei aufgefallen: In der Kopfleiste stand auf JEDER Seite der rohe
Rollenschluessel -- "Chef · admin". Das war schon vorher unschoen, faellt
aber jetzt erst richtig auf. Alle elf Seiten zeigen jetzt "Chef ·
DogFather". Geprueft: kein rohes "admin" mehr in der Oberflaeche.
Geaendert: Rollenkachel und Untertitel auf der Anmeldeseite, Kopfleiste
aller Seiten, Rollentext auf dem Dashboard, Rollenmarken und Auswahl in
der Personenverwaltung, die Betreuungs-Auswahl ("nur DogFather"), der
Hinweis auf der Dateienseite, die Erklaerung zur internen Notiz im
Profil, die Hinweise fuer Scouts ohne Zuteilung -- und vier
Fehlermeldungen vom Server.
Schreibweise "DogFather" wie von Filipe geschrieben; das ist auch auf
der oeffentlichen Website die haeufigste Form (717 von 1216).
In den Code-Kommentaren der Fachmodule heisst die Rolle weiterhin "das
Management". Das bleibt bewusst so -- eine Massenaenderung an vierzig
Kommentaren waere reines Risiko ohne sichtbaren Nutzen. Ein Hinweis an
der ROLLEN_NAME-Zuordnung erklaert den Zusammenhang.
367 lines
13 KiB
JavaScript
367 lines
13 KiB
JavaScript
/* ===================================================================
|
||
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_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 DogFather sehen sie.';
|
||
}
|
||
|
||
await laden();
|
||
})();
|
||
})();
|