Filipe: "wenn ich auf screen1 druecke geht mein ordner auf aber meine
excel datei kann ich nicht auswaehlen wieso??"
Im Feld stand accept=".csv,text/csv,text/plain". Die .xlsx war damit
ausgegraut -- ohne ein Wort dazu. Eine Sackgasse ohne Wegweiser ist
schlimmer als eine Absage mit Begruendung: Man sucht den Fehler bei
sich.
WARUM SIE NICHT EINFACH GELESEN WIRD: Der Leser dahinter (csvZerlegen)
versteht Text -- Komma, Semikolon, Tabulator. Eine .xlsx ist in
Wahrheit ein ZIP-Archiv. Als Text gelesen ergibt sie Zeichensalat, und
der schlimmste Ausgang waere nicht "geht nicht", sondern eine Vorschau
mit Zahlen aus dem Dateikopf. Das Feld einfach zu oeffnen, ohne den
Fall zu behandeln, haette aus einer klaren Sperre einen unklaren
Fehlschlag gemacht.
Jetzt laesst sich jede Datei waehlen, und WAS sie ist, sagt der INHALT
-- die ersten Bytes, nicht die Endung. Eine umbenannte Datei ist keine
andere Datei. .xlsx beginnt mit "PK" (ZIP), die alte .xls mit dem
OLE-Kennzeichen D0 CF 11 E0.
Und dann steht da, was stattdessen zu tun ist, mit ZWEI Wegen:
1. In Excel markieren, Strg+C, und den Knopf "Backstage-Tabelle
einfuegen" nehmen -- der versteht TAB-getrennte Zeilen, also genau
das, was beim Kopieren aus Excel in der Zwischenablage liegt. Das
geht sofort und ohne Umspeichern.
2. Oder in Excel als "CSV UTF-8" speichern.
Der erste Weg funktionierte schon vorher -- er stand nur nirgends.
Nebenbei: Eine leere Datei meldet jetzt "Die Datei ist leer" statt
durch die Vorschau zu laufen, und eine abgewiesene Datei bleibt nicht
im Feld stehen.
pruef-backstage-import 66 -> 72. Geprueft wird an einem echten
ZIP-Kopf, nicht an der Endung: Die Testdatei heisst .xlsx UND traegt
das Kennzeichen -- eine Pruefung ueber den Namen waere gruen, ohne die
Erkennung je zu beruehren. Mit Gegenprobe, dass eine echte CSV
denselben Weg weiterhin durchlaeuft; sonst hiesse das nur, dass gar
nichts mehr eingelesen wird.
Co-Authored-By: Claude Opus 5 <[email protected]>
799 lines
31 KiB
JavaScript
799 lines
31 KiB
JavaScript
/* =====================================================================
|
||
leistung.js — die Zahlen über das Geschäft.
|
||
|
||
Stufe 1 des Plans vom 31.08.2026. Der Server rechnet (siehe
|
||
workspace-leistung.js), diese Datei zeigt und nimmt entgegen.
|
||
|
||
DREI DINGE, die jede Zahl hier mitbringt -- sonst fliegt sie raus:
|
||
1. den WERT
|
||
2. den VERGLEICH zur Vorwoche ("+23 %")
|
||
3. den VERLAUF als kleine Linie
|
||
|
||
Eine Zahl ohne diese drei ist eine Eitelkeitszahl. Sie sieht nach
|
||
Auskunft aus, und man kann nichts entscheiden.
|
||
===================================================================== */
|
||
(() => {
|
||
'use strict';
|
||
|
||
const $ = (id) => document.getElementById(id);
|
||
const el = (tag, klasse, text) => {
|
||
const k = document.createElement(tag);
|
||
if (klasse) k.className = klasse;
|
||
if (text !== undefined) k.textContent = text;
|
||
return k;
|
||
};
|
||
|
||
/* Die Leitungsliste steht in bereiche.js -- eine Antwort statt neun.
|
||
Sie stand hier frueher selbst, ohne 'spicy', und genau das war der
|
||
Fehler, den Filipe am 07.09.2026 gefunden hat. */
|
||
const LEITUNG = window.Bereiche.LEITUNG;
|
||
let ich = null;
|
||
let creator = [];
|
||
let offenerCreator = 0;
|
||
let stand = null;
|
||
let importText = '';
|
||
let importSpalten = null;
|
||
|
||
async function hole(weg, einst) {
|
||
const a = await fetch(weg, { credentials: 'same-origin', ...einst });
|
||
if (a.status === 401) { location.assign('/workspace/'); throw new Error('weg'); }
|
||
return a;
|
||
}
|
||
const melde = (t) => { $('fehler').textContent = t || ''; };
|
||
|
||
/* ---------- Zahlen lesbar machen ---------------------------------------- */
|
||
|
||
/** 1240 → "1.240". Tausenderpunkte, damit man vierstellige Zahlen
|
||
* auf einen Blick erfasst. */
|
||
const zahl = (n) => (n === null || n === undefined ? '–' : Number(n).toLocaleString('de-DE'));
|
||
|
||
/** Sekunden → "3:30 Min". Eine Verweildauer von 210 Sekunden liest
|
||
* niemand als dreieinhalb Minuten, wenn sie als "210" dasteht. */
|
||
function dauer(s) {
|
||
if (s === null || s === undefined) return '–';
|
||
const m = Math.floor(s / 60);
|
||
const rest = Math.round(s % 60);
|
||
return m ? `${m}:${String(rest).padStart(2, '0')} Min` : `${rest} Sek`;
|
||
}
|
||
|
||
/** Minuten → "1 Std 35". */
|
||
function stunden(min) {
|
||
if (min === null || min === undefined) return '–';
|
||
const h = Math.floor(min / 60);
|
||
const m = min % 60;
|
||
return h ? `${h} Std ${m}` : `${m} Min`;
|
||
}
|
||
|
||
/* ---------- Die Wochenkarten -------------------------------------------- */
|
||
|
||
const FELDER = [
|
||
/* Die Verweildauer steht VORN. Sie ist die Leitzahl (Recherche
|
||
06.09.2026), nicht eine von acht. */
|
||
{ key: 'verweildauer_s', name: 'Ø Verweildauer', form: dauer, leit: true },
|
||
{ key: 'diamanten', name: 'Diamanten', form: zahl },
|
||
{ key: 'gueltige_tage', name: 'LIVE-Tage', form: (n) => `${n} von 7` },
|
||
{ key: 'dauer_min', name: 'LIVE-Zeit', form: stunden },
|
||
{ key: 'zuschauer_avg', name: 'Ø Zuschauer', form: zahl },
|
||
{ key: 'schenker', name: 'Schenker', form: zahl },
|
||
{ key: 'follower_neu', name: 'Neue Follower', form: zahl },
|
||
];
|
||
|
||
function wochenKartenBauen() {
|
||
const ziel = $('zahlkarten');
|
||
ziel.textContent = '';
|
||
if (!stand) return;
|
||
|
||
for (const f of FELDER) {
|
||
const w = stand.woche[f.key];
|
||
if (!w) continue;
|
||
|
||
const karte = el('article', 'zahlkarte');
|
||
if (f.leit) karte.dataset.leit = 'ja';
|
||
|
||
karte.append(el('p', 'zahlkarte__name', f.name));
|
||
karte.append(el('p', 'zahlkarte__wert', f.form(w.wert)));
|
||
|
||
/* DER VERGLEICH. Ohne ihn ist die Zahl eine Behauptung. */
|
||
const v = el('p', 'zahlkarte__wandel');
|
||
if (w.wandel?.prozent !== undefined) {
|
||
const p = w.wandel.prozent;
|
||
v.dataset.richtung = p > 0 ? 'hoch' : p < 0 ? 'runter' : 'gleich';
|
||
v.textContent = `${p > 0 ? '+' : ''}${p} % gegenüber der Vorwoche`;
|
||
} else if (w.wandel?.neu) {
|
||
/* "Von null auf 500" -- keine Prozent, sondern die Auskunft,
|
||
die dahintersteckt. */
|
||
v.dataset.richtung = 'hoch';
|
||
v.textContent = 'neu – vorher nichts';
|
||
} else {
|
||
v.dataset.richtung = 'gleich';
|
||
v.textContent = w.vorher === null ? 'kein Vergleich' : 'wie in der Vorwoche';
|
||
}
|
||
karte.append(v);
|
||
|
||
/* DER VERLAUF als kleine Linie. Acht Wochen -- ohne Richtung ist
|
||
ein Wert nur ein Punkt. */
|
||
const linie = verlaufsLinie(stand.verlauf, f.key);
|
||
if (linie) karte.append(linie);
|
||
|
||
ziel.append(karte);
|
||
}
|
||
}
|
||
|
||
/** Eine kleine Linie aus den letzten acht Wochen.
|
||
*
|
||
* Selbst gezeichnet als SVG-Pfad, kein Diagrammpaket: Für acht
|
||
* Punkte eine Bibliothek zu laden wäre in einem Haus mit drei
|
||
* Abhängigkeiten die vierte -- und sie brächte Farben, Achsen und
|
||
* Beschriftungen mit, die hier alle stören würden. */
|
||
function verlaufsLinie(verlauf, key) {
|
||
if (!Array.isArray(verlauf) || verlauf.length < 3) return null;
|
||
/* == null FAENGT AUCH undefined -- und genau darauf kam es an.
|
||
Der Server liefert im Verlauf nur drei der sieben Groessen
|
||
(Diamanten, LIVE-Tage, Verweildauer); fuer alle anderen ist
|
||
w[key] undefined. Mit `=== null` rutschte das durch, wurde zu
|
||
Number(undefined) = NaN, und die Linie bekam Punkte wie
|
||
"NaN,NaN" -- der Browser zeichnet dann gar nichts und sagt
|
||
nichts. Eine leere Stelle in der Karte, ohne Fehlermeldung. */
|
||
const werte = verlauf.map((w) => {
|
||
const r = w?.[key];
|
||
if (r === null || r === undefined) return null;
|
||
const n = Number(r);
|
||
return Number.isFinite(n) ? n : null;
|
||
});
|
||
const echte = werte.filter((w) => w !== null);
|
||
if (echte.length < 3) return null;
|
||
|
||
const max = Math.max(...echte);
|
||
const min = Math.min(...echte);
|
||
const spanne = max - min || 1;
|
||
const B = 100, H = 26;
|
||
|
||
const punkte = werte.map((w, i) => {
|
||
if (w === null) return null;
|
||
const x = (i / (werte.length - 1)) * B;
|
||
/* Von unten nach oben: In SVG wächst y nach unten. */
|
||
const y = H - ((w - min) / spanne) * (H - 4) - 2;
|
||
return `${x.toFixed(1)},${y.toFixed(1)}`;
|
||
}).filter(Boolean);
|
||
|
||
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||
svg.setAttribute('class', 'zahlkarte__linie');
|
||
svg.setAttribute('viewBox', `0 0 ${B} ${H}`);
|
||
svg.setAttribute('preserveAspectRatio', 'none');
|
||
svg.setAttribute('aria-hidden', 'true');
|
||
svg.innerHTML = `<polyline points="${punkte.join(' ')}" />`;
|
||
/* Der letzte Punkt bekommt einen Kreis -- er ist das Jetzt. */
|
||
const letzter = punkte[punkte.length - 1].split(',');
|
||
svg.innerHTML += `<circle cx="${letzter[0]}" cy="${letzter[1]}" r="2.2" />`;
|
||
return svg;
|
||
}
|
||
|
||
/* ---------- Ziele als Fortschritt --------------------------------------- */
|
||
|
||
function zieleBauen() {
|
||
const ziel = $('zielbalken');
|
||
ziel.textContent = '';
|
||
const z = stand?.ziele;
|
||
$('ziele-aendern').hidden = !darfEintragen();
|
||
|
||
/* ALLE EINTRAG-KNOEPFE AN EINER STELLE (11.09.2026).
|
||
Seit heute oeffnet sich diese Seite fuer jede Rolle -- Filipe:
|
||
"jetzt soll jede rolle diese kachel sehen ... aber die creator
|
||
kriegen dan quasi nur ihre daten zu sehen."
|
||
|
||
GEMESSEN, NICHT ANGENOMMEN: Beim Umbau fiel auf, dass
|
||
'import-oeffnen' als einziger Knopf NIE eine Rechteregel hatte.
|
||
Das war folgenlos, solange nur DogFather hereinkam. Ab heute
|
||
haette ein Creator dort "Aus Datei einlesen" gesehen und eine
|
||
Absage bekommen. Genau deshalb steht die Liste jetzt hier: Wer
|
||
einen Knopf hinzufuegt, sieht die Regel daneben und kann sie
|
||
nicht vergessen. */
|
||
const KNOEPFE = [
|
||
/* Eigene Datei mit Tageszahlen -- wer eintragen darf, darf das. */
|
||
['import-oeffnen', darfEintragen()],
|
||
/* Der Backstage-Weg gilt fuer ALLE Creator auf einmal -- deshalb
|
||
nur fuer die Leitung, nicht fuer einen Scout, der ohnehin nur
|
||
seine eigenen sieht. Der Server prueft dasselbe (netzDarf);
|
||
hier geht es darum, niemandem einen Knopf hinzustellen, der
|
||
eine Absage bringt. */
|
||
['netz-oeffnen', LEITUNG.has(ich?.rolle)],
|
||
];
|
||
for (const [knopf, darf] of KNOEPFE) $(knopf).hidden = !darf;
|
||
|
||
/* Jeder Erklaersatz haengt an SEINEM Knopf, nicht am Block. Ein
|
||
Scout, der die Backstage-Tabelle nicht hat, soll sie auch nicht
|
||
erklaert bekommen -- das waere keine Hilfe, sondern eine Absage,
|
||
die er nicht gesucht hat. Und ein Creator sieht keinen Knopf,
|
||
also auch keinen Satz.
|
||
|
||
Die Liste hat seit dem 11.09.2026 nur noch einen Eintrag (der
|
||
Weg ueber die TikTok-Datei des Creators ist entfernt). Sie
|
||
bleibt trotzdem eine Liste: Der naechste Weg haengt sich dann
|
||
daneben, statt dass jemand die Regel ein zweites Mal
|
||
aufschreibt. */
|
||
for (const [satz, knopf] of [['quelle-netz', 'netz-oeffnen']]) {
|
||
$(satz).hidden = $(knopf).hidden;
|
||
}
|
||
$('quellen-hinweis').hidden = $('quelle-netz').hidden;
|
||
|
||
if (!z || (!z.diamanten && !z.tage && !z.verweildauer)) {
|
||
ziel.append(el('p', 'leistung__leer',
|
||
darfEintragen()
|
||
? 'Noch keine Ziele gesetzt. Aus „mehr Reichweite" wird hier eine prüfbare Größe.'
|
||
: 'Noch keine Ziele gesetzt.'));
|
||
return;
|
||
}
|
||
|
||
for (const [key, name, form] of [
|
||
['diamanten', 'Diamanten', zahl],
|
||
['tage', 'LIVE-Tage', (n) => String(n)],
|
||
['verweildauer', 'Ø Verweildauer', dauer],
|
||
]) {
|
||
const t = z[key];
|
||
if (!t) continue;
|
||
const zeile = el('div', 'zielbalken__zeile');
|
||
const kopf = el('div', 'zielbalken__kopf');
|
||
kopf.append(el('span', 'zielbalken__name', name));
|
||
kopf.append(el('span', 'zielbalken__zahl', `${form(t.ist)} von ${form(t.ziel)}`));
|
||
zeile.append(kopf);
|
||
|
||
const spur = el('div', 'zielbalken__spur');
|
||
const fuell = el('div', 'zielbalken__fuell');
|
||
/* Über 100 % wird nicht breiter -- der Balken bliebe sonst aus
|
||
dem Kasten hängen. Die Zahl daneben sagt es trotzdem. */
|
||
fuell.style.width = `${Math.min(100, Math.max(0, t.anteil))}%`;
|
||
if (t.anteil >= 100) fuell.dataset.voll = 'ja';
|
||
spur.append(fuell);
|
||
zeile.append(spur);
|
||
zeile.append(el('p', 'zielbalken__anteil',
|
||
t.anteil >= 100 ? `Ziel erreicht (${t.anteil} %)` : `${t.anteil} %`));
|
||
ziel.append(zeile);
|
||
}
|
||
}
|
||
|
||
const darfEintragen = () => LEITUNG.has(ich?.rolle) || ich?.rolle === 'scout';
|
||
|
||
/* ---------- Die Tageszeilen --------------------------------------------- */
|
||
|
||
async function tageLaden() {
|
||
let tage = [];
|
||
try {
|
||
const a = await hole(`/workspace/api/leistung/${offenerCreator}/tage?tage=21`);
|
||
if (a.ok) tage = (await a.json()).tage || [];
|
||
} catch { return; }
|
||
|
||
const ziel = $('tagetabelle');
|
||
ziel.textContent = '';
|
||
const nach = new Map(tage.map((t) => [t.tag, t]));
|
||
|
||
/* DIE LETZTEN 14 TAGE, auch die ohne Eintrag. Nur die vorhandenen
|
||
zu zeigen hieße, dass eine Lücke unsichtbar ist -- und genau die
|
||
Lücke ist die Auskunft: Da war nichts, oder es wurde nicht
|
||
erfasst. */
|
||
for (let i = 0; i < 14; i++) {
|
||
const d = new Date();
|
||
d.setDate(d.getDate() - i);
|
||
const p = (n) => String(n).padStart(2, '0');
|
||
const tag = `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
|
||
const z = nach.get(tag);
|
||
|
||
const zeile = el('div', 'tagzeile');
|
||
if (!z) zeile.dataset.leer = 'ja';
|
||
if (z?.quelle === 'import') zeile.dataset.quelle = 'import';
|
||
|
||
const datum = el('div', 'tagzeile__datum');
|
||
datum.append(el('span', 'tagzeile__wochentag',
|
||
d.toLocaleDateString('de-DE', { weekday: 'short' })));
|
||
datum.append(el('span', 'tagzeile__zahl',
|
||
d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' })));
|
||
zeile.append(datum);
|
||
|
||
const werte = el('div', 'tagzeile__werte');
|
||
if (z) {
|
||
werte.append(wert('Diamanten', zahl(z.diamanten)));
|
||
werte.append(wert('LIVE', stunden(z.dauer_min)));
|
||
werte.append(wert('Verweil', dauer(z.verweildauer_s)));
|
||
werte.append(wert('Ø Zusch.', zahl(z.zuschauer_avg)));
|
||
} else {
|
||
werte.append(el('span', 'tagzeile__leer', 'nichts erfasst'));
|
||
}
|
||
zeile.append(werte);
|
||
|
||
if (z?.notiz) {
|
||
const n = el('p', 'tagzeile__notiz', z.notiz);
|
||
zeile.append(n);
|
||
}
|
||
|
||
if (darfEintragen()) {
|
||
const knopf = el('button', 'tagzeile__knopf', z ? 'ändern' : 'eintragen');
|
||
knopf.type = 'button';
|
||
knopf.setAttribute('aria-label',
|
||
`${tag} ${z ? 'ändern' : 'eintragen'}`);
|
||
knopf.addEventListener('click', () => tagOeffnen(tag, z));
|
||
zeile.append(knopf);
|
||
}
|
||
ziel.append(zeile);
|
||
}
|
||
|
||
/* ZUGEKLAPPT AUF SIEBEN TAGE (07.09.2026, Wunsch Filipe).
|
||
|
||
Vierzehn Zeilen sind zu viel fuers Hinsehen und zu wenig fuers
|
||
Nachschlagen -- man scrollt an ihnen vorbei, statt sie zu lesen.
|
||
Sieben ist die Woche: der kuerzeste Zeitraum, ueber den man
|
||
ueberhaupt etwas sagen kann, und genau der, mit dem der
|
||
Wochenbericht rechnet. Die restlichen sieben Tage sind einen
|
||
Klick entfernt und verschwinden nicht.
|
||
|
||
Die Sieben steht nicht hier, sondern als eigene Regel im CSS
|
||
(`[data-klapp="zu7"]`) -- der Schalter kennt beide Stufen und
|
||
benennt sie im Knopftext, damit niemand raten muss, wie viel
|
||
gerade verborgen ist. */
|
||
window.klappe?.(ziel, document.getElementById('tage-schalter'),
|
||
document.getElementById('tage-schalter-text'), 7);
|
||
}
|
||
|
||
function wert(name, text) {
|
||
const s = el('span', 'tagzeile__wert');
|
||
s.append(el('span', 'tagzeile__wertname', name));
|
||
s.append(el('span', 'tagzeile__wertzahl', text));
|
||
return s;
|
||
}
|
||
|
||
/* ---------- Eintragen ---------------------------------------------------- */
|
||
|
||
function tagOeffnen(tag, z) {
|
||
const d = $('tag-dialog');
|
||
d.dataset.tag = tag;
|
||
const datum = new Date(tag + 'T12:00:00');
|
||
$('tag-titel').textContent = datum.toLocaleDateString('de-DE',
|
||
{ weekday: 'long', day: 'numeric', month: 'long' });
|
||
$('tag-unter').textContent = z?.quelle === 'import'
|
||
? 'Diese Zeile kam aus einer Datei. Änderungen von Hand bleiben erhalten, bis erneut eingelesen wird.'
|
||
: 'Leer lassen heißt: nicht gemessen. Eine Null ist etwas anderes.';
|
||
|
||
$('f-diamanten').value = z?.diamanten ?? '';
|
||
$('f-dauer').value = z?.dauer_min ?? '';
|
||
$('f-verweildauer').value = z?.verweildauer_s ?? '';
|
||
$('f-zavg').value = z?.zuschauer_avg ?? '';
|
||
$('f-zmax').value = z?.zuschauer_max ?? '';
|
||
$('f-schenker').value = z?.schenker ?? '';
|
||
$('f-follower').value = z?.follower_neu ?? '';
|
||
$('f-notiz').value = z?.notiz ?? '';
|
||
$('tag-fehler').textContent = '';
|
||
$('tag-loeschen').hidden = !z;
|
||
d.showModal();
|
||
$('f-diamanten').focus();
|
||
}
|
||
|
||
async function tagSpeichern(e) {
|
||
e.preventDefault();
|
||
const d = $('tag-dialog');
|
||
const tag = d.dataset.tag;
|
||
const n = (id) => {
|
||
const v = $(id).value.trim();
|
||
return v === '' ? null : Number(v);
|
||
};
|
||
const dauerMin = n('f-dauer');
|
||
|
||
try {
|
||
const a = await hole(`/workspace/api/leistung/${offenerCreator}/${tag}`, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
diamanten: n('f-diamanten'),
|
||
dauer_min: dauerMin,
|
||
/* Ein gültiger Tag ist ein Tag mit LIVE-Zeit -- daraus
|
||
geschlossen, statt ein weiteres Kästchen zu verlangen. */
|
||
gueltiger_tag: dauerMin > 0,
|
||
verweildauer_s: n('f-verweildauer'),
|
||
zuschauer_avg: n('f-zavg'),
|
||
zuschauer_max: n('f-zmax'),
|
||
schenker: n('f-schenker'),
|
||
follower_neu: n('f-follower'),
|
||
notiz: $('f-notiz').value,
|
||
}),
|
||
});
|
||
if (!a.ok) {
|
||
$('tag-fehler').textContent = (await a.json().catch(() => ({}))).fehler || 'Ging nicht.';
|
||
return;
|
||
}
|
||
d.close();
|
||
await allesLaden();
|
||
} catch { /* umgeleitet */ }
|
||
}
|
||
|
||
async function tagLoeschen() {
|
||
const d = $('tag-dialog');
|
||
if (!window.confirm('Diese Zeile wirklich löschen? Die Zahlen des Tages sind dann weg.')) return;
|
||
try {
|
||
const a = await hole(`/workspace/api/leistung/${offenerCreator}/${d.dataset.tag}`,
|
||
{ method: 'DELETE' });
|
||
if (!a.ok) { $('tag-fehler').textContent = 'Ging nicht.'; return; }
|
||
d.close();
|
||
await allesLaden();
|
||
} catch { /* umgeleitet */ }
|
||
}
|
||
|
||
/* ---------- Ziele ------------------------------------------------------- */
|
||
|
||
function zieleOeffnen() {
|
||
const z = stand?.ziele;
|
||
$('z-diamanten').value = z?.diamanten?.ziel ?? '';
|
||
$('z-tage').value = z?.tage?.ziel ?? '';
|
||
$('z-verweildauer').value = z?.verweildauer?.ziel ?? '';
|
||
$('ziele-fehler').textContent = '';
|
||
$('ziele-dialog').showModal();
|
||
}
|
||
|
||
async function zieleSpeichern(e) {
|
||
e.preventDefault();
|
||
const n = (id) => {
|
||
const v = $(id).value.trim();
|
||
return v === '' ? null : Number(v);
|
||
};
|
||
try {
|
||
const a = await hole(`/workspace/api/leistung/${offenerCreator}/ziele`, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
diamanten_woche: n('z-diamanten'),
|
||
tage_woche: n('z-tage'),
|
||
verweildauer_s: n('z-verweildauer'),
|
||
}),
|
||
});
|
||
if (!a.ok) { $('ziele-fehler').textContent = 'Ging nicht.'; return; }
|
||
$('ziele-dialog').close();
|
||
await allesLaden();
|
||
} catch { /* umgeleitet */ }
|
||
}
|
||
|
||
/* ---------- Import ------------------------------------------------------ */
|
||
|
||
async function dateiGewaehlt(e) {
|
||
const datei = e.target.files?.[0];
|
||
if (!datei) return;
|
||
$('import-fehler').textContent = '';
|
||
$('vorschau').hidden = true;
|
||
$('import-los').disabled = true;
|
||
|
||
/* WAS IST DAS UEBERHAUPT FUER EINE DATEI? (11.09.2026)
|
||
|
||
Filipe: "meine excel datei kann ich nicht auswaehlen wieso??"
|
||
|
||
Bis heute stand im Auswahlfeld nur `.csv` -- eine .xlsx war
|
||
ausgegraut, ohne ein Wort dazu. Eine Sackgasse ohne Wegweiser
|
||
ist schlimmer als eine Absage mit Begruendung: Man sucht den
|
||
Fehler bei sich.
|
||
|
||
Jetzt laesst sich jede Datei waehlen, und WAS sie ist, sagt der
|
||
INHALT -- nicht die Endung. Eine umbenannte Datei ist keine
|
||
andere Datei. Eine .xlsx ist in Wahrheit ein ZIP-Archiv und
|
||
beginnt mit "PK"; die alte .xls beginnt mit dem
|
||
OLE-Kennzeichen D0 CF 11 E0.
|
||
|
||
WARUM WIR SIE NICHT EINFACH LESEN: Der Leser dahinter versteht
|
||
Text (Komma, Semikolon, Tabulator). Ein ZIP-Archiv als Text
|
||
gelesen ergibt Zeichensalat -- und der schlimmste Ausgang waere
|
||
nicht "geht nicht", sondern eine Vorschau mit Zahlen, die
|
||
irgendwo aus dem Dateikopf stammen. */
|
||
const anfang = new Uint8Array(await datei.slice(0, 8).arrayBuffer());
|
||
const beginnt = (...b) => b.every((x, i) => anfang[i] === x);
|
||
if (beginnt(0x50, 0x4b) || beginnt(0xd0, 0xcf, 0x11, 0xe0)) {
|
||
$('import-fehler').innerHTML =
|
||
'Das ist eine <strong>Excel-Datei</strong>. Hier passt eine Text-Tabelle '
|
||
+ '(CSV) hinein. Zwei Wege, beide dauern keine Minute:<br>'
|
||
+ '<strong>1.</strong> In Excel alles markieren (Strg+A), kopieren (Strg+C) – '
|
||
+ 'und statt hier den Knopf <strong>„Backstage-Tabelle einfügen"</strong> nehmen. '
|
||
+ 'Dort einfügen, fertig.<br>'
|
||
+ '<strong>2.</strong> Oder in Excel: <em>Datei → Speichern unter</em> und als '
|
||
+ 'Dateityp <em>CSV UTF-8 (durch Trennzeichen getrennt)</em> wählen.';
|
||
$('datei').value = '';
|
||
return;
|
||
}
|
||
|
||
try {
|
||
importText = await datei.text();
|
||
} catch {
|
||
$('import-fehler').textContent = 'Die Datei ließ sich nicht lesen.';
|
||
return;
|
||
}
|
||
|
||
/* Eine leere Datei ist kein Lesefehler, sieht aber wie einer aus. */
|
||
if (!importText.trim()) {
|
||
$('import-fehler').textContent = 'Die Datei ist leer.';
|
||
$('datei').value = '';
|
||
return;
|
||
}
|
||
|
||
/* ERST DIE VORSCHAU. Ein Import, der sofort schreibt, ist bei
|
||
einer falsch zugeordneten Spalte nicht mehr zurückzuholen. */
|
||
try {
|
||
const a = await hole(`/workspace/api/leistung/${offenerCreator}/import/vorschau`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ text: importText }),
|
||
});
|
||
const v = await a.json();
|
||
if (!a.ok) {
|
||
$('import-fehler').textContent = v.fehler || 'Die Datei konnte nicht gelesen werden.';
|
||
return;
|
||
}
|
||
importSpalten = v.spalten;
|
||
vorschauZeigen(v);
|
||
} catch { /* umgeleitet */ }
|
||
}
|
||
|
||
function vorschauZeigen(v) {
|
||
const ziel = $('vorschau');
|
||
ziel.textContent = '';
|
||
ziel.hidden = false;
|
||
|
||
/* Die ZAHLEN stehen vorn: Wer übernimmt, will wissen "wie viele",
|
||
nicht "welche". */
|
||
const zeile = el('div', 'import-zahlen');
|
||
zeile.append(importZahl(v.neu, 'neu'));
|
||
zeile.append(importZahl(v.ersetzt, v.ersetzt === 1 ? 'wird ersetzt' : 'werden ersetzt'));
|
||
if (v.fehlerhaft) zeile.append(importZahl(v.fehlerhaft, 'unlesbar', true));
|
||
ziel.append(zeile);
|
||
|
||
/* Welche Spalten erkannt wurden -- damit man merkt, wenn etwas
|
||
fehlt. Eine stillschweigend nicht erkannte Spalte wäre eine
|
||
Zahl, die einfach nie ankommt. */
|
||
const erkannt = Object.keys(importSpalten || {}).filter((k) => k !== 'tag');
|
||
const namen = {
|
||
diamanten: 'Diamanten', dauer_min: 'LIVE-Dauer', zuschauer_avg: 'Ø Zuschauer',
|
||
zuschauer_max: 'Spitze', verweildauer_s: 'Verweildauer',
|
||
schenker: 'Schenker', follower_neu: 'Follower',
|
||
};
|
||
ziel.append(el('p', 'import-spalten',
|
||
erkannt.length
|
||
? 'Erkannt: ' + erkannt.map((k) => namen[k] || k).join(' · ')
|
||
: 'Außer dem Datum wurde keine Spalte erkannt – die Zahlen bleiben leer.'));
|
||
|
||
if (v.probleme?.length) {
|
||
const liste = el('ul', 'import-probleme');
|
||
for (const p of v.probleme) {
|
||
liste.append(el('li', null, `Zeile ${p.zeile}: ${p.grund}`));
|
||
}
|
||
ziel.append(liste);
|
||
}
|
||
|
||
$('import-los').disabled = !(v.neu + v.ersetzt);
|
||
}
|
||
|
||
function importZahl(n, name, warn = false) {
|
||
const k = el('div', 'import-zahl');
|
||
if (warn) k.dataset.warn = 'ja';
|
||
k.append(el('span', 'import-zahl__wert', String(n)));
|
||
k.append(el('span', 'import-zahl__name', name));
|
||
return k;
|
||
}
|
||
|
||
async function importUebernehmen(e) {
|
||
e.preventDefault();
|
||
if (!importText) return;
|
||
const knopf = $('import-los');
|
||
knopf.disabled = true;
|
||
try {
|
||
const a = await hole(`/workspace/api/leistung/${offenerCreator}/import`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ text: importText, spalten: importSpalten }),
|
||
});
|
||
const r = await a.json();
|
||
if (!a.ok) { $('import-fehler').textContent = r.fehler || 'Ging nicht.'; return; }
|
||
$('import-dialog').close();
|
||
$('datei').value = '';
|
||
importText = '';
|
||
await allesLaden();
|
||
melde(`${r.geschrieben} Tage übernommen.`);
|
||
setTimeout(() => melde(''), 5000);
|
||
} catch { /* umgeleitet */ } finally { knopf.disabled = false; }
|
||
}
|
||
|
||
/* ---------- Laden ------------------------------------------------------- */
|
||
|
||
async function allesLaden() {
|
||
if (!offenerCreator) return;
|
||
try {
|
||
const a = await hole(`/workspace/api/leistung/${offenerCreator}`);
|
||
if (!a.ok) { melde('Die Zahlen konnten nicht geladen werden.'); return; }
|
||
stand = await a.json();
|
||
} catch { return; }
|
||
|
||
$('woche').hidden = false;
|
||
$('ziele-block').hidden = false;
|
||
$('tage-block').hidden = false;
|
||
|
||
/* WIE VOLLSTÄNDIG IST DIE WOCHE? Eine Zahl aus zwei von sieben
|
||
Tagen ist etwas anderes als eine aus sieben -- ohne diesen
|
||
Hinweis hält man eine halbe Woche für einen Einbruch. */
|
||
const e = stand.erfassung;
|
||
$('erfassung').textContent = e.tage >= 7
|
||
? 'alle 7 Tage erfasst'
|
||
: `${e.tage} von 7 Tagen erfasst`;
|
||
$('erfassung').dataset.knapp = e.tage < 4 ? 'ja' : '';
|
||
|
||
wochenKartenBauen();
|
||
zieleBauen();
|
||
await tageLaden();
|
||
}
|
||
|
||
/* ---------- Start -------------------------------------------------------- */
|
||
|
||
$('tag-form').addEventListener('submit', tagSpeichern);
|
||
$('tag-zu').addEventListener('click', () => $('tag-dialog').close());
|
||
$('tag-loeschen').addEventListener('click', tagLoeschen);
|
||
/* =====================================================================
|
||
DIE BACKSTAGE-TABELLE — Vorschau und Übernahme
|
||
===================================================================== */
|
||
async function netzVorschau() {
|
||
const text = $('netz-text').value.trim();
|
||
$('netz-fehler').textContent = '';
|
||
if (!text) { $('netz-vorschau').hidden = true; $('netz-los').disabled = true; return; }
|
||
|
||
const a = await hole('/workspace/api/leistung/netzwerk-import/vorschau', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ text, tag: $('netz-tag').value || null }),
|
||
}).catch(() => null);
|
||
if (!a) return;
|
||
const d = await a.json().catch(() => ({}));
|
||
|
||
if (!a.ok) {
|
||
$('netz-vorschau').hidden = true;
|
||
$('netz-los').disabled = true;
|
||
$('netz-fehler').textContent = d.fehler || 'Die Tabelle konnte nicht gelesen werden.';
|
||
/* Fehlt nur der Tag, ist das kein Fehler, sondern eine Frage --
|
||
also wird das Feld hervorgehoben statt nur gemeckert. */
|
||
if (d.braucht_tag) $('netz-tag').focus();
|
||
return;
|
||
}
|
||
|
||
const v = $('netz-vorschau');
|
||
v.textContent = '';
|
||
v.hidden = false;
|
||
|
||
const zeile = (text2, art) => {
|
||
const p = document.createElement('p');
|
||
p.className = 'import-vorschau__zeile';
|
||
if (art) p.dataset.art = art;
|
||
p.textContent = text2;
|
||
v.append(p);
|
||
};
|
||
|
||
zeile(`${d.zugeordnet} von ${d.zugeordnet + d.unbekannt.length} Zeilen zugeordnet`
|
||
+ ` · ${d.neu} neu, ${d.ersetzt} werden ersetzt`, d.zugeordnet ? 'gut' : 'warn');
|
||
zeile(`Erkannte Spalten: ${Object.keys(d.spalten).join(', ') || 'keine'}`
|
||
+ (d.aus_tabelle ? ' · Datum aus der Tabelle' : ' · Datum aus dem Feld oben'));
|
||
|
||
/* DIE UNBEKANNTEN SIND DER WICHTIGE TEIL. Wer sie übersieht, hat
|
||
stillschweigend Leute ausgelassen -- und merkt es erst, wenn die
|
||
Zahlen nicht stimmen. Deshalb stehen sie mit Namen da und nicht
|
||
als Zahl. */
|
||
if (d.unbekannt.length) {
|
||
zeile(`Nicht zugeordnet (${d.unbekannt.length}): ${d.unbekannt.slice(0, 8).join(', ')}`
|
||
+ (d.unbekannt.length > 8 ? ' …' : ''), 'warn');
|
||
zeile('Diese Namen stehen bei keinem Creator im Feld „TikTok" im Steckbrief. '
|
||
+ 'Dort eintragen – danach werden sie beim nächsten Mal gefunden.', 'still');
|
||
}
|
||
if (d.probleme_gesamt) {
|
||
zeile(`${d.probleme_gesamt} Zeile(n) übersprungen:`, 'warn');
|
||
for (const p of d.probleme) zeile(`Zeile ${p.zeile}: ${p.grund}`, 'still');
|
||
}
|
||
for (const b of d.beispiele) {
|
||
const zahlen = Object.entries(b)
|
||
.filter(([k]) => !['creator_id', 'name', 'tag'].includes(k))
|
||
.filter(([, w]) => w !== null && w !== undefined)
|
||
.map(([k, w]) => `${k} ${w}`).join(' · ');
|
||
zeile(`${b.name} · ${b.tag} · ${zahlen}`, 'still');
|
||
}
|
||
|
||
$('netz-los').disabled = d.zugeordnet === 0;
|
||
}
|
||
|
||
async function netzUebernehmen(ereignis) {
|
||
ereignis.preventDefault();
|
||
$('netz-los').disabled = true;
|
||
const a = await hole('/workspace/api/leistung/netzwerk-import', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
text: $('netz-text').value, tag: $('netz-tag').value || null,
|
||
}),
|
||
}).catch(() => null);
|
||
if (!a) { $('netz-los').disabled = false; return; }
|
||
const d = await a.json().catch(() => ({}));
|
||
if (!a.ok) {
|
||
$('netz-fehler').textContent = d.fehler || 'Übernehmen ging nicht.';
|
||
$('netz-los').disabled = false;
|
||
return;
|
||
}
|
||
$('netz-dialog').close();
|
||
melde(`${d.geschrieben} Zeile(n) übernommen`
|
||
+ (d.unbekannt?.length ? ` · ${d.unbekannt.length} nicht zugeordnet` : ''));
|
||
await laden();
|
||
}
|
||
|
||
$('ziele-aendern').addEventListener('click', zieleOeffnen);
|
||
$('ziele-form').addEventListener('submit', zieleSpeichern);
|
||
$('ziele-zu').addEventListener('click', () => $('ziele-dialog').close());
|
||
$('import-oeffnen').addEventListener('click', () => {
|
||
$('vorschau').hidden = true;
|
||
$('import-los').disabled = true;
|
||
$('import-fehler').textContent = '';
|
||
$('import-dialog').showModal();
|
||
});
|
||
$('import-zu').addEventListener('click', () => $('import-dialog').close());
|
||
$('datei').addEventListener('change', dateiGewaehlt);
|
||
$('import-form').addEventListener('submit', importUebernehmen);
|
||
|
||
/* ---------- Die Backstage-Tabelle ------------------------------------ */
|
||
$('netz-oeffnen').addEventListener('click', () => {
|
||
$('netz-vorschau').hidden = true;
|
||
$('netz-los').disabled = true;
|
||
$('netz-fehler').textContent = '';
|
||
$('netz-text').value = '';
|
||
$('netz-dialog').showModal();
|
||
$('netz-text').focus();
|
||
});
|
||
$('netz-zu').addEventListener('click', () => $('netz-dialog').close());
|
||
/* Beim EINFÜGEN sofort nachsehen -- nicht erst auf einen Knopf warten.
|
||
Wer eine Tabelle einfügt, will wissen, ob sie erkannt wurde; ein
|
||
zweiter Klick dazwischen ist nur eine Gelegenheit, es nicht zu tun. */
|
||
$('netz-text').addEventListener('paste', () => setTimeout(netzVorschau, 60));
|
||
$('netz-text').addEventListener('change', netzVorschau);
|
||
$('netz-tag').addEventListener('change', netzVorschau);
|
||
$('netz-form').addEventListener('submit', netzUebernehmen);
|
||
|
||
(async () => {
|
||
try {
|
||
const a = await hole('/workspace/api/ich');
|
||
if (!a.ok) { location.assign('/workspace/'); return; }
|
||
ich = await a.json();
|
||
} catch { return; }
|
||
window.werZeigen?.(ich);
|
||
|
||
/* Ein Creator sieht nur sich selbst -- ohne Auswahlfeld, das nur
|
||
einen Eintrag hätte. */
|
||
if (ich.rolle === 'creator') {
|
||
offenerCreator = ich.id;
|
||
await allesLaden();
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const { creator: liste } = await (await hole('/workspace/api/leistung')).json();
|
||
creator = liste || [];
|
||
} catch { return; }
|
||
|
||
if (!creator.length) {
|
||
melde('Dir ist noch kein Creator zugeordnet.');
|
||
return;
|
||
}
|
||
|
||
$('creator-wahl').hidden = false;
|
||
for (const c of creator) {
|
||
const o = document.createElement('option');
|
||
o.value = String(c.id);
|
||
/* Die wichtigste Zahl steht gleich in der Auswahl -- so sieht
|
||
man beim Umschalten, wo etwas los ist. */
|
||
o.textContent = c.tage_erfasst
|
||
? `${c.name} — ${zahl(c.diamanten)} Diamanten`
|
||
: `${c.name} — noch keine Zahlen`;
|
||
$('creator').append(o);
|
||
}
|
||
/* Vom Dashboard kommend: direkt zur richtigen Person. */
|
||
const wunsch = Number(new URLSearchParams(location.search).get('creator'));
|
||
offenerCreator = creator.some((c) => c.id === wunsch) ? wunsch : creator[0].id;
|
||
$('creator').value = String(offenerCreator);
|
||
window.personenwahl?.('creator');
|
||
|
||
$('creator').addEventListener('change', async () => {
|
||
offenerCreator = Number($('creator').value);
|
||
history.replaceState(null, '', `leistung.html?creator=${offenerCreator}`);
|
||
await allesLaden();
|
||
});
|
||
|
||
await allesLaden();
|
||
})();
|
||
})();
|