diff --git a/server/pruef-scouting-felder.mjs b/server/pruef-scouting-felder.mjs new file mode 100644 index 00000000..d58494e7 --- /dev/null +++ b/server/pruef-scouting-felder.mjs @@ -0,0 +1,281 @@ +/* ===================================================================== + DIE SCOUT-PIPELINE — die sieben neuen Felder und die Trennung. + + Filipe (11.09.2026), zur Pipeline: "perfektionnier diese kategorien + wie die aussehen und was man da noch alles immer in jeder kategorie + eintragen kann. weil man kan da nichts machen ... und das soll + getrennt von der team dogi seite sein." + + Drei Behauptungen stehen darin, und jede wird hier gemessen: + + 1. Man kann mehr eintragen -> die sieben Felder kommen an + 2. Der Server nimmt keinen Unsinn -> Land, Netzwerk, Follower + 3. Getrennt von Team Dogi -> auf crew. gibt es keine Leads + + WARUM AUCH DIE OBERFLAECHE GEMESSEN WIRD: "man kann nichts machen" + war ja gerade KEIN Serverproblem -- der Editor lag hinter einem Knopf + namens "Details" und wurde nicht gefunden. Ein gruener Servertest + haette das nie bemerkt. + + Aufruf: node server/pruef-scouting-felder.mjs + ===================================================================== */ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawn } from "node:child_process"; +import { DatabaseSync } from "node:sqlite"; +import { scryptSync, randomBytes } from "node:crypto"; +import { notbremse } from "./helfer-notbremse.mjs"; + +/* MILLISEKUNDEN, nicht Sekunden. Beim ersten Anlauf stand hier 240 -- + die Pruefung brach nach einer Viertelsekunde mit "HING" ab, obwohl + sie noch gar nicht angefangen hatte. Die Meldung war richtig, die + Zahl war es nicht. */ +notbremse(240_000, "pruef-scouting-felder"); + +const HIER = dirname(fileURLToPath(import.meta.url)); +const PORT = 4387; +const BASIS = `http://127.0.0.1:${PORT}`; +const ordner = mkdtempSync(join(tmpdir(), "ws-scoutf-")); +const DB = join(ordner, "workspace.db"); +const jetzt = new Date().toISOString(); + +let gemacht = 0, fehler = 0; +const ok = (b, text) => { + gemacht++; + if (b) console.log(` ok ${text}`); + else { fehler++; console.log(` FEHL ${text}`); } +}; + +let kind = null; +async function starten() { + kind = spawn(process.execPath, [join(HIER, "index.js")], { + env: { ...process.env, WORKSPACE_DB: DB, PORT: String(PORT), + SITE_ACCESS_SECRET: "lokaler-test", + SITE_PUBLIC_LAUNCH_AT: "2020-01-01T00:00:00+01:00" }, + stdio: ["ignore", "pipe", "pipe"] }); + let aus = ""; + kind.stdout.on("data", (d) => { aus += d; }); + kind.stderr.on("data", (d) => { aus += d; }); + for (let i = 0; i < 80; i++) { + await new Promise((r) => setTimeout(r, 250)); + if (kind.exitCode !== null) break; + try { const a = await fetch(BASIS + "/workspace/", { redirect: "manual" }); + if (a.status < 500) return; } catch { /* noch nicht oben */ } + } + throw new Error("Server startete nicht:\n" + aus.slice(-1500)); +} +await starten(); +kind.kill(); +await new Promise((r) => setTimeout(r, 600)); + +function anlegen(d, name, rolle, code) { + const salz = randomBytes(16).toString("hex"); + const hash = scryptSync(code, salz, 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, salz, 32768, jetzt); + return d.prepare("SELECT last_insert_rowid() AS id").get().id; +} +{ + const d = new DatabaseSync(DB); + anlegen(d, "Filipe", "admin", "CODE-DOGI-0001"); + anlegen(d, "Sam", "scout", "CODE-SAM-00001"); + d.close(); +} +await starten(); + +const anmelden = async (rolle, code) => { + const a = await fetch(BASIS + "/workspace/api/anmelden", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ rolle, code }) }); + const keks = (a.headers.getSetCookie?.() || []).map((k) => k.split(";")[0]).join("; "); + if (!keks) throw new Error(`Anmeldung ${rolle} fehlgeschlagen (${a.status})`); + return keks; +}; +const keksScout = await anmelden("scout", "CODE-SAM-00001"); + +const ruf = async (art, weg, keks, koerper) => { + const a = await fetch(BASIS + weg, { method: art, + headers: { Cookie: keks, "Content-Type": "application/json", Origin: BASIS }, + body: koerper ? JSON.stringify(koerper) : undefined }); + let daten = null; try { daten = await a.json(); } catch { /* leer ist erlaubt */ } + return { status: a.status, daten }; +}; + +/* ======================================================================= + 1. Die sieben Felder kommen an -- und wieder heraus + ======================================================================= */ +console.log("=== Was ein Scout jetzt eintragen kann ==="); +let leadId = null; +{ + const { status, daten } = await ruf("POST", "/workspace/api/leads", keksScout, { + name: "Tamy", plattform: "TikTok", handle: "@coolemama1991", + land: "CH", netzwerk: "nein", follower: "12,4k", + woher: "LIVE entdeckt", kontaktweg: "TikTok-DM", + live_zeiten: "Mo–Fr ab 21 Uhr", + }); + ok(status === 201, `ein Kontakt mit allen Feldern entsteht (HTTP ${status})`); + leadId = daten?.id; +} +{ + const { daten } = await ruf("GET", "/workspace/api/leads", keksScout); + const l = (daten?.leads || []).find((x) => x.id === leadId); + ok(!!l, "und steht in der Liste"); + ok(l?.land === "CH", `Land kommt zurueck (${l?.land})`); + ok(l?.netzwerk === "nein", `Netzwerk kommt zurueck (${l?.netzwerk})`); + /* DIE UMRECHNUNG IST DER PUNKT: Wer "12,4k" aus TikTok kopiert, soll + 12400 gespeichert bekommen und nicht 12. */ + ok(l?.follower === 12400, `"12,4k" wurde zu 12400 (${l?.follower})`); + ok(l?.woher === "LIVE entdeckt", `Wie gefunden (${l?.woher})`); + ok(l?.kontaktweg === "TikTok-DM", `Angeschrieben ueber (${l?.kontaktweg})`); + ok(l?.live_zeiten === "Mo–Fr ab 21 Uhr", `LIVE-Zeiten (${l?.live_zeiten})`); + /* Und die Auswahllisten kommen mit, damit die Oberflaeche keine + eigenen fuehrt. */ + ok(Array.isArray(daten?.laender) && daten.laender.includes("CH") && daten.laender.includes("LU"), + `die Laenderliste kommt vom Server (${(daten?.laender || []).join(", ")})`); + ok(Array.isArray(daten?.netzwerk) && daten.netzwerk.length === 3, + `und die Netzwerk-Liste (${(daten?.netzwerk || []).join(", ")})`); +} + +/* ======================================================================= + 2. Aendern geht auch -- das war der eigentliche Mangel + ======================================================================= */ +console.log("\n=== Nachtraeglich aendern ==="); +{ + const { status } = await ruf("PATCH", `/workspace/api/leads/${leadId}`, keksScout, { + aktivitaet: "Vier Abende die Woche, Just Chatting", + potenzial: "Sehr ruhige Moderation, treue Zuschauer", + netzwerk: "ja", + }); + ok(status === 200, `Aktivitaet und Potenzial lassen sich aendern (HTTP ${status})`); + const { daten } = await ruf("GET", "/workspace/api/leads", keksScout); + const l = (daten?.leads || []).find((x) => x.id === leadId); + ok(l?.aktivitaet?.startsWith("Vier Abende"), `und stehen danach da (${l?.aktivitaet?.slice(0, 20)}…)`); + ok(l?.netzwerk === "ja", "die Netzwerk-Angabe ebenfalls"); +} + +/* ======================================================================= + 3. Der Server nimmt keinen Unsinn an + ======================================================================= */ +console.log("\n=== Gegenproben: was NICHT angenommen wird ==="); +for (const [feld, wert, was] of [ + ["land", "Schweiz", "ein Land ausserhalb der Liste"], + ["netzwerk", "vielleicht", "eine erfundene Netzwerk-Angabe"], + ["follower", "ganz viele", "eine Follower-Zahl, die keine ist"], + ["follower", "-5", "eine negative Follower-Zahl"], +]) { + const { status } = await ruf("PATCH", `/workspace/api/leads/${leadId}`, keksScout, { [feld]: wert }); + ok(status === 400, `${was} wird abgelehnt (HTTP ${status})`); +} +{ + /* Und die Gegenprobe zur Gegenprobe: Das Richtige geht durch. Ohne + sie hiesse "alles wird abgelehnt" auch "alles wird abgelehnt". */ + const { status } = await ruf("PATCH", `/workspace/api/leads/${leadId}`, keksScout, { land: "LU" }); + ok(status === 200, `Luxemburg dagegen geht durch (HTTP ${status})`); +} + +/* ======================================================================= + 4. Getrennt von Team Dogi + ======================================================================= */ +console.log("\n=== Die Pipeline gibt es auf der crew-Adresse nicht ==="); +{ + /* DogFather sieht im Workspace alles -- das ist der Vergleichswert. */ + const keksDogi = await anmelden("admin", "CODE-DOGI-0001"); + const drin = await ruf("GET", "/workspace/api/leads", keksDogi); + ok(drin.status === 200 && (drin.daten?.leads || []).length >= 1, + `DogFather sieht die Pipeline im Workspace (${(drin.daten?.leads || []).length} Kontakte)`); + + /* Dieselbe Sitzung, dieselbe Adresse -- nur der Host-Kopf sagt crew. + Genau so kommt die Anfrage auf der echten Adresse an. + + MIT node:http UND NICHT MIT fetch: `Host` ist ein verbotener + Kopf, den fetch stillschweigend wegwirft. Der erste Anlauf dieser + Pruefung hat deshalb 200 gemeldet und sah wie ein Befund aus -- + tatsaechlich war die Anfrage nie auf der crew-Adresse. Ein + Messgeraet, das die Bedingung gar nicht herstellen kann, misst + nichts. */ + const { request } = await import("node:http"); + const status = await new Promise((fertig, schief) => { + const r = request({ host: "127.0.0.1", port: PORT, path: "/workspace/api/leads", + headers: { Cookie: keksDogi, Host: "crew.dogfather-universe.com" } }, + (antwort) => { antwort.resume(); fertig(antwort.statusCode); }); + r.on("error", schief); + r.end(); + }); + ok(status === 404, + `auf crew. gibt es sie nicht -- auch fuer DogFather (HTTP ${status})`); + + /* GEGENPROBE ZUM MESSGERAET: Derselbe Weg mit dem normalen Host muss + 200 liefern. Sonst hiesse "404 auf crew" nur, dass node:http hier + grundsaetzlich scheitert. */ + const statusNormal = await new Promise((fertig, schief) => { + const r = request({ host: "127.0.0.1", port: PORT, path: "/workspace/api/leads", + headers: { Cookie: keksDogi } }, + (antwort) => { antwort.resume(); fertig(antwort.statusCode); }); + r.on("error", schief); + r.end(); + }); + ok(statusNormal === 200, + `derselbe Weg ohne crew-Kopf liefert weiterhin die Liste (HTTP ${statusNormal})`); +} + +/* ======================================================================= + 5. Und die Oberflaeche zeigt es auch + ======================================================================= */ +console.log("\n=== Die Seite selbst ==="); +{ + const pw = await import("file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"); + const browser = await pw.chromium.launch(); + const ctx = await browser.newContext({ viewport: { width: 1400, height: 1000 } }); + const seite = await ctx.newPage(); + const skriptfehler = []; + seite.on("pageerror", (e) => skriptfehler.push(String(e).slice(0, 140))); + await seite.goto(BASIS + "/workspace/", { waitUntil: "networkidle" }); + await seite.click('.rolle[data-rolle="scout"]'); + await seite.fill("#code", "CODE-SAM-00001"); + await seite.click("button[type=submit]"); + await seite.waitForURL("**/start.html", { timeout: 25000 }); + await seite.goto(BASIS + "/workspace/scouting.html", { waitUntil: "networkidle" }); + await seite.waitForTimeout(1600); + + ok(skriptfehler.length === 0, `kein Skriptfehler (${skriptfehler.join(" | ") || "keiner"})`); + + /* Der Knopf heisst, was er tut. */ + const knopfText = await seite.$eval(".knopf-still--bearbeiten", (e) => e.textContent.trim()) + .catch(() => null); + ok(knopfText === "Bearbeiten", `der Knopf heisst "Bearbeiten" (${knopfText})`); + + /* Die Stufe erklaert sich. */ + const was = await seite.$eval(".stufe__was", (e) => e.textContent.trim()).catch(() => null); + ok(!!was && was.length > 10, `die Stufe sagt, was sie bedeutet ("${was}")`); + + /* Die harten Zahlen stehen auf der Karte, ohne Klick. */ + const fakten = await seite.$$eval(".kk__fakt", (ks) => ks.map((k) => k.textContent.trim())); + ok(fakten.some((f) => f.includes("Luxemburg")), `das Land steht auf der Karte (${fakten.join(", ")})`); + ok(fakten.some((f) => f.includes("Follower")), "die Reichweite ebenfalls"); + + /* Und die Warnung, wenn jemand schon vergeben ist. */ + const warn = await seite.$eval('.marke-punkt[data-warn="ja"]', (e) => e.textContent.trim()) + .catch(() => null); + ok(warn === "Schon im Netzwerk", `die Netzwerk-Warnung ist zu sehen (${warn})`); + + /* 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. */ + await seite.click(".knopf-still--bearbeiten"); + 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)`); + await browser.close(); +} + +kind.kill(); +await new Promise((r) => setTimeout(r, 400)); +try { rmSync(ordner, { recursive: true, force: true }); } catch { /* Zugabe */ } + +console.log(`\n${gemacht} Pruefungen`); +console.log(fehler === 0 ? "ALLES IN ORDNUNG" : `${fehler} FEHLER`); +process.exit(fehler ? 1 : 0); diff --git a/server/workspace-scouts.js b/server/workspace-scouts.js index 47cad658..02889ead 100644 --- a/server/workspace-scouts.js +++ b/server/workspace-scouts.js @@ -33,6 +33,27 @@ export const STUFEN = { }; const WEITER = { neu: "angesprochen", angesprochen: "gespraech", gespraech: "interessiert", interessiert: "uebergeben" }; const PRIORITAETEN = ["hoch", "mittel", "niedrig"]; + +/* DIE VIER LAENDER, IN DENEN BETREUT WIRD -- plus "andere". + + Filipe am 10.09.2026: "wir sind in deutschland, betreuen creator aus + deutschland, luxemburg, schweiz und österreich." Die Liste steht + hier und wird der Oberflaeche mitgeschickt (siehe /api/leads), damit + es nicht zwei davon gibt. */ +export const LAENDER = ["DE", "AT", "CH", "LU", "andere"]; +export const LAND_NAME = { + DE: "Deutschland", AT: "Österreich", CH: "Schweiz", LU: "Luxemburg", andere: "anderes Land", +}; + +/* SCHON BEI EINEM NETZWERK? Die teuerste Frage der ganzen Pipeline -- + wer unter Vertrag steht, kann nicht uebernommen werden. "unbekannt" + ist ausdruecklich eine gueltige Antwort und nicht dasselbe wie + "nein": Nicht gefragt zu haben ist etwas anderes, als gefragt und + ein Nein bekommen zu haben. */ +export const NETZWERK = ["nein", "ja", "unbekannt"]; +export const NETZWERK_NAME = { + nein: "frei", ja: "schon im Netzwerk", unbekannt: "noch nicht gefragt", +}; const NAME_MAX = 120; const TEXT_MAX = 3000; @@ -42,6 +63,23 @@ 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" }); + /* DIE PIPELINE GEHOERT DEM WORKSPACE, NICHT TEAM DOGI (11.09.2026). + + Filipe: "das soll getrennt von der team dogi seite sein. die laufen + was daten angeht getrennt." + + Ohne diese Zeile war es genau umgekehrt: `sichtbar()` gibt fuer + jeden mit `siehtAlles` schlicht `1=1` zurueck -- und DogFather hat + `siehtAlles` auch dann, wenn er auf der crew-Adresse arbeitet. Die + komplette Scout-Pipeline des Workspace waere dort mitgekommen. + + Die Absperrung steht HIER und nicht in `sichtbar()`: Es geht nicht + darum, WELCHE Leads man sieht, sondern darum, dass es auf dieser + Adresse ueberhaupt keine gibt. Eine gefilterte Liste waere eine + Liste; 404 ist die Aussage, dass es den Bereich nicht gibt -- und + sie gilt fuer alle acht Wege dieses Routers auf einmal, auch fuer + die, die erst noch dazukommen. */ + if (person.haus === "crew") return res.status(404).json({ fehler: "nicht_gefunden" }); req.person = person; next(); } @@ -80,6 +118,7 @@ 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, + l.follower, l.land, l.woher, l.kontaktweg, l.live_zeiten, l.netzwerk, l.absage_grund, ps.name AS scout_name, pc.name AS creator_name`; const VERBUND = ` @@ -95,6 +134,12 @@ scoutRouter.get("/workspace/api/leads", (req, res) => { const heute = jetzt().slice(0, 10); res.json({ stufen: STUFEN, + /* Die Auswahllisten kommen MIT, damit die Oberflaeche keine + eigenen fuehrt. Zwei Listen fuer dieselbe Auswahl waren im Haus + schon mehrfach die Stelle, an der eine gepflegt wurde und die + andere nicht -- zuletzt bei der Rollenwahl am 10.09. */ + laender: LAENDER, land_name: LAND_NAME, + netzwerk: NETZWERK, netzwerk_name: NETZWERK_NAME, heute, leads: db().prepare(` SELECT ${SPALTEN} ${VERBUND} @@ -140,6 +185,52 @@ function pruefe(körper, { neu }) { if (!PRIORITAETEN.includes(körper.prioritaet)) fehler.push("Unbekannte Priorität."); else aus.prioritaet = körper.prioritaet; } + /* ---- DIE SIEBEN NEUEN FELDER (11.09.2026) ---------------------------- + + Jedes wird geprueft, keines wird geglaubt. Die drei mit fester + Auswahl (Land, Netzwerk) gegen ihre Liste, die Zahl gegen ihre + Grenzen, die Texte gegen ihre Laenge. + + WARUM `land` UND `netzwerk` EIGENE LISTEN HABEN UND KEINE FREITEXTE: + Danach wird spaeter gefiltert und gezaehlt ("wie viele in der + Schweiz", "wie viele sind noch frei"). Ein Freitext ergibt + "Schweiz", "CH", "ch" und "Suisse" -- und damit vier Gruppen fuer + ein Land. */ + text("woher", 80); + text("kontaktweg", 80); + text("live_zeiten", 200); + text("absage_grund", TEXT_MAX); + + if (körper.land !== undefined) { + const w = String(körper.land ?? "").trim(); + if (!w) aus.land = null; + else if (!LAENDER.includes(w)) fehler.push("Unbekanntes Land."); + else aus.land = w; + } + if (körper.netzwerk !== undefined) { + const w = String(körper.netzwerk ?? "").trim(); + if (!w) aus.netzwerk = null; + else if (!NETZWERK.includes(w)) fehler.push("Unbekannte Angabe zum Netzwerk."); + else aus.netzwerk = w; + } + if (körper.follower !== undefined) { + const roh = String(körper.follower ?? "").trim(); + if (!roh) aus.follower = null; + else { + /* Punkte und Leerzeichen fallen weg: Wer aus TikTok kopiert, hat + "12.400" oder "12 400" in der Zwischenablage. Ein "k" oder "M" + wird ausgerechnet -- sonst steht am Ende eine 12 statt 12000, + und das faellt niemandem auf. */ + const m = /^([\d.,\s]+)\s*([kKmM]?)$/.exec(roh); + const zahl = m ? Number(m[1].replace(/[.\s]/g, "").replace(",", ".")) : NaN; + const faktor = m && /[kK]/.test(m[2]) ? 1000 : m && /[mM]/.test(m[2]) ? 1000000 : 1; + const wert = Math.round(zahl * faktor); + if (!Number.isFinite(wert) || wert < 0 || wert > 1000000000) { + fehler.push("Follower-Zahl ist ungültig."); + } else aus.follower = wert; + } + } + for (const feld of ["letzte_nachricht", "naechster_followup"]) { if (körper[feld] === undefined) continue; const d = String(körper[feld] ?? "").trim(); @@ -175,13 +266,17 @@ scoutRouter.post("/workspace/api/leads", gleicheHerkunft, (req, res) => { 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( + notizen, letzte_nachricht, naechster_followup, scout_id, erstellt, erstellt_von, + follower, land, woher, kontaktweg, live_zeiten, netzwerk, absage_grund) + 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); + 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); protokolliere("lead_angelegt", { personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req), diff --git a/server/workspace.js b/server/workspace.js index 16509a03..07d4a75d 100644 --- a/server/workspace.js +++ b/server/workspace.js @@ -1226,6 +1226,37 @@ function umstellungen(d) { ["eintraege", "saeule_id", "INTEGER"],// zu welcher Content-Saeule ["eintraege", "geplant", "TEXT"], // wann es raus soll + /* ---- WAS EIN SCOUT WIRKLICH AUFSCHREIBEN MUSS (11.09.2026) ---- + + Filipe, zur Pipeline: "perfektionnier diese kategorien ... und was + man da noch alles immer in jeder kategorie eintragen kann. weil + man kan da nichts machen." + + Ein Lead hatte bis heute Name, Plattform, Handle, Priorität, + Aktivität, Potenzial, Notizen und zwei Daten. Das genügt, um + jemanden zu MERKEN, aber nicht, um ihn zu BEURTEILEN. Die sieben + Felder hier sind genau die Fragen, die ein Scout sonst im Kopf + behält -- und die der Nächste nicht mehr findet, wenn der Scout + im Urlaub ist. + + `netzwerk` ist das wichtigste davon und kein Schmuck: Wer schon + bei einem anderen Creator-Netzwerk unter Vertrag steht, kann + nicht übernommen werden. Diese Frage ganz am Anfang zu stellen + spart die ganze übrige Arbeit -- und sie hier zu haben heisst, + dass niemand zweimal fragt. + + `land` steht da, weil die Betreuung in DE, LU, AT und CH läuft + und die Schweiz rechtlich anders liegt als die drei EU-Länder + (siehe die TikTok-Recherche vom 10.09.). Auch dafür ist es + besser, es beim ersten Kontakt zu wissen als beim Vertrag. */ + ["leads", "follower", "INTEGER"], // Reichweite beim Fund + ["leads", "land", "TEXT"], // DE / AT / CH / LU / andere + ["leads", "woher", "TEXT"], // wie gefunden: LIVE, Empfehlung, Kommentar … + ["leads", "kontaktweg", "TEXT"], // wo angeschrieben: TikTok-DM, Instagram … + ["leads", "live_zeiten", "TEXT"], // wann die Person üblicherweise live ist + ["leads", "netzwerk", "TEXT"], // schon bei einer Agentur? nein/ja/unbekannt + ["leads", "absage_grund", "TEXT"], // warum es nicht gepasst hat + ["personen", "bild", "TEXT"], // Dateiname des Profilbildes ["personen", "ueber_mich", "TEXT"], // ein Satz zur Person ["personen", "tiktok", "TEXT"], // öffentlicher Name, ohne @ diff --git a/workspace/app.webmanifest b/workspace/app.webmanifest index ceb3e987..87b8eb15 100644 --- a/workspace/app.webmanifest +++ b/workspace/app.webmanifest @@ -13,25 +13,25 @@ "theme_color": "#06090f", "icons": [ { - "src": "/assets/img/app-symbole/workspace-192.png?v=202609111104", + "src": "/assets/img/app-symbole/workspace-192.png?v=202609111117", "sizes": "192x192", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/workspace-512.png?v=202609111104", + "src": "/assets/img/app-symbole/workspace-512.png?v=202609111117", "sizes": "512x512", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/workspace-192-maskable.png?v=202609111104", + "src": "/assets/img/app-symbole/workspace-192-maskable.png?v=202609111117", "sizes": "192x192", "type": "image/png", "purpose": "maskable" }, { - "src": "/assets/img/app-symbole/workspace-512-maskable.png?v=202609111104", + "src": "/assets/img/app-symbole/workspace-512-maskable.png?v=202609111117", "sizes": "512x512", "type": "image/png", "purpose": "maskable" diff --git a/workspace/assets/css/scouting.css b/workspace/assets/css/scouting.css index 3a12a0b4..6b4d432c 100644 --- a/workspace/assets/css/scouting.css +++ b/workspace/assets/css/scouting.css @@ -237,3 +237,88 @@ @media (max-width: 560px) { .leer-hinweis--erklaerung { padding: 22px 16px 20px; } } + +/* ===================================================================== + DIE STUFEN ERKLÄREN SICH SELBST (11.09.2026) + + Filipe: "perfektionnier diese kategorien wie die aussehen und was man + da noch alles immer in jeder kategorie eintragen kann. weil man kan + da nichts machen." + + Zwei Dinge steckten dahinter, und nur eins davon war Optik: + + 1. Man KONNTE etwas machen -- der ganze Editor lag hinter einem + stillen Textknopf namens "Details". Ein Wort, das Lesen + verspricht, an einer Stelle, an der man ändert. Er heißt jetzt + "Bearbeiten" und sieht auch danach aus. + 2. Die Stufen hatten nur einen Namen. Was "Interessiert" von + "Gespräch" unterscheidet, stand einmal im leeren Zustand der + Seite -- also genau so lange, bis der erste Kontakt da war. + + Der Ton der Stufe (--stufe-farbe) steht schon oben in dieser Datei; + er wird hier nur konsequent benutzt, statt nur die Zahl zu färben. + ===================================================================== */ +.stufe__text { min-width: 0; } +.stufe__was { + margin: 2px 0 0; + font-size: .8rem; line-height: 1.45; + color: var(--text-still); + /* Der Satz darf umbrechen, aber nicht die Überschrift überragen. */ + max-width: 62ch; +} +/* Eine feine Kante in der Farbe der Stufe -- sie trennt die Abschnitte, + ohne dass eine durchgehende Linie die Seite zerschneidet. */ +.stufe__kopf { + align-items: flex-start; + padding-bottom: 9px; + border-bottom: 1px solid color-mix(in srgb, var(--stufe-farbe, var(--rand)) 34%, transparent); +} +.stufe__zahl { margin-left: auto; flex: none; } + +/* ---- Die harten Zahlen auf der Karte ------------------------------- + Land und Reichweite beantworten "lohnt sich das überhaupt". Sie + stehen deshalb in der Kurzinfo und nicht im aufgeklappten Teil -- + wer sie erst nach einem Klick sieht, sortiert nicht danach. */ +.kk__fakt { + display: inline-flex; align-items: center; + padding: 2px 9px; + border: 1px solid var(--rand-hell); border-radius: 999px; + font-size: .76rem; font-weight: 600; + color: var(--text-leise); + white-space: nowrap; +} +.kk__fakt[data-art="follower"] { + font-variant-numeric: tabular-nums; + border-color: color-mix(in srgb, var(--akzent) 34%, transparent); + color: color-mix(in srgb, var(--akzent) 62%, #ffffff); +} + +/* ---- „Schon im Netzwerk" ------------------------------------------ + Die teuerste Angabe der ganzen Pipeline: Wer unter Vertrag steht, + kann nicht übernommen werden. Sie trägt deshalb die Warnfarbe des + Hauses -- nicht als Alarm, sondern als Stoppschild. */ +.marke-punkt[data-warn="ja"] { + border-color: color-mix(in srgb, var(--k-warn, #e0736b) 55%, transparent); + background: color-mix(in srgb, var(--k-warn, #e0736b) 16%, transparent); + color: color-mix(in srgb, var(--k-warn, #e0736b) 42%, #ffffff); +} + +/* ---- Der Knopf, der nicht gefunden wurde -------------------------- + Er sah aus wie ein Nebensatz und war die einzige Tür zum Editor. + Jetzt hat er eine Kante -- er bleibt zurückhaltend gegenüber + „Weiter zu …", ist aber als Knopf zu erkennen. */ +.knopf-still--bearbeiten { + border: 1px solid var(--rand-hell); + border-radius: 10px; + padding: 5px 12px; +} +.knopf-still--bearbeiten:hover { border-color: var(--akzent); color: var(--text); } +.knopf-still--bearbeiten[aria-expanded="true"] { + border-color: color-mix(in srgb, var(--akzent) 55%, transparent); + background: color-mix(in srgb, var(--akzent) 12%, transparent); +} + +@media (forced-colors: active) { + .kk__fakt, .knopf-still--bearbeiten { border: 1px solid CanvasText; } + .marke-punkt[data-warn="ja"] { border: 2px solid CanvasText; } +} diff --git a/workspace/assets/js/scouting.js b/workspace/assets/js/scouting.js index 475d082e..2f52b186 100644 --- a/workspace/assets/js/scouting.js +++ b/workspace/assets/js/scouting.js @@ -25,8 +25,29 @@ gespraech: 'interessiert', interessiert: 'uebergeben', }; + /* WAS JEDE STUFE BEDEUTET -- an EINER Stelle. + + Diese Saetze standen bis zum 11.09.2026 nur im leeren Zustand der + Seite, ausgeschrieben in einer eigenen Liste. Jetzt lesen beide + daraus: die Erklaerung fuer den leeren Zustand und der Kopf jeder + Stufe. Zwei Fassungen derselben Erklaerung waeren zwei + Gelegenheiten, dass eine von beiden veraltet -- und eine veraltete + Erklaerung ist schlimmer als keine. */ + const STUFE_WAS = { + neu: 'Entdeckt. Noch nicht angeschrieben.', + angesprochen: 'Erste Nachricht ist raus. Jetzt läuft die Uhr.', + gespraech: 'Ihr seid im Austausch – Fragen, Bedingungen, Termine.', + interessiert: 'Grundsätzlich dabei. Jetzt zählt, dass es nicht liegen bleibt.', + uebergeben: 'Ein Manager übernimmt. Ab hier ist es Betreuung, kein Scouting mehr.', + abgelehnt: 'Passt nicht – festgehalten, damit niemand zweimal fragt.', + }; + let ich = null; let stufen = {}; + /* Die Auswahllisten kommen vom Server (siehe /api/leads) -- die + Oberflaeche fuehrt keine eigenen. Leere Vorgabe, damit ein + Ausfall der Abfrage kein kaputtes Formular ergibt. */ + let laenderListe = [], landName = {}, netzwerkListe = [], netzwerkName = {}; let heute = ''; let daten = []; let offen = new Set(); @@ -71,6 +92,15 @@ return p; } + /* 12.400 -> "12,4k". Auf einer Karte zaehlt die Groessenordnung, nicht + die letzte Stelle -- und die genaue Zahl steht im Titel. */ + function zahlKurz(n) { + if (!Number.isFinite(n)) return ''; + if (n >= 1000000) return (n / 1000000).toFixed(n >= 10000000 ? 0 : 1).replace('.', ',') + 'M'; + if (n >= 1000) return (n / 1000).toFixed(n >= 10000 ? 0 : 1).replace('.', ',') + 'k'; + return String(n); + } + function karte(l) { const k = el('article', 'kk'); k.dataset.prio = l.prioritaet; @@ -87,6 +117,23 @@ kopf.append(links); const marken = el('div', 'kk__marken'); + /* SCHON IM NETZWERK? ZUERST UND IN ROT (11.09.2026). + + Das ist die einzige Angabe, die die ganze weitere Arbeit + erledigt: Wer unter Vertrag steht, kann nicht uebernommen + werden. Sie steht deshalb VOR der Prioritaet -- eine hohe + Prioritaet bei jemandem, der gar nicht kann, ist eine + Verabredung mit sich selbst. */ + if (l.netzwerk === 'ja') { + const n = el('span', 'marke-punkt', 'Schon im Netzwerk'); + n.dataset.warn = 'ja'; + n.title = 'Steht bei einer anderen Agentur unter Vertrag – eine Übernahme geht nicht.'; + marken.append(n); + } else if (l.netzwerk === 'unbekannt') { + const n = el('span', 'marke-still', 'Netzwerk ungefragt'); + n.title = 'Noch nicht geklärt, ob die Person schon bei einer Agentur ist.'; + marken.append(n); + } const prio = el('span', 'marke-punkt', l.prioritaet === 'hoch' ? 'Hoch' : l.prioritaet === 'niedrig' ? 'Niedrig' : 'Mittel'); prio.dataset.prio = l.prioritaet; @@ -98,6 +145,22 @@ /* Kurzinfo */ const info = el('div', 'kk__info'); + /* DIE HARTEN ZAHLEN ZUERST (11.09.2026). Land, Reichweite und + LIVE-Zeiten beantworten "lohnt sich das ueberhaupt" -- und zwar + ohne dass man die Karte aufklappen muss. Fehlt eine Angabe, + steht sie nicht da; ein leeres Schild ist schlechter als keins. */ + if (l.land) { + const s = el('span', 'kk__fakt', landName[l.land] || l.land); + s.dataset.art = 'land'; + info.append(s); + } + if (l.follower) { + const f = el('span', 'kk__fakt', zahlKurz(l.follower) + ' Follower'); + f.dataset.art = 'follower'; + f.title = l.follower.toLocaleString('de-DE') + ' Follower beim Fund'; + info.append(f); + } + if (l.live_zeiten) info.append(el('span', 'kk__still', 'LIVE: ' + l.live_zeiten)); if (l.naechster_followup) { const f = el('span', 'kk__followup', 'Follow-up ' + datum(l.naechster_followup)); if (fällig) f.dataset.faellig = 'ja'; @@ -122,8 +185,17 @@ o.addEventListener('click', () => onboarding(l, o)); knöpfe.append(o); } - const mehr = el('button', 'knopf-still', offen.has(l.id) ? 'Weniger' : 'Details'); + /* „BEARBEITEN" STATT „DETAILS" (11.09.2026). + + Filipe: "weil man kan da nichts machen." Man konnte -- der ganze + Editor lag hinter diesem Knopf. Nur verspricht "Details" das + Lesen und nicht das Aendern, und er stand als stiller Textknopf + neben einem auffaelligen. Wer ihn nicht drueckt, haelt die Karte + fuer eine Anzeige. Der Name sagt jetzt, was passiert. */ + const mehr = el('button', 'knopf-still knopf-still--bearbeiten', + offen.has(l.id) ? 'Fertig' : 'Bearbeiten'); mehr.type = 'button'; + mehr.setAttribute('aria-expanded', offen.has(l.id) ? 'true' : 'false'); mehr.addEventListener('click', () => { if (offen.has(l.id)) offen.delete(l.id); else offen.add(l.id); zeichne(); @@ -136,9 +208,12 @@ /* ---------- 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)); + /* AKTIVITAET UND POTENZIAL WAREN NUR ZU LESEN (11.09.2026). + Sie liessen sich ausschliesslich beim ANLEGEN eintragen -- danach + standen sie da und waren nicht mehr zu aendern. Genau das meinte + Filipes "man kan da nichts machen": Es sah nach Anzeige aus, weil + es Anzeige war. Jetzt sind es Felder wie alle anderen. */ const notiz = el('textarea', 'kk__notiz'); notiz.rows = 3; notiz.maxLength = 3000; @@ -170,8 +245,63 @@ const o = document.createElement('option'); o.value = w; o.textContent = t; f.append(o); } }); + + /* ---- DIE SIEBEN NEUEN FELDER ------------------------------------ + + Die Auswahllisten kommen vom Server (laender, netzwerk) -- hier + steht keine eigene. */ + const leer = (f, text) => { + const o = document.createElement('option'); o.value = ''; o.textContent = text; f.append(o); + }; + const fNetz = mach('Schon im Netzwerk?', 'select', l.netzwerk, (f) => { + leer(f, '– nicht angegeben –'); + for (const w of netzwerkListe) { + const o = document.createElement('option'); + o.value = w; o.textContent = netzwerkName[w] || w; f.append(o); + } + }); + const fLand = mach('Land', 'select', l.land, (f) => { + leer(f, '– nicht angegeben –'); + for (const w of laenderListe) { + const o = document.createElement('option'); + o.value = w; o.textContent = landName[w] || w; f.append(o); + } + }); + const fFollower = mach('Follower', 'text', l.follower ? String(l.follower) : '', (f) => { + f.placeholder = 'z. B. 12400 oder 12,4k'; + f.inputMode = 'numeric'; + }); + const fWoher = mach('Wie gefunden', 'text', l.woher, (f) => { + f.maxLength = 80; f.placeholder = 'LIVE entdeckt, Empfehlung, Kommentar …'; + }); + const fWeg = mach('Angeschrieben über', 'text', l.kontaktweg, (f) => { + f.maxLength = 80; f.placeholder = 'TikTok-DM, Instagram, WhatsApp …'; + }); + const fZeiten = mach('LIVE-Zeiten', 'text', l.live_zeiten, (f) => { + f.maxLength = 200; f.placeholder = 'z. B. Mo–Fr ab 21 Uhr'; + }); d.append(raster); + /* Die beiden langen Texte stehen UNTER dem Raster, nicht darin: + Sie brauchen Zeilen, keine Spalte. */ + const lang = (schild, wert, platzhalter) => { + const t = el('textarea', 'kk__notiz'); + t.rows = 2; t.maxLength = 3000; t.placeholder = platzhalter; t.value = wert || ''; + d.append(el('label', 'feld-schild', schild), t); + return t; + }; + const fAktiv = lang('Aktivität & LIVE-Fokus', l.aktivitaet, + 'Wie oft LIVE? Welche Zeiten? Welches Format?'); + const fPot = lang('Potenzial & Stil', l.potenzial, + 'Was macht die Person besonders?'); + /* Der Absagegrund erscheint nur, wo er hingehoert. Ein Feld + "warum nicht" an einem Kontakt, der gerade gut laeuft, ist eine + Frage, die niemand gestellt hat. */ + const fAbsage = l.status === 'abgelehnt' + ? lang('Warum es nicht gepasst hat', l.absage_grund, + 'Kein Interesse, schon vergeben, passt nicht zur Agentur …') + : null; + const unten = el('div', 'kk__knoepfe'); const sp = el('button', 'knopf knopf--klein', 'Speichern'); sp.type = 'button'; @@ -180,6 +310,10 @@ 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, + netzwerk: fNetz.value, land: fLand.value, follower: fFollower.value, + woher: fWoher.value, kontaktweg: fWeg.value, live_zeiten: fZeiten.value, + aktivitaet: fAktiv.value, potenzial: fPot.value, + ...(fAbsage ? { absage_grund: fAbsage.value } : {}), }); sp.disabled = false; if (r) await laden(); @@ -276,13 +410,16 @@ + 'wem seit drei Wochen nichts passiert ist. Genau die gehen ' + 'verloren – nicht die, die Nein sagen.')); + /* Aus derselben Liste wie die Stufenkoepfe -- siehe STUFE_WAS. + "Abgelehnt" bleibt hier weg: Die Einfuehrung beschreibt den + Weg nach vorn, und eine Absage ist keine Stufe darauf. */ const stufenListe = el('ol', 'erklaerung__stufen'); for (const [nr, name, was] of [ - ['1', 'Neu', 'Entdeckt. Noch nicht angeschrieben.'], - ['2', 'Angesprochen', 'Erste Nachricht ist raus. Jetzt läuft die Uhr.'], - ['3', 'Gespräch', 'Ihr seid im Austausch – Fragen, Bedingungen, Termine.'], - ['4', 'Interessiert', 'Grundsätzlich dabei. Jetzt zählt, dass es nicht liegen bleibt.'], - ['5', 'Übergeben', 'Ein Manager übernimmt. Ab hier ist es Betreuung, kein Scouting mehr.'], + ['1', 'Neu', STUFE_WAS.neu], + ['2', 'Angesprochen', STUFE_WAS.angesprochen], + ['3', 'Gespräch', STUFE_WAS.gespraech], + ['4', 'Interessiert', STUFE_WAS.interessiert], + ['5', 'Übergeben', STUFE_WAS.uebergeben], ]) { const z = el('li', 'erklaerung__stufe'); z.append(el('span', 'erklaerung__nr', nr)); @@ -311,8 +448,21 @@ if (!drin.length) continue; const gruppe = el('section', 'stufe'); gruppe.dataset.stufe = wert; + /* JEDE STUFE SAGT, WAS SIE BEDEUTET (11.09.2026). + + Die Erklaerung stand bisher nur im leeren Zustand -- also + genau einmal, bevor der erste Kontakt da war, und danach nie + wieder. Wer im Januar dazukommt, sieht sechs Ueberschriften und + muss raten, was "Interessiert" von "Gespraech" unterscheidet. + + Der Satz steht deshalb an der Stufe selbst und wird aus + derselben Liste gelesen wie der leere Zustand -- nicht aus + einer zweiten. */ const kopf = el('div', 'stufe__kopf'); - kopf.append(el('h2', 'stufe__titel', name), el('span', 'stufe__zahl', String(drin.length))); + const kt = el('div', 'stufe__text'); + kt.append(el('h2', 'stufe__titel', name)); + if (STUFE_WAS[wert]) kt.append(el('p', 'stufe__was', STUFE_WAS[wert])); + kopf.append(kt, el('span', 'stufe__zahl', String(drin.length))); gruppe.append(kopf); const box = el('div', 'stufe__karten'); for (const l of drin) { @@ -331,6 +481,8 @@ if (!a.ok) { melde('Pipeline konnte nicht geladen werden.'); return; } const d = await a.json(); stufen = d.stufen; heute = d.heute; daten = d.leads; + laenderListe = d.laender || []; landName = d.land_name || {}; + netzwerkListe = d.netzwerk || []; netzwerkName = d.netzwerk_name || {}; zeichne(); } catch { /* umgeleitet */ } } @@ -363,6 +515,8 @@ aktivitaet: $('n-aktivitaet').value, potenzial: $('n-potenzial').value, naechster_followup: $('n-followup').value, scout_id: $('n-scout').value || null, + netzwerk: $('n-netzwerk').value, land: $('n-land').value, + follower: $('n-follower').value, }), }).catch(() => null); knopf.disabled = false; diff --git a/workspace/aufgaben.html b/workspace/aufgaben.html index f7db673b..af70c295 100644 --- a/workspace/aufgaben.html +++ b/workspace/aufgaben.html @@ -12,19 +12,19 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - + + + + + + - + @@ -252,10 +252,10 @@ - - - - - + + + + + diff --git a/workspace/automation.html b/workspace/automation.html index e4f32e6d..e99a29aa 100644 --- a/workspace/automation.html +++ b/workspace/automation.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -147,10 +147,10 @@ - - - - - + + + + + diff --git a/workspace/bereich.html b/workspace/bereich.html index 4e034ab6..a6af020f 100644 --- a/workspace/bereich.html +++ b/workspace/bereich.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -181,14 +181,14 @@ - - - - - - - - - + + + + + + + + + diff --git a/workspace/calls.html b/workspace/calls.html index f7eb09ae..e1830484 100644 --- a/workspace/calls.html +++ b/workspace/calls.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -89,11 +89,11 @@ - - - - - - + + + + + + diff --git a/workspace/chat.html b/workspace/chat.html index f6629267..183246ef 100644 --- a/workspace/chat.html +++ b/workspace/chat.html @@ -12,26 +12,26 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - + + + + - - - + + + - + @@ -289,10 +289,10 @@ - - - - - + + + + + diff --git a/workspace/content.html b/workspace/content.html index a72cc847..f95f3c16 100644 --- a/workspace/content.html +++ b/workspace/content.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -179,12 +179,12 @@ - - - - - - - + + + + + + + diff --git a/workspace/crew-index.html b/workspace/crew-index.html index e2e58c8d..774e6775 100644 --- a/workspace/crew-index.html +++ b/workspace/crew-index.html @@ -30,15 +30,15 @@ - - - + + + - + @@ -252,6 +252,6 @@ - + diff --git a/workspace/crew.webmanifest b/workspace/crew.webmanifest index 4399ac6b..6b865989 100644 --- a/workspace/crew.webmanifest +++ b/workspace/crew.webmanifest @@ -13,25 +13,25 @@ "theme_color": "#06090f", "icons": [ { - "src": "/assets/img/app-symbole/crew-192.png?v=202609111104", + "src": "/assets/img/app-symbole/crew-192.png?v=202609111117", "sizes": "192x192", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/crew-512.png?v=202609111104", + "src": "/assets/img/app-symbole/crew-512.png?v=202609111117", "sizes": "512x512", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/crew-192-maskable.png?v=202609111104", + "src": "/assets/img/app-symbole/crew-192-maskable.png?v=202609111117", "sizes": "192x192", "type": "image/png", "purpose": "maskable" }, { - "src": "/assets/img/app-symbole/crew-512-maskable.png?v=202609111104", + "src": "/assets/img/app-symbole/crew-512-maskable.png?v=202609111117", "sizes": "512x512", "type": "image/png", "purpose": "maskable" diff --git a/workspace/dateien.html b/workspace/dateien.html index 5259f6ee..0f7a03c1 100644 --- a/workspace/dateien.html +++ b/workspace/dateien.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -108,10 +108,10 @@ - - - - - + + + + + diff --git a/workspace/index.html b/workspace/index.html index 3a35f168..2dbcfe37 100644 --- a/workspace/index.html +++ b/workspace/index.html @@ -30,9 +30,9 @@ - - - + + + @@ -401,6 +401,6 @@ - + diff --git a/workspace/kalender.html b/workspace/kalender.html index ad405924..8eb53726 100644 --- a/workspace/kalender.html +++ b/workspace/kalender.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -377,11 +377,11 @@ - - - - - - + + + + + + diff --git a/workspace/leistung.html b/workspace/leistung.html index de69a2ba..d51f02ca 100644 --- a/workspace/leistung.html +++ b/workspace/leistung.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -224,10 +224,10 @@ - - - - - + + + + + diff --git a/workspace/personen.html b/workspace/personen.html index 00bbb4c7..9847548c 100644 --- a/workspace/personen.html +++ b/workspace/personen.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -396,10 +396,10 @@ - - - - - + + + + + diff --git a/workspace/profil.html b/workspace/profil.html index 50057519..d88ffffb 100644 --- a/workspace/profil.html +++ b/workspace/profil.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -214,11 +214,11 @@ - - - - - - + + + + + + diff --git a/workspace/report.html b/workspace/report.html index 4dd83d6d..b0dfb24b 100644 --- a/workspace/report.html +++ b/workspace/report.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -106,11 +106,11 @@ - - - - - - + + + + + + diff --git a/workspace/scouting.html b/workspace/scouting.html index 62c961b2..fbef0d09 100644 --- a/workspace/scouting.html +++ b/workspace/scouting.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -109,6 +109,40 @@ + +
+ + +
+
+ + +
+
+ + +
@@ -139,10 +173,10 @@
- - - - - + + + + + diff --git a/workspace/start.html b/workspace/start.html index 05f8f953..85c478be 100644 --- a/workspace/start.html +++ b/workspace/start.html @@ -12,21 +12,21 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - + + + + - - + + - + @@ -674,10 +674,10 @@

- - - - - + + + + + diff --git a/workspace/startcheck.html b/workspace/startcheck.html index 4ae8a059..7fa07098 100644 --- a/workspace/startcheck.html +++ b/workspace/startcheck.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -85,11 +85,11 @@ - - - - - - + + + + + + diff --git a/workspace/steckbrief.html b/workspace/steckbrief.html index a67ae1c3..43a6c543 100644 --- a/workspace/steckbrief.html +++ b/workspace/steckbrief.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -126,10 +126,10 @@ - - - - - + + + + + diff --git a/workspace/team.html b/workspace/team.html index 825fb7ad..935820ce 100644 --- a/workspace/team.html +++ b/workspace/team.html @@ -7,20 +7,20 @@ - - - - - - - + + + + + + + - + @@ -110,10 +110,10 @@ - - - - - + + + + + diff --git a/workspace/teamlage.html b/workspace/teamlage.html index fa8673cc..716aeecd 100644 --- a/workspace/teamlage.html +++ b/workspace/teamlage.html @@ -7,20 +7,20 @@ - - - - - - - + + + + + + + - + - - - - + + + + diff --git a/workspace/uebersicht.html b/workspace/uebersicht.html index abb3a310..6a12a380 100644 --- a/workspace/uebersicht.html +++ b/workspace/uebersicht.html @@ -12,22 +12,22 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - + + + + - - - + + + - + @@ -149,11 +149,11 @@ - + - - - - + + + + diff --git a/workspace/wissen.html b/workspace/wissen.html index 67f324a1..7a65fb91 100644 --- a/workspace/wissen.html +++ b/workspace/wissen.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -190,10 +190,10 @@ - - - - - + + + + +