Verwaltung: Auswertungs-Kacheln (Bestseller/Herkunft/Stammkundschaft) im Stil der Bestellstatus-Kacheln

Jede Kategorie/jedes Land/jede Stammkundin ist jetzt eine eigene, klickbare Kachel
im selben Look wie die Bestellstatus-Kacheln oben (Wunsch: "sollen aussehen wie
screen2"). Klick auf eine Kachel öffnet direkt darunter ein Panel mit der
vollständigen Liste (nicht mehr nur den Top 4/8) — nochmal klicken klappt es
wieder ein.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
qcigano
2026-08-03 23:00:40 +02:00
co-authored by Claude Sonnet 5
parent 5df0643a9d
commit 03a9fa234c
2 changed files with 211 additions and 31 deletions
+162 -31
View File
@@ -158,7 +158,10 @@ const lang: Locale = "de";
<p>Noch keine ausgewerteten Verkäufe.</p>
<p class="small">Hier siehst du bald, welche Produkte je Kategorie am besten laufen (stornierte Bestellungen zählen nicht mit).</p>
</div>
<div class="verwaltung-auswertung-grid" id="verwaltung-kat-grid"></div>
{/* Jede Kategorie ist jetzt eine eigene Kachel im selben Stil wie die
Bestellstatus-Kacheln oben — Klick öffnet direkt darunter die volle Produktliste. */}
<div class="verwaltung-analyse-grid" id="verwaltung-kat-grid"></div>
<div class="verwaltung-analyse-detail" id="verwaltung-kat-detail" hidden></div>
</div>
</div>
@@ -173,7 +176,8 @@ const lang: Locale = "de";
<p>Noch keine Bestellungen.</p>
<p class="small">Sobald Bestellungen eingehen, siehst du hier den Anteil je Land (DE/AT/CH/LU).</p>
</div>
<div class="verwaltung-bar-list" id="verwaltung-land-list"></div>
<div class="verwaltung-analyse-grid" id="verwaltung-land-grid"></div>
<div class="verwaltung-analyse-detail" id="verwaltung-land-detail" hidden></div>
</div>
</div>
@@ -188,7 +192,8 @@ const lang: Locale = "de";
<p>Noch keine wiederkehrende Kundschaft.</p>
<p class="small">Sobald jemand zum zweiten Mal bestellt, erscheint sie hier — sortiert nach bisherigem Umsatz.</p>
</div>
<div class="verwaltung-bar-list" id="verwaltung-kunden-list"></div>
<div class="verwaltung-analyse-grid" id="verwaltung-kunden-grid"></div>
<div class="verwaltung-analyse-detail" id="verwaltung-kunden-detail" hidden></div>
</div>
</div>
</div>
@@ -296,6 +301,12 @@ const lang: Locale = "de";
let alleBestellungen = [];
let bekannteIds = new Set();
let erstesLaden = true;
// Merkt sich die zuletzt berechneten Auswertungsdaten, damit die Detail-Panels (Klick auf eine
// Kategorie-/Land-/Kundschafts-Kachel) darauf zugreifen können, ohne alles neu zu berechnen.
let letzteKategorien = [];
let letzteLaender = [];
let letzteKunden = [];
let letzteAktive = [];
async function ladeUndRendere() {
loadingEl.style.display = "";
@@ -359,6 +370,15 @@ const lang: Locale = "de";
function render() {
const aktive = alleBestellungen.filter((b) => b.status !== "storniert");
letzteAktive = aktive;
// Die Auswertungs-Detail-Panels zeigen ggf. veraltete Daten, sobald sich die Bestellliste
// ändert (z.B. neuer Status) — bei jedem Rendern lieber wieder einklappen, statt falsche
// Zahlen stehen zu lassen. Ein erneuter Klick zeigt die aktuellen Daten sofort wieder an.
["kat", "land", "kunden"].forEach((gruppe) => {
document.getElementById(`verwaltung-${gruppe}-detail`)?.setAttribute("hidden", "");
});
document.querySelectorAll(".verwaltung-stat-tile.is-active").forEach((t) => t.classList.remove("is-active"));
// ── Buckets für die Stat-Kacheln + Filterleiste ──
const buckets = { offen: 0, inBearbeitung: 0, versendet: 0, abgeschlossen: 0, storniert: 0 };
@@ -502,10 +522,13 @@ const lang: Locale = "de";
name: kat?.name?.[lang] ?? kategorieSlug,
icon: kat?.icon ?? "📦",
gesamtMenge: sortiert.reduce((s, p) => s + p.menge, 0),
produkte: sortiert.slice(0, 4).map((p) => ({ ...p, prozent: Math.round((p.menge / maxMenge) * 100) })),
// Volle Liste (nicht mehr auf 4 gekürzt) — die Kachel selbst zeigt nur die Summe, die
// vollständige Rangliste erscheint erst im aufklappbaren Detail-Panel darunter.
produkte: sortiert.map((p) => ({ ...p, prozent: Math.round((p.menge / maxMenge) * 100) })),
};
})
.sort((a, b) => b.gesamtMenge - a.gesamtMenge);
letzteKategorien = auswertungKategorien;
const katGrid = document.getElementById("verwaltung-kat-grid");
const katEmpty = document.getElementById("verwaltung-kat-empty");
@@ -515,17 +538,11 @@ const lang: Locale = "de";
} else {
katEmpty.style.display = "none";
katGrid.innerHTML = auswertungKategorien.map((k) => `
<div class="card verwaltung-kat-card">
<h3>${k.icon} ${escapeHtml(k.name)}</h3>
<div class="verwaltung-bar-list">
${k.produkte.map((p) => `
<div class="verwaltung-bar-row">
<div class="verwaltung-bar-head"><span>${escapeHtml(p.name)}</span><span class="small">${p.menge}× verkauft</span></div>
<div class="verwaltung-bar-track"><div class="verwaltung-bar-fill" style="width:${p.prozent}%"></div></div>
</div>
`).join("")}
</div>
</div>
<button type="button" class="card verwaltung-stat-tile" data-kat-tile="${escapeHtml(k.slug)}">
<span class="verwaltung-stat-icon" aria-hidden="true">${k.icon}</span>
<span class="verwaltung-stat-value">${k.gesamtMenge}</span>
<span class="verwaltung-stat-label">${escapeHtml(k.name)}</span>
</button>
`).join("");
}
@@ -540,20 +557,22 @@ const lang: Locale = "de";
count, prozent: aktive.length > 0 ? Math.round((count / aktive.length) * 100) : 0,
balken: Math.round((count / maxLandCount) * 100),
}));
letzteLaender = auswertungLaender;
const landList = document.getElementById("verwaltung-land-list");
const landGrid = document.getElementById("verwaltung-land-grid");
const landEmpty = document.getElementById("verwaltung-land-empty");
if (auswertungLaender.length === 0) {
landList.innerHTML = "";
landGrid.innerHTML = "";
landEmpty.style.display = "";
} else {
landEmpty.style.display = "none";
landList.innerHTML = auswertungLaender.map((l) => `
<div class="verwaltung-bar-row">
<div class="verwaltung-bar-head"><span>${l.flagge} ${escapeHtml(l.name)}</span><span class="small">${l.count} Bestellungen · ${l.prozent}%</span></div>
<div class="verwaltung-bar-track"><div class="verwaltung-bar-fill verwaltung-bar-fill-land" style="width:${l.balken}%"></div></div>
</div>
`).join("");
landGrid.innerHTML = auswertungLaender.map((l) => `
<button type="button" class="card verwaltung-stat-tile" data-land-tile="${l.land}">
<span class="verwaltung-stat-icon" aria-hidden="true">${l.flagge}</span>
<span class="verwaltung-stat-value">${l.count}</span>
<span class="verwaltung-stat-label">${escapeHtml(l.name)} · ${l.prozent}%</span>
</button>
`).join("");
}
// ── NEU: Beste Kundinnen (Umsatz pro E-Mail, nur wer mehr als 1× bestellt hat) ──
@@ -569,25 +588,93 @@ const lang: Locale = "de";
.map(([email, v]) => ({ email, ...v }))
.sort((a, b) => b.summe - a.summe)
.slice(0, 8);
const maxKundenUmsatz = Math.max(1, ...besteKunden.map((k) => k.summe));
letzteKunden = besteKunden;
const kundenEmpty = document.getElementById("verwaltung-kunden-empty");
const kundenList = document.getElementById("verwaltung-kunden-list");
const kundenGrid = document.getElementById("verwaltung-kunden-grid");
if (besteKunden.length === 0) {
kundenList.innerHTML = "";
kundenGrid.innerHTML = "";
kundenEmpty.style.display = "";
} else {
kundenEmpty.style.display = "none";
kundenList.innerHTML = besteKunden.map((k) => `
<div class="verwaltung-bar-row">
<div class="verwaltung-bar-head"><span>👑 ${escapeHtml(k.name)} <span class="small">(${escapeHtml(k.email)})</span></span><span class="small">${formatPrice(k.summe, lang)} · ${k.anzahl}× bestellt</span></div>
<div class="verwaltung-bar-track"><div class="verwaltung-bar-fill verwaltung-bar-fill-kunden" style="width:${Math.round((k.summe / maxKundenUmsatz) * 100)}%"></div></div>
</div>
kundenGrid.innerHTML = besteKunden.map((k) => `
<button type="button" class="card verwaltung-stat-tile" data-kunden-tile="${escapeHtml(k.email)}">
<span class="verwaltung-stat-icon" aria-hidden="true">👑</span>
<span class="verwaltung-stat-value">${formatPrice(k.summe, lang)}</span>
<span class="verwaltung-stat-label">${escapeHtml(k.name)} · ${k.anzahl}×</span>
</button>
`).join("");
}
wendeFilterAn();
}
// ── Detail-Panels für die Auswertungs-Kacheln: erscheinen genau unter der jeweiligen Kachel-
// Reihe (siehe .verwaltung-analyse-detail), sobald man draufklickt — mit der VOLLSTÄNDIGEN
// Liste (nicht mehr nur den Top 4/8 wie vorher direkt auf der Kachel). ──
function zeigeKatDetail(slug) {
const k = letzteKategorien.find((x) => x.slug === slug);
const panel = document.getElementById("verwaltung-kat-detail");
if (!k) { panel.setAttribute("hidden", ""); return; }
panel.innerHTML = `
<div class="verwaltung-analyse-detail-head">
<h3>${k.icon} ${escapeHtml(k.name)} — ${k.produkte.length} Produkt${k.produkte.length === 1 ? "" : "e"}</h3>
<button type="button" class="verwaltung-analyse-close" data-close-detail="kat" aria-label="Schließen">✕</button>
</div>
<div class="verwaltung-bar-list">
${k.produkte.map((p) => `
<div class="verwaltung-bar-row">
<div class="verwaltung-bar-head"><span>${escapeHtml(p.name)}</span><span class="small">${p.menge}× verkauft</span></div>
<div class="verwaltung-bar-track"><div class="verwaltung-bar-fill" style="width:${p.prozent}%"></div></div>
</div>
`).join("")}
</div>
`;
panel.removeAttribute("hidden");
document.querySelectorAll("[data-kat-tile]").forEach((t) => t.classList.toggle("is-active", t.dataset.katTile === slug));
}
function zeigeLandDetail(land) {
const l = letzteLaender.find((x) => x.land === land);
const panel = document.getElementById("verwaltung-land-detail");
if (!l) { panel.setAttribute("hidden", ""); return; }
const bestellungen = letzteAktive.filter((b) => b.land === land).sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
panel.innerHTML = `
<div class="verwaltung-analyse-detail-head">
<h3>${l.flagge} ${escapeHtml(l.name)} — ${bestellungen.length} Bestellung${bestellungen.length === 1 ? "" : "en"}</h3>
<button type="button" class="verwaltung-analyse-close" data-close-detail="land" aria-label="Schließen">✕</button>
</div>
${bestellungen.map((b) => `
<div class="verwaltung-analyse-order-row">
<span>${escapeHtml(b.bestellnummer || `#${b.id}`)} · ${escapeHtml(b.kunde_name)}</span>
<span class="small">${new Date(b.created_at).toLocaleDateString("de-DE")} · ${formatPrice(Number(b.summe), lang)}</span>
</div>
`).join("")}
`;
panel.removeAttribute("hidden");
document.querySelectorAll("[data-land-tile]").forEach((t) => t.classList.toggle("is-active", t.dataset.landTile === land));
}
function zeigeKundeDetail(email) {
const k = letzteKunden.find((x) => x.email === email);
const panel = document.getElementById("verwaltung-kunden-detail");
if (!k) { panel.setAttribute("hidden", ""); return; }
const bestellungen = letzteAktive.filter((b) => b.kunde_email === email).sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
panel.innerHTML = `
<div class="verwaltung-analyse-detail-head">
<h3>👑 ${escapeHtml(k.name)} <span class="small">(${escapeHtml(email)})</span> — ${bestellungen.length} Bestellung${bestellungen.length === 1 ? "" : "en"}</h3>
<button type="button" class="verwaltung-analyse-close" data-close-detail="kunden" aria-label="Schließen">✕</button>
</div>
${bestellungen.map((b) => `
<div class="verwaltung-analyse-order-row">
<span>${escapeHtml(b.bestellnummer || `#${b.id}`)}</span>
<span class="small">${new Date(b.created_at).toLocaleDateString("de-DE")} · ${formatPrice(Number(b.summe), lang)}</span>
</div>
`).join("")}
`;
panel.removeAttribute("hidden");
document.querySelectorAll("[data-kunden-tile]").forEach((t) => t.classList.toggle("is-active", t.dataset.kundenTile === email));
}
function escapeHtml(str) {
return String(str ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
}
@@ -716,6 +803,50 @@ const lang: Locale = "de";
zeitraumSelect.value = zeitraumBtn.dataset.zeitraum;
wendeFilterAn();
document.getElementById("verwaltung-order-list")?.scrollIntoView({ behavior: "smooth", block: "start" });
return;
}
// ── Auswertungs-Kacheln: Klick öffnet das Detail-Panel direkt darunter, nochmal draufklicken
// klappt es wieder ein. ──
const katTile = e.target.closest("[data-kat-tile]");
if (katTile) {
const schonOffen = katTile.classList.contains("is-active");
if (schonOffen) {
document.getElementById("verwaltung-kat-detail").setAttribute("hidden", "");
katTile.classList.remove("is-active");
} else {
zeigeKatDetail(katTile.dataset.katTile);
}
return;
}
const landTile = e.target.closest("[data-land-tile]");
if (landTile) {
const schonOffen = landTile.classList.contains("is-active");
if (schonOffen) {
document.getElementById("verwaltung-land-detail").setAttribute("hidden", "");
landTile.classList.remove("is-active");
} else {
zeigeLandDetail(landTile.dataset.landTile);
}
return;
}
const kundenTile = e.target.closest("[data-kunden-tile]");
if (kundenTile) {
const schonOffen = kundenTile.classList.contains("is-active");
if (schonOffen) {
document.getElementById("verwaltung-kunden-detail").setAttribute("hidden", "");
kundenTile.classList.remove("is-active");
} else {
zeigeKundeDetail(kundenTile.dataset.kundenTile);
}
return;
}
const closeDetailBtn = e.target.closest("[data-close-detail]");
if (closeDetailBtn) {
const gruppe = closeDetailBtn.dataset.closeDetail;
document.getElementById(`verwaltung-${gruppe}-detail`)?.setAttribute("hidden", "");
document.querySelectorAll(`[data-${gruppe}-tile]`).forEach((t) => t.classList.remove("is-active"));
return;
}
});
+49
View File
@@ -3034,6 +3034,55 @@ a:focus-visible, button:focus-visible {
.verwaltung-auswertung-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 1rem; margin-top: 1.2rem; }
.verwaltung-kat-card { display: flex; flex-direction: column; gap: 0.9rem; }
.verwaltung-kat-card h3 { margin: 0; }
/* Auswertungs-Kacheln (Bestseller je Kategorie / Herkunft / Stammkundschaft) — sehen jetzt
bewusst genauso aus wie die Bestellstatus-Kacheln oben (.verwaltung-stat-tile), auf Wunsch
("sollen aussehen wie auf screen2"), statt eigener Bar-Listen direkt in der Kiste. Klick auf
eine Kachel öffnet direkt darunter (".verwaltung-analyse-detail") die vollständige Liste. */
.verwaltung-analyse-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 0.9rem; margin: 1.2rem 0; }
.verwaltung-stat-tile.is-active {
border-color: color-mix(in srgb, var(--box-color, var(--c-accent)) 70%, transparent);
background: color-mix(in srgb, var(--box-color, var(--c-accent)) 14%, var(--c-anthracite-light));
}
.verwaltung-analyse-detail {
margin-top: 0.4rem;
padding: 1.2rem 1.3rem;
border-radius: var(--radius-m);
border: 1.5px solid color-mix(in srgb, var(--box-color, var(--c-accent)) 40%, transparent);
background: color-mix(in srgb, var(--box-color, var(--c-accent)) 6%, var(--c-anthracite));
animation: verwaltung-analyse-detail-in 0.2s var(--ease);
}
.verwaltung-analyse-detail[hidden] { display: none; }
@keyframes verwaltung-analyse-detail-in {
from { opacity: 0; transform: translateY(-6px); }
to { opacity: 1; transform: translateY(0); }
}
.verwaltung-analyse-detail-head { display: flex; align-items: center; justify-content: space-between; gap: 1rem; margin-bottom: 0.9rem; }
.verwaltung-analyse-detail-head h3 { margin: 0; font-size: 1.05rem; }
.verwaltung-analyse-close {
flex-shrink: 0;
background: transparent;
border: none;
color: var(--c-text-muted);
font-size: 1.2rem;
line-height: 1;
padding: 0.2rem 0.4rem;
cursor: pointer;
transition: color 0.15s var(--ease);
}
.verwaltung-analyse-close:hover { color: var(--c-text); }
.verwaltung-analyse-order-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
padding: 0.6rem 0;
border-bottom: 1px solid rgba(244, 241, 247, 0.08);
font-size: 0.9rem;
}
.verwaltung-analyse-order-row:last-child { border-bottom: none; }
@media (prefers-reduced-motion: reduce) { .verwaltung-analyse-detail { animation: none; } }
.verwaltung-bar-list { display: flex; flex-direction: column; gap: 0.8rem; }
.verwaltung-bar-head { display: flex; justify-content: space-between; gap: 1rem; font-size: 0.9rem; margin-bottom: 0.3rem; }
.verwaltung-bar-track { height: 9px; border-radius: 999px; background: rgba(244, 241, 247, 0.08); overflow: hidden; }