Scouting: links die normalen Creator, rechts die Premium

Filipe: "ich will dass es da in der mitte eine trennung gibt. links
sollen normale creator sein und rechts premium creator. es gibt bei
tiktok naemlich diese zwei optionen von streamer ... auch dass wenn man
die eintraegt soll man aussuchen koennen."

Jede Stufe der Pipeline hat jetzt zwei Spalten mit einer Trennlinie
dazwischen. Die Trennung sitzt IN der Stufe, nicht einmal ueber der
ganzen Seite: Zwei komplette Pipelines nebeneinander haetten jede
Stufenueberschrift verdoppelt und die beiden Seiten waeren nie auf
gleicher Hoehe gewesen.

GENAU ZWEI WERTE, kein "weiss nicht". Anders als bei `netzwerk`, wo
"noch nicht gefragt" eine eigene gueltige Antwort ist: Dort wird eine
fremde Tatsache festgehalten, hier eine eigene Absicht -- und deren
Normalfall ist "normal". NULL wird als "normal" gelesen, der eine
vorhandene Lead steht damit links, ohne dass ihm etwas unterstellt wird.

DIE ART UEBERLEBT DIE UEBERGABE. Beim Creator-Onboarding wandert sie auf
die Person und ist in der Personenverwaltung aenderbar. Ohne das endet
die Angabe genau dort, wo sie zum ersten Mal vertraglich zaehlt.

Die Schwelle misst den STUFENKASTEN (@container), nicht das Fenster --
der Fehler vom 06.09.2026 im selben Haus. Auf dem Handy liegen die
Spalten untereinander, die senkrechte Linie faellt weg.

ZWEI FUNDE BEIM BAUEN, beide von Hauswachen:
 * `pruef-fingermass` fand, dass meine neue Handy-Messung mit
   `setViewportSize` statt eigenem Kontext lief -- ohne `hasTouch`
   greift keine einzige Regel aus `@media (pointer: coarse)`.
 * Die Klassen hiessen zuerst `.spalte__*` -- die gehoeren `aufgaben.css`,
   und scouting.html laedt die VOR scouting.css. `margin-left: auto` aus
   dem Aufgabenbrett zog die Zahl 560 px von ihrem Wort weg, ohne dass
   eine Pruefung rot wurde. Jetzt `.art-spalte__*`, und der Abstand wird
   gemessen.

pruef-scouting-felder: 28 -> 64 Pruefungen, gruen. Zwei Gegenproben
gefahren (Trennung deaktiviert, Spaltenreihenfolge gedreht) -- beide
schlugen an. Dazu gruen: haus-trennung, css-klassen, personen-liste,
personen-kachel, hand-personen, schranke, formulare, lesbarkeit,
agentur, deutsche-texte, tippziele, leerzustand, fingermass.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-10-06 23:33:12 +02:00
co-authored by Claude Opus 5
parent c4bdc1c4c2
commit f18ab32f8c
55 changed files with 1588 additions and 701 deletions
+189
View File
@@ -0,0 +1,189 @@
/* Bild der Trennung Normal / Premium in der Scout-Pipeline (06.10.2026)
Filipe: "ich will dass es da in der mitte eine trennung gibt. links
sollen normale creator sein und rechts premium creator."
Die Zahlen sind gruen (pruef-scouting-felder, 63 Pruefungen) -- das
sagt, dass die Kaesten an der richtigen Stelle liegen, aber nicht,
wie es AUSSIEHT. Dafuer ist dieses Werkzeug da.
Aufrufen mit: node server/bild-arten.mjs
*/
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
const ordner = mkdtempSync(join(tmpdir(), "ws-arten-"));
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
const { portMussFreiSein } = await import("./helfer-port.mjs");
const PORT = await portMussFreiSein(4336, "das Bildwerkzeug Arten");
process.env.PORT = String(PORT);
process.env.SITE_ACCESS_SECRET = "lokaler-test";
process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00";
const express = (await import("express")).default;
const ec = express.response.cookie;
express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); };
await import("./index.js");
await new Promise((r) => setTimeout(r, 1200));
const BASIS = `http://127.0.0.1:${PORT}`;
const { DatabaseSync } = await import("node:sqlite");
const { scryptSync, randomBytes } = await import("node:crypto");
for (let i = 0; i < 60; i++) {
try {
const p = new DatabaseSync(process.env.WORKSPACE_DB);
try { p.prepare("SELECT 1 FROM personen LIMIT 1").get(); break; } finally { p.close(); }
} catch { await new Promise((r) => setTimeout(r, 250)); }
}
const d = new DatabaseSync(process.env.WORKSPACE_DB);
const j = new Date().toISOString();
const heute = j.slice(0, 10);
function anlegen(name, rolle, code) {
const salt = randomBytes(16).toString("hex");
const hash = scryptSync(code, salt, 64, { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
d.prepare("INSERT INTO personen (name,rolle,code_hash,code_salt,code_n,aktiv,erstellt) VALUES (?,?,?,?,?,1,?)")
.run(name, rolle, hash, salt, 32768, j);
return d.prepare("SELECT last_insert_rowid() AS id").get().id;
}
const dogi = anlegen("Filipe", "admin", "CODE-DOGI-0001");
const scout = anlegen("BananaStift", "scout", "CODE-SCOU-0001");
/* Ein realistischer Stand: beide Arten, mehrere Stufen, eine leere
Spalte -- genau die Faelle, die im Alltag vorkommen. */
const lead = (name, handle, status, art, prio, land, follower, netz, followup) =>
d.prepare(`INSERT INTO leads
(name, plattform, handle, status, prioritaet, scout_id, erstellt, erstellt_von,
creator_art, land, follower, netzwerk, naechster_followup, live_zeiten)
VALUES (?,'TikTok',?,?,?,?,?,?,?,?,?,?,?,?)`)
.run(name, handle, status, prio, scout, j, dogi, art, land, follower, netz,
followup, "Mo-Fr ab 21 Uhr");
lead("Tamy", "@coolemama1991", "neu", "normal", "hoch", "DE", 12400, "nein", heute);
lead("Mira", "@mira.live", "neu", "normal", "mittel", "AT", 3200, "unbekannt", null);
lead("Nala", "@nala", "neu", "premium", "hoch", "CH", 184000, "nein", null);
lead("Jo", "@jo_streamt", "angesprochen", "normal", "mittel", "DE", 8700, "nein", null);
lead("Elif", "@elif", "angesprochen", "premium", "hoch", "DE", 92000, "nein", heute);
lead("Sam", "@samsam", "gespraech", "normal", "niedrig", "LU", 1500, "ja", null);
d.close();
const pw = await import("file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
const browser = await pw.chromium.launch();
const fehlerAufSeite = [];
const anmelden = async (seite, rolle, code) => {
await seite.goto(BASIS + "/workspace/", { waitUntil: "networkidle" });
await seite.click(`.rolle[data-rolle="${rolle}"]`);
await seite.fill("#code", code);
await seite.click("button[type=submit]");
await seite.waitForURL("**/start.html", { timeout: 25000 });
};
/* ---- 1. Am Rechner ---------------------------------------------------- */
const ctx = await browser.newContext({ viewport: { width: 1400, height: 1100 }, deviceScaleFactor: 2 });
const seite = await ctx.newPage();
seite.on("console", (m) => { if (m.type() === "error") fehlerAufSeite.push(m.text()); });
seite.on("pageerror", (f) => fehlerAufSeite.push(String(f)));
await anmelden(seite, "scout", "CODE-SCOU-0001");
await seite.goto(BASIS + "/workspace/scouting.html", { waitUntil: "networkidle" });
await seite.waitForTimeout(1800);
console.log("\n=== 1. Am Rechner (1400 px) ===");
const spalten = await seite.$$eval(".art-spalte", (ks) => ks.map((k) => ({
stufe: k.closest(".stufe")?.dataset.stufe,
art: k.dataset.art,
titel: k.querySelector(".art-spalte__titel")?.textContent.trim(),
zahl: k.querySelector(".art-spalte__zahl")?.textContent.trim(),
x: Math.round(k.getBoundingClientRect().x),
breite: Math.round(k.getBoundingClientRect().width),
linie: getComputedStyle(k).borderLeftWidth,
namen: [...k.querySelectorAll(".kk__name")].map((n) => n.textContent.trim()).join(", ")
|| k.querySelector(".art-spalte__leer")?.textContent.trim(),
})));
for (const s of spalten) {
console.log(` ${String(s.stufe).padEnd(14)} ${String(s.titel).padEnd(8)} ${String(s.zahl).padStart(2)} `
+ `x=${String(s.x).padStart(4)} b=${String(s.breite).padStart(4)} Linie=${s.linie.padStart(5)} ${s.namen}`);
}
const stufe1 = await seite.$('.stufe[data-stufe="neu"]');
await stufe1.screenshot({ path: "server/bild-arten-rechner.png" });
await seite.screenshot({ path: "server/bild-arten-rechner-ganz.png", fullPage: false });
/* ---- 2. Das Formular -------------------------------------------------- */
await seite.click("#neu-auf");
await seite.waitForTimeout(600);
const feld = await seite.$("#neu-block");
await feld.screenshot({ path: "server/bild-arten-formular.png" });
console.log("\n=== 2. Im Formular ===");
console.log(" Auswahl:", (await seite.$$eval("#n-art option",
(os) => os.map((o) => o.textContent.trim()))).join(" | "));
await seite.click("#neu-zu");
/* ---- 3. Auf dem Handy (mit Finger!) ----------------------------------- */
const handyKontext = await browser.newContext({
viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true, deviceScaleFactor: 3,
});
await handyKontext.addCookies(await ctx.cookies());
const handy = await handyKontext.newPage();
await handy.goto(BASIS + "/workspace/scouting.html", { waitUntil: "networkidle" });
await handy.waitForTimeout(1800);
console.log("\n=== 3. Auf dem Handy (390 px) ===");
const schmal = await handy.$$eval('.stufe[data-stufe="neu"] .art-spalte', (ks) => ks.map((k) => ({
art: k.dataset.art, y: Math.round(k.getBoundingClientRect().y),
breite: Math.round(k.getBoundingClientRect().width),
ueberRand: k.getBoundingClientRect().right > window.innerWidth,
})));
for (const s of schmal) {
console.log(` ${String(s.art).padEnd(8)} y=${String(s.y).padStart(5)} b=${s.breite}`
+ (s.ueberRand ? " UEBER DEN RAND" : ""));
}
const stufeH = await handy.$('.stufe[data-stufe="neu"]');
await stufeH.screenshot({ path: "server/bild-arten-handy.png" });
/* ---- 4. Die Personenseite nach einer Uebergabe ------------------------- */
const ctxD = await browser.newContext({ viewport: { width: 1400, height: 1100 }, deviceScaleFactor: 2 });
const dogiSeite = await ctxD.newPage();
await anmelden(dogiSeite, "admin", "CODE-DOGI-0001");
await dogiSeite.goto(BASIS + "/workspace/scouting.html", { waitUntil: "networkidle" });
await dogiSeite.waitForTimeout(1500);
/* Elif (Premium) durch die Pipeline bis zur Uebergabe und ins Onboarding. */
const elifId = await dogiSeite.evaluate(async () => {
const a = await fetch("/workspace/api/leads", { credentials: "same-origin" });
const d2 = await a.json();
return d2.leads.find((l) => l.name === "Elif")?.id;
});
await dogiSeite.evaluate(async (id) => {
await fetch("/workspace/api/leads/" + id, {
method: "PATCH", credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: "uebergeben" }),
});
await fetch(`/workspace/api/leads/${id}/onboarding`, {
method: "POST", credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "Elif" }),
});
}, elifId);
await dogiSeite.goto(BASIS + "/workspace/personen.html", { waitUntil: "networkidle" });
await dogiSeite.waitForTimeout(1800);
console.log("\n=== 4. Auf der Personenseite ===");
/* Die Rollengruppen sind zugeklappt (Vorgabe: nur DogFather offen).
Ohne diesen Klick steht die Creator-Kachel gar nicht im Dokument --
beim ersten Lauf meldete dieser Abschnitt deshalb nichts, und das
sah aus wie ein Fehler an der Art. */
for (const kopf of await dogiSeite.$$(".gruppe__kopf--klappbar")) {
const t = (await kopf.textContent()) || "";
if (t.toLowerCase().includes("creator")) { await kopf.click(); await dogiSeite.waitForTimeout(800); }
}
const creator = await dogiSeite.$$eval('.person[data-rolle="creator"]', (ks) => ks.map((k) => ({
name: k.querySelector(".person__name")?.textContent.trim(),
marken: [...k.querySelectorAll(".marke-rolle")].map((m) => m.textContent.trim()).join(" - "),
art: k.querySelector('select[id^="creator-art-"]')?.value,
})));
for (const c of creator) console.log(` ${String(c.name).padEnd(14)} [${c.marken}] Auswahl: ${c.art}`);
const kachel = await dogiSeite.$('.person[data-rolle="creator"]');
if (kachel) await kachel.screenshot({ path: "server/bild-arten-person.png" });
console.log("\nFehler auf der Seite:", fehlerAufSeite.length ? fehlerAufSeite.join(" | ") : "keine");
await browser.close();
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* Aufraeumrest */ }
process.exit(0);
+233 -2
View File
@@ -222,7 +222,113 @@ console.log("\n=== Die Pipeline gibt es auf der crew-Adresse nicht ===");
}
/* =======================================================================
5. Und die Oberflaeche zeigt es auch
5. Normal oder Premium -- die beiden Arten, die TikTok kennt
======================================================================= */
console.log("\n=== Normal und Premium ===");
let premiumId = null, uebergebenId = null;
{
/* Die Liste kommt vom Server, damit die Oberflaeche keine eigene
fuehrt -- und die REIHENFOLGE ist die der Spalten: links, rechts. */
const { daten } = await ruf("GET", "/workspace/api/leads", keksScout);
ok(Array.isArray(daten?.arten) && daten.arten.length === 2
&& daten.arten[0] === "normal" && daten.arten[1] === "premium",
`die Artenliste kommt vom Server, in der Reihenfolge der Spalten (${(daten?.arten || []).join(", ")})`);
ok(daten?.art_name?.premium === "Premium",
`und ihre Namen (${daten?.art_name?.premium})`);
/* DER LEAD VON OBEN HAT NIE EINE ART BEKOMMEN. Er muss trotzdem in
einer der beiden Spalten liegen, sonst waere er unsichtbar. */
const alt = (daten?.leads || []).find((x) => x.id === leadId);
ok(alt?.creator_art === "normal",
`ein Kontakt ohne Angabe gilt als "normal" (${alt?.creator_art})`);
}
{
const { status, daten } = await ruf("POST", "/workspace/api/leads", keksScout, {
name: "Nala", plattform: "TikTok", handle: "@nala",
creator_art: "premium", status: "angesprochen",
});
ok(status === 201, `ein Premium-Kontakt laesst sich anlegen (HTTP ${status})`);
premiumId = daten?.id;
const liste = await ruf("GET", "/workspace/api/leads", keksScout);
const l = (liste.daten?.leads || []).find((x) => x.id === premiumId);
ok(l?.creator_art === "premium", `und bleibt Premium (${l?.creator_art})`);
}
{
/* GEGENPROBEN. Eine erfundene Art wird NICHT stillschweigend zu
"normal" gemacht -- wer sich vertippt, soll es erfahren, statt die
Karte in der falschen Spalte zu suchen. */
const falsch = await ruf("PATCH", `/workspace/api/leads/${premiumId}`, keksScout,
{ creator_art: "vip" });
ok(falsch.status === 400, `eine erfundene Art wird abgelehnt (HTTP ${falsch.status})`);
/* Und die Gegenprobe zur Gegenprobe: Umstellen geht. Ohne sie hiesse
"wird abgelehnt" auch "wird immer abgelehnt". */
const zurueck = await ruf("PATCH", `/workspace/api/leads/${premiumId}`, keksScout,
{ creator_art: "normal" });
ok(zurueck.status === 200, `auf "normal" umstellen geht (HTTP ${zurueck.status})`);
const wieder = await ruf("PATCH", `/workspace/api/leads/${premiumId}`, keksScout,
{ creator_art: "premium" });
ok(wieder.status === 200, `und wieder zurueck auf "premium" (HTTP ${wieder.status})`);
const liste = await ruf("GET", "/workspace/api/leads", keksScout);
const l = (liste.daten?.leads || []).find((x) => x.id === premiumId);
ok(l?.creator_art === "premium", `der Wert steht danach richtig da (${l?.creator_art})`);
}
/* =======================================================================
6. Die Art ueberlebt die Uebergabe
======================================================================= */
console.log("\n=== Vom Lead zum Creator ===");
{
const keksDogi = await anmelden("admin", "CODE-DOGI-0001");
const neu = await ruf("POST", "/workspace/api/leads", keksScout, {
name: "Nova", plattform: "TikTok", handle: "@nova",
creator_art: "premium", status: "uebergeben",
});
uebergebenId = neu.daten?.id;
ok(neu.status === 201, `ein uebergebener Premium-Lead entsteht (HTTP ${neu.status})`);
const ob = await ruf("POST", `/workspace/api/leads/${uebergebenId}/onboarding`, keksDogi,
{ name: "Nova" });
ok(ob.status === 201, `das Onboarding legt den Creator an (HTTP ${ob.status})`);
const creatorId = ob.daten?.id;
const liste = await ruf("GET", "/workspace/api/verwaltung/personen", keksDogi);
const person = (liste.daten?.personen || []).find((x) => x.id === creatorId);
/* DAS IST DER PUNKT DIESES ABSCHNITTS: Ohne diese Zeile endet die
Angabe genau dort, wo sie zum ersten Mal vertraglich zaehlt. */
ok(person?.creator_art === "premium",
`und die Art geht mit auf den Creator (${person?.creator_art})`);
ok(Array.isArray(liste.daten?.arten) && liste.daten.arten.length === 2,
`die Personenliste bringt dieselbe Artenliste mit (${(liste.daten?.arten || []).join(", ")})`);
/* AENDERBAR, nicht nur gesetzt. Ein Wert, den man einmal setzt und nie
wieder, war am 11.09.2026 der eigentliche Mangel der Pipeline. */
const um = await ruf("PUT", `/workspace/api/verwaltung/personen/${creatorId}/creator-art`,
keksDogi, { creator_art: "normal" });
ok(um.status === 200, `die Art laesst sich am Creator aendern (HTTP ${um.status})`);
const danach = await ruf("GET", "/workspace/api/verwaltung/personen", keksDogi);
const p2 = (danach.daten?.personen || []).find((x) => x.id === creatorId);
ok(p2?.creator_art === "normal", `und steht danach auf normal (${p2?.creator_art})`);
/* Gegenproben: Unsinn und falsche Rolle. */
const unsinn = await ruf("PUT", `/workspace/api/verwaltung/personen/${creatorId}/creator-art`,
keksDogi, { creator_art: "deluxe" });
ok(unsinn.status === 400, `eine erfundene Art wird auch hier abgelehnt (HTTP ${unsinn.status})`);
const scoutPerson = (danach.daten?.personen || []).find((x) => x.rolle === "scout");
const falscheRolle = await ruf("PUT",
`/workspace/api/verwaltung/personen/${scoutPerson?.id}/creator-art`, keksDogi,
{ creator_art: "premium" });
ok(falscheRolle.status === 404,
`an einem Scout gibt es keine Creator-Art (HTTP ${falscheRolle.status})`);
/* Zurueck auf Premium -- die Oberflaechenpruefung unten erwartet es so. */
await ruf("PUT", `/workspace/api/verwaltung/personen/${creatorId}/creator-art`,
keksDogi, { creator_art: "premium" });
}
/* =======================================================================
7. Und die Oberflaeche zeigt es auch
======================================================================= */
console.log("\n=== Die Seite selbst ===");
{
@@ -261,6 +367,79 @@ console.log("\n=== Die Seite selbst ===");
.catch(() => null);
ok(warn === "Schon im Netzwerk", `die Netzwerk-Warnung ist zu sehen (${warn})`);
/* ===== LINKS NORMAL, RECHTS PREMIUM =============================
Filipe: "ich will dass es da in der mitte eine trennung gibt."
Gemessen wird nicht, ob die Klassen da sind, sondern wo die Kaesten
WIRKLICH liegen -- links/rechts, auf gleicher Hoehe, mit einer
Trennlinie dazwischen. Eine Pruefung auf Klassennamen waere auch
dann gruen, wenn das CSS gar nicht geladen haette. */
const spaltenLesen = (wo, stufe) => wo.$$eval(
`.stufe[data-stufe="${stufe}"] .art-spalte`, (ks) => ks.map((k) => {
const r = k.getBoundingClientRect();
const stil = getComputedStyle(k);
return {
art: k.dataset.art,
titel: k.querySelector(".art-spalte__titel")?.textContent.trim() || null,
zahl: k.querySelector(".art-spalte__zahl")?.textContent.trim() || null,
/* Wie weit steht die Zahl von ihrem Wort entfernt? Siehe den
Fund vom 06.10.2026 in scouting.css: Eine fremde Regel aus
`aufgaben.css` hat sie ans andere Ende der Spalte gezogen,
und keine Pruefung wurde rot. */
abstand: (() => {
const t = k.querySelector(".art-spalte__titel");
const z = k.querySelector(".art-spalte__zahl");
if (!t || !z) return null;
return Math.round(z.getBoundingClientRect().left - t.getBoundingClientRect().right);
})(),
x: Math.round(r.x), y: Math.round(r.y), breite: Math.round(r.width),
randLinks: parseFloat(stil.borderLeftWidth) || 0,
namen: [...k.querySelectorAll(".kk__name")].map((n) => n.textContent.trim()),
leer: k.querySelector(".art-spalte__leer")?.textContent.trim() || null,
};
}));
const neuSpalten = await spaltenLesen(seite, "neu");
ok(neuSpalten.length === 2, `die Stufe "Neu" hat zwei Spalten (${neuSpalten.length})`);
ok(neuSpalten[0]?.art === "normal" && neuSpalten[0]?.titel === "Normal",
`links steht "Normal" (${neuSpalten[0]?.titel})`);
ok(neuSpalten[1]?.art === "premium" && neuSpalten[1]?.titel === "Premium",
`rechts steht "Premium" (${neuSpalten[1]?.titel})`);
/* LINKS IST WIRKLICH LINKS -- in Pixeln, nicht in der Reihenfolge im
Quelltext. `order` oder `direction` koennten sie drehen, ohne dass
am HTML etwas auffaellt. */
ok(neuSpalten.length === 2 && neuSpalten[1].x > neuSpalten[0].x + 100,
`und zwar rechts DANEBEN (x ${neuSpalten[0]?.x} -> ${neuSpalten[1]?.x})`);
ok(neuSpalten.length === 2 && Math.abs(neuSpalten[0].y - neuSpalten[1].y) <= 4,
`beide beginnen auf gleicher Hoehe (y ${neuSpalten[0]?.y} / ${neuSpalten[1]?.y})`);
ok((neuSpalten[1]?.randLinks || 0) >= 1,
`die Trennung in der Mitte ist eine echte Linie (${neuSpalten[1]?.randLinks} px)`);
/* DIE ZAHL GEHOERT NEBEN IHR WORT. Nicht Geschmack: Eine Zahl am
anderen Ende einer 580 px breiten Spalte gehoert optisch zu nichts
mehr. Gefunden wurde das nur im Bildschirmfoto -- diese Messung
holt es in die Zahlen. */
ok(neuSpalten.every((k) => k.abstand !== null && k.abstand >= 0 && k.abstand <= 20),
`die Zahl steht neben ihrem Wort (${neuSpalten.map((k) => k.abstand + " px").join(" / ")})`);
/* UND DIE KARTEN LIEGEN AUCH DORT, WO SIE HINGEHOEREN. */
ok(neuSpalten[0]?.namen.includes("Tamy"),
`der normale Kontakt steht links (${(neuSpalten[0]?.namen || []).join(", ") || "nichts"})`);
ok(neuSpalten[1]?.leer === "noch niemand",
`die leere Premium-Spalte bleibt stehen und sagt es (${neuSpalten[1]?.leer})`);
const angSpalten = await spaltenLesen(seite, "angesprochen");
ok(angSpalten[1]?.namen.includes("Nala"),
`der Premium-Kontakt steht rechts (${(angSpalten[1]?.namen || []).join(", ") || "nichts"})`);
ok(angSpalten[0]?.leer === "noch niemand",
`und links steht dort "noch niemand" (${angSpalten[0]?.leer})`);
/* Die Marke auf der Karte -- nur bei Premium, nirgends sonst. */
const premiumMarken = await seite.$$eval('.kk .marke-punkt[data-art="premium"]',
(ks) => ks.map((k) => k.closest(".kk")?.querySelector(".kk__name")?.textContent.trim()));
ok(premiumMarken.length === 2 && premiumMarken.includes("Nala") && premiumMarken.includes("Nova"),
`genau die zwei Premium-Karten tragen die Marke (${premiumMarken.join(", ")})`);
/* AUFKLAPPEN UND ZAEHLEN: Wie viele Felder stehen wirklich zur
Verfuegung? Das ist Filipes Frage ("was man da noch alles eintragen
kann") als Zahl. Vorher waren es fuenf. */
@@ -268,7 +447,59 @@ console.log("\n=== Die Seite selbst ===");
await seite.waitForTimeout(700);
const felder = await seite.$$eval(".kk__detail input, .kk__detail select, .kk__detail textarea",
(ks) => ks.length);
ok(felder >= 12, `im Editor stehen ${felder} Felder (vorher 5)`);
ok(felder >= 13, `im Editor stehen ${felder} Felder (vorher 5, mit der Art 13)`);
/* Die Art ist im Editor dabei -- sonst waere sie beim Anlegen zu
setzen und danach nie wieder zu aendern. */
const artImEditor = await seite.$$eval(".kk__detail select",
(ks) => ks.map((k) => [...k.options].map((o) => o.value).join(",")));
ok(artImEditor.includes("normal,premium"),
`im Editor steht die Art zur Auswahl (${artImEditor.join(" | ")})`);
/* Und beim ANLEGEN -- das war der zweite Teil von Filipes Wunsch. */
await seite.click("#neu-auf");
await seite.waitForTimeout(300);
const neuArt = await seite.$$eval("#n-art option",
(os) => os.map((o) => `${o.value}:${o.textContent.trim()}`));
ok(neuArt.length === 2 && neuArt[0] === "normal:Normal" && neuArt[1] === "premium:Premium",
`im Formular "Neuer Kontakt" kann man zwischen beiden waehlen (${neuArt.join(", ")})`);
await seite.click("#neu-zu");
await seite.waitForTimeout(250);
/* ===== GEGENPROBE: AUF DEM HANDY LIEGEN SIE UNTEREINANDER ========
Ohne diese Messung wuesste niemand, ob die Trennung ueberhaupt
umschaltet -- eine Spaltenregel, die bei 390 px zwei Spalten zu je
170 px macht, waere unbedienbar und waere oben trotzdem gruen
gewesen. Gleichzeitig beweist sie, dass die erste Messung etwas
messen KANN: Dieselben Kaesten, dieselbe Abfrage, anderes Ergebnis.
MIT EIGENEM KONTEXT UND MIT FINGER (hasTouch), nicht mit
`setViewportSize` auf derselben Seite. `hasTouch` gehoert zum
KONTEXT und laesst sich danach nicht nachtragen -- ohne ihn meldet
der Browser `pointer: fine`, und keine einzige Regel aus
`@media (pointer: coarse)` greift. Eine Messung mit Mauszeiger auf
390 px vermisst eine Seite, die es auf keinem Telefon gibt, und
meldet dabei "in Ordnung". Genau das hat `pruef-fingermass` hier
beim ersten Anlauf gefunden -- die Hauswache, die es dafuer gibt. */
const handyKontext = await browser.newContext({
viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true,
});
await handyKontext.addCookies(await ctx.cookies());
const handy = await handyKontext.newPage();
await handy.goto(seite.url(), { waitUntil: "networkidle" });
await handy.waitForTimeout(900);
const schmal = await spaltenLesen(handy, "neu");
ok(schmal.length === 2, `auf dem Handy sind es immer noch zwei Spalten (${schmal.length})`);
ok(schmal.length === 2 && schmal[1].y > schmal[0].y + 40,
`aber untereinander statt nebeneinander (y ${schmal[0]?.y} -> ${schmal[1]?.y})`);
ok(schmal.length === 2 && Math.abs(schmal[0].x - schmal[1].x) <= 2,
`und beide gleich weit links (x ${schmal[0]?.x} / ${schmal[1]?.x})`);
ok((schmal[1]?.randLinks || 0) === 0,
`die senkrechte Trennlinie faellt dabei weg (${schmal[1]?.randLinks} px)`);
ok(schmal.every((k) => k.breite >= 300),
`keine Spalte wird gequetscht (${schmal.map((k) => k.breite).join(" / ")} px)`);
await browser.close();
}
+66 -1
View File
@@ -22,6 +22,11 @@ import {
db, protokolliere, echteIp, sitzungLesen, personAnlegen, codeNeu, sitzungToken, personSperren, betreuungSetzen, scoutZuteilungSetzen, istLeitung, istDogFather, siehtModis, siehtAlles, ROLLEN_SORTIERUNG, ROLLEN_REIHE, istSpicy, verborgeneIds, TEAM_DOGI_ROLLEN, darfAnlegen, darfRollenWechseln, darfRolleAendern, rollenZumAendern, hausBedingung, istHand, fuehrtDieZugaenge,
} from "./workspace.js";
import { sicherungJetzt } from "./workspace-sicherung.js";
/* Normal und Premium stehen bei der Scout-Pipeline, weil sie dort
entstehen. Hier wird dieselbe Liste benutzt und keine zweite
angelegt -- sonst koennte eine Seite einen Wert anbieten, den die
andere ablehnt. */
import { ARTEN, ART_NAME, artVon } from "./workspace-scouts.js";
export const personenRouter = express.Router();
@@ -343,6 +348,12 @@ personenRouter.get("/workspace/api/verwaltung/personen", (req, res) => {
res.json({
personen: db().prepare(`
SELECT p.id, p.name, p.rolle, p.aktiv, p.erstellt, p.letzter_login,
/* Normal oder Premium (06.10.2026). Sie kommt beim
Creator-Onboarding aus der Scout-Pipeline mit und wird
ab da hier gepflegt. Bei allen anderen Rollen ist sie
leer und bedeutet nichts -- die Oberflaeche fragt
deshalb vorher nach der Rolle, nicht nach dem Wert. */
p.creator_art,
(SELECT COUNT(*) FROM sitzungen s WHERE s.person_id = p.id) AS sitzungen,
(SELECT COUNT(*) FROM aufgaben a
WHERE (a.creator_id = p.id OR a.verantwortlich_id = p.id)
@@ -383,7 +394,13 @@ personenRouter.get("/workspace/api/verwaltung/personen", (req, res) => {
LEFT JOIN scout_zuteilung sz ON sz.scout_id = p.id
WHERE 1=1${hausBedingung(req.person, "p.rolle")}
ORDER BY ${ROLLEN_SORTIERUNG.replace("rolle", "p.rolle")}, p.aktiv DESC, p.name`)
.all().filter((z) => !weg.has(z.id)),
.all().filter((z) => !weg.has(z.id))
/* NULL heisst "normal" -- aufgeloest wird das hier und nicht in
der Oberflaeche, damit es nicht zwei Meinungen darueber gibt.
Nur bei Creatorn: Bei allen anderen Rollen bleibt das Feld
leer, und "leer" ist dort die richtige Antwort. */
.map((z) => (z.rolle === "creator"
? { ...z, creator_art: artVon(z.creator_art) } : z)),
/* Wer ueberhaupt als zustaendig eingetragen werden kann.
Bis zum 31.08.2026 waren das nur Scouts. In der Auswahl stand
@@ -401,6 +418,14 @@ personenRouter.get("/workspace/api/verwaltung/personen", (req, res) => {
WHERE rolle IN ('admin', 'manager', 'scout') AND aktiv = 1
ORDER BY ${ROLLEN_SORTIERUNG}, name`).all().filter((z) => !weg.has(z.id)),
/* Normal und Premium kommen MIT -- aus derselben Liste wie in der
Scout-Pipeline. Die Oberflaeche fuehrt keine eigene: Sonst
stuenden die beiden Arten an drei Stellen im Haus (Server,
Pipeline-Seite, Personenseite), und die dritte waere die, die
beim naechsten Mal vergessen wird. Genau so ist es am 10.09.
schon einmal mit der Rollenwahl passiert. */
arten: ARTEN, art_name: ART_NAME,
/* =============================================================
ROLLEN, DIE NICHT IM BROWSER STEHEN DUERFEN (09.09.2026)
@@ -601,6 +626,46 @@ personenRouter.put("/workspace/api/verwaltung/betreuung/:id", gleicheHerkunft, (
}
});
/* ---------- Normal oder Premium (06.10.2026) -----------------------------
Die Art kommt beim Creator-Onboarding aus der Scout-Pipeline mit. Ab
hier haengt sie an der PERSON und laeuft dem Lead nicht mehr
hinterher: Ein Lead kann geloescht werden, ein Vertrag kann wechseln,
und die Pipeline ist ein Arbeitsstand, kein Register.
DESHALB MUSS SIE HIER AENDERBAR SEIN. Ein Wert, den man einmal setzt
und danach nie wieder -- genau das war Filipes Befund vom 11.09.2026
ueber die Pipeline ("man kan da nichts machen"), und ihn hier noch
einmal zu bauen waere derselbe Fehler mit einer anderen Spalte. */
personenRouter.put("/workspace/api/verwaltung/personen/:id/creator-art", gleicheHerkunft, (req, res) => {
try {
const id = Number(req.params.id);
if (!Number.isInteger(id)) return res.status(400).json({ fehler: "ungueltig" });
const creator = db().prepare("SELECT id, name, rolle FROM personen WHERE id = ?").get(id);
/* NUR an Creatorn. Ein Manager mit der Art "Premium" waere eine
Angabe, die nirgends etwas bedeutet -- und sie stuende dann in
der Liste, als haette jemand sie gemeint. */
if (!creator || creator.rolle !== "creator") {
return res.status(404).json({ fehler: "nicht_gefunden" });
}
const w = String(req.body?.creator_art ?? "").trim();
if (!ARTEN.includes(w)) return res.status(400).json({ fehler: "Unbekannte Creator-Art." });
db().prepare("UPDATE personen SET creator_art = ? WHERE id = ?").run(w, id);
protokolliere("creator_art_gesetzt", {
personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req),
detail: `#${id} ${creator.name} -> ${ART_NAME[w] || w}`.slice(0, 120),
});
res.json({ ok: true, creator_art: w });
} catch (fehler) {
console.error("[workspace] Creator-Art setzen:", fehler?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
/* ---------- Welcher Scout gehoert zu welchem Manager --------------------
"er soll nur die Scouts sehen, die ihm zugeteilt sind." (01.09.2026)
+56 -4
View File
@@ -55,6 +55,27 @@ export const NETZWERK = ["nein", "ja", "unbekannt"];
export const NETZWERK_NAME = {
nein: "frei", ja: "schon im Netzwerk", unbekannt: "noch nicht gefragt",
};
/* NORMAL ODER PREMIUM -- die beiden Arten, die TikTok kennt (06.10.2026).
Filipe: "es gibt bei tiktok nämlich diese zwei optionen von streamer."
GENAU ZWEI, und absichtlich kein "weiss nicht". Die Begruendung steht
bei der Spalte in workspace.js; kurz: Hier wird nicht eine fremde
Tatsache festgehalten, sondern eine eigene Absicht -- und deren
Normalfall ist "normal".
DIE REIHENFOLGE IST DIE DER SPALTEN auf der Seite: links normal,
rechts premium. Die Oberflaeche liest sie von hier, sie fuehrt keine
eigene Liste -- sonst stuende die Trennung an zwei Stellen und wuerde
an einer davon geaendert. */
export const ARTEN = ["normal", "premium"];
export const ART_NAME = { normal: "Normal", premium: "Premium" };
/* NULL ist "normal". Diese Umrechnung steht an EINER Stelle, weil sonst
jede Abfrage, jede Spaltenzuteilung und jede Zaehlung ihre eigene
Meinung dazu haette, wo ein Lead ohne Eintrag hingehoert. */
export const artVon = (wert) => (wert === "premium" ? "premium" : "normal");
const NAME_MAX = 120;
const TEXT_MAX = 3000;
@@ -120,6 +141,7 @@ const SPALTEN = `
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,
l.follower, l.land, l.woher, l.kontaktweg, l.live_zeiten, l.netzwerk, l.absage_grund,
l.creator_art,
ps.name AS scout_name, pc.name AS creator_name`;
const VERBUND = `
@@ -143,7 +165,11 @@ scoutRouter.get("/workspace/api/leads", (req, res) => {
andere nicht -- zuletzt bei der Rollenwahl am 10.09. */
laender: LAENDER, land_name: LAND_NAME,
netzwerk: NETZWERK, netzwerk_name: NETZWERK_NAME,
arten: ARTEN, art_name: ART_NAME,
heute,
/* `creator_art` wird hier schon aufgeloest (NULL -> "normal"),
damit die Oberflaeche nicht ihre eigene Regel dafuer braucht --
sie waere die zweite und wuerde irgendwann die andere sagen. */
leads: db().prepare(`
SELECT ${SPALTEN} ${VERBUND}
WHERE ${wo}
@@ -153,7 +179,8 @@ scoutRouter.get("/workspace/api/leads", (req, res) => {
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),
l.naechster_followup, l.id DESC`).all(...werte)
.map((l) => ({ ...l, creator_art: artVon(l.creator_art) })),
});
} catch (fehler) {
console.error("[workspace] Leads lesen:", fehler?.message);
@@ -216,6 +243,19 @@ function pruefe(körper, { neu }) {
else if (!NETZWERK.includes(w)) fehler.push("Unbekannte Angabe zum Netzwerk.");
else aus.netzwerk = w;
}
/* Normal oder Premium. Ein leerer Wert ist hier NICHT dasselbe wie bei
Land und Netzwerk: Dort heisst leer "nicht angegeben" und wird als
NULL gespeichert, hier gibt es keinen dritten Zustand -- leer faellt
auf "normal" zurueck, so wie es die Spalte ohnehin liest. Ein
erfundener Wert wird trotzdem abgelehnt und nicht stillschweigend
zu "normal" gemacht: Wer "premiumm" schickt, hat sich vertippt und
soll es erfahren, statt die Karte auf der falschen Seite zu finden. */
if (körper.creator_art !== undefined) {
const w = String(körper.creator_art ?? "").trim();
if (!w) aus.creator_art = "normal";
else if (!ARTEN.includes(w)) fehler.push("Unbekannte Creator-Art.");
else aus.creator_art = w;
}
if (körper.follower !== undefined) {
const roh = String(körper.follower ?? "").trim();
if (!roh) aus.follower = null;
@@ -270,8 +310,9 @@ scoutRouter.post("/workspace/api/leads", gleicheHerkunft, (req, res) => {
INSERT INTO leads
(name, plattform, handle, status, prioritaet, aktivitaet, potenzial,
notizen, letzte_nachricht, naechster_followup, scout_id, erstellt, erstellt_von,
follower, land, woher, kontaktweg, live_zeiten, netzwerk, absage_grund)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`).run(
follower, land, woher, kontaktweg, live_zeiten, netzwerk, absage_grund,
creator_art)
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,
@@ -279,7 +320,7 @@ scoutRouter.post("/workspace/api/leads", gleicheHerkunft, (req, res) => {
scoutId, jetzt(), req.person.id,
aus.follower ?? null, aus.land ?? null, aus.woher ?? null,
aus.kontaktweg ?? null, aus.live_zeiten ?? null, aus.netzwerk ?? null,
aus.absage_grund ?? null);
aus.absage_grund ?? null, aus.creator_art ?? "normal");
protokolliere("lead_angelegt", {
personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req),
@@ -382,6 +423,17 @@ scoutRouter.post("/workspace/api/leads/:id/onboarding", gleicheHerkunft, (req, r
db().prepare("UPDATE leads SET creator_id = ?, geaendert = ? WHERE id = ?")
.run(neu.id, jetzt(), id);
/* DIE ART GEHT MIT (06.10.2026).
Sonst endet die Angabe genau in dem Moment, in dem sie zum ersten
Mal vertraglich zaehlt: Der Scout haelt fest, dass jemand als
Premium geholt werden soll, das Onboarding legt die Person an --
und am Creator steht es nirgends mehr. Ab hier ist es eine Angabe
an der Person und laeuft dem Lead nicht mehr hinterher; geaendert
wird sie danach in der Personenverwaltung. */
db().prepare("UPDATE personen SET creator_art = ? WHERE id = ?")
.run(artVon(lead.creator_art), neu.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;
+27
View File
@@ -3921,7 +3921,34 @@ function umstellungen(d) {
["leads", "netzwerk", "TEXT"], // schon bei einer Agentur? nein/ja/unbekannt
["leads", "absage_grund", "TEXT"], // warum es nicht gepasst hat
/* ---- NORMAL ODER PREMIUM (06.10.2026) --------------------------
Filipe: "es gibt bei tiktok nämlich diese zwei optionen von
streamer ... auch dass wenn man die einträgt soll man aussuchen
können zwischen normalen und premium creator."
Es sind GENAU ZWEI Werte, kein dritter. Das ist bewusst anders
als bei `netzwerk`, wo "noch nicht gefragt" eine eigene, gueltige
Antwort ist: Dort wird eine TATSACHE ueber eine fremde Person
festgehalten, die man wirklich nicht kennen kann. Hier wird
entschieden, unter welcher Art WIR jemanden holen wollen -- und
der Normalfall ist "normal". Ein dritter Zustand waere eine
Spalte, in der die Haelfte der Karten liegt, und die Trennung,
um die es geht, waere wieder keine.
NULL heisst deshalb "normal" und wird auch so gelesen (siehe
`artVon()` in workspace-scouts.js). Die eine Zeile, die es am
06.10. schon gab, steht damit links, ohne dass eine Umstellung
ihr etwas unterstellt, das niemand eingetragen hat. */
["leads", "creator_art", "TEXT"], // normal | premium
["personen", "bild", "TEXT"], // Dateiname des Profilbildes
/* Die Art ueberlebt die Uebergabe (06.10.2026). Sie steht an der
PERSON und nicht im `profile`-Steckbrief: Unter welcher Art
jemand geholt wurde, ist eine Angabe der Agentur -- so wie die
Rolle --, keine, die der Creator ueber sich selbst schreibt.
Bei allen anderen Rollen bleibt sie leer und bedeutet nichts. */
["personen", "creator_art", "TEXT"], // normal | premium, nur bei Creatorn
["personen", "ueber_mich", "TEXT"], // ein Satz zur Person
["personen", "tiktok", "TEXT"], // öffentlicher Name, ohne @
["personen", "instagram", "TEXT"],