Verwaltungs-Hauptseite ultra-speziell für VanVan: Begrüßung, Sparkline, Konfetti, Stepper, Logo-Hintergrund

Auf ausdrücklichen Wunsch NUR die Haupt-Bestellverwaltungsseite (nicht die Zugangscode-Seite,
nicht deren Formular-Logik) grundlegend aufgewertet:

- Rollenbasierte, tageszeitabhängige Begrüßung ("Guten Morgen, VanVan!") aus dem vorhandenen
  Vorschau-Rollen-Cookie
- Van's-Logo als großes, fix positioniertes, warm eingefärbtes Wasserzeichen im gesamten
  Seitenhintergrund (zwei gegenläufig rotierende Ebenen, derselbe CSS-Trick wie auf der
  Zugangscode-Seite, aber eigenständig für diese scrollbare Seite nachgebaut)
- Handgebaute SVG-Sparkline (Umsatztrend der letzten 14 Tage) + Wochenvergleich-Badge
- Sanft hochzählende Kennzahlen-Kacheln statt abruptem Zahlensprung
- Sichtbarer Status-Fortschrittsbalken pro Bestellkarte + Ein-Klick-"nächster Schritt"-Knopf
  neben dem bisherigen Dropdown
- Konfetti + Erfolgs-Banner bei runden Bestell-Meilensteinen (10er/25er/50er-Schritte)
- Tab-Titel zeigt die Anzahl offener Bestellungen, auch wenn der Tab im Hintergrund ist
- Optionaler Ton (Web-Audio-Glöckchen, keine Audio-Datei) und Desktop-Benachrichtigungen bei
  neuen Bestellungen, beides einzeln togglebar und in localStorage gemerkt

Alles handgebaut ohne externe Bibliotheken/Kosten, respektiert prefers-reduced-motion. Lokal mit
wrangler pages dev + echter D1-Bindung end-to-end getestet (Login, Bestell-API, Status-PATCH,
alle neuen UI-Elemente im Seiten-HTML verifiziert).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
qcigano
2026-08-04 14:15:09 +02:00
co-authored by Claude Sonnet 5
parent aeb76a277d
commit e37d12b4c7
2 changed files with 544 additions and 22 deletions
+381 -18
View File
@@ -29,6 +29,15 @@ const lang: Locale = "de";
path="/verwaltung/"
noindex={true}
>
{/* Van's-Logo als großes, sehr dezentes Wasserzeichen im GESAMTEN Seitenhintergrund — fix
positioniert (bleibt beim Scrollen stehen), zwei gegenläufig rotierende Ebenen (gold/violett
eingefärbt), exakt dieselbe CSS-Technik wie auf der Zugangscode-Seite (invert()+sepia()+
mix-blend-mode:screen), nur als eigenständige Datei nachgebaut, da diese Seite anders als
/verwaltung-code.html ein ganz normaler Astro-Layout-Seiteninhalt mit Scroll ist. Auf
ausdrücklichen Wunsch: die ganze Seite soll sich für VanVan "ultra speziell" anfühlen. */}
<div class="verwaltung-logo-bg verwaltung-logo-bg-1" aria-hidden="true"></div>
<div class="verwaltung-logo-bg verwaltung-logo-bg-2" aria-hidden="true"></div>
<section class="section-tight">
<div class="container">
<p class="todo-note">
@@ -40,8 +49,17 @@ const lang: Locale = "de";
<span class="verwaltung-hero-sparkle verwaltung-hero-sparkle-1" aria-hidden="true">✨</span>
<span class="verwaltung-hero-sparkle verwaltung-hero-sparkle-2" aria-hidden="true">✨</span>
<span class="eyebrow">Verwaltung</span>
{/* Rollenbasierte, tageszeitabhängige Begrüßung (liest das nicht-httpOnly Cookie
"vandiy_gate_role", dasselbe, das auch den Vorschau-Umschalter in Layout.astro
freischaltet) — auf ausdrücklichen Wunsch: diese Seite soll sich für VanVan "richtig
speziell" anfühlen, nicht wie eine trockene Admin-Oberfläche. */}
<p class="verwaltung-greeting" id="verwaltung-greeting"></p>
<h1>📊 Bestellverwaltung</h1>
<p class="lead">Behalte jederzeit den Überblick: wie viele Bestellungen offen, in Bearbeitung, versendet oder abgeschlossen sind — plus eine Auswertung, was sich am besten verkauft und woher eure Kundschaft kommt.</p>
<div class="verwaltung-hero-settings">
<button type="button" class="verwaltung-toggle-btn" id="verwaltung-sound-toggle" aria-pressed="false">🔈 Ton aus</button>
<button type="button" class="verwaltung-toggle-btn" id="verwaltung-notify-toggle" aria-pressed="false">🔕 Benachrichtigungen aus</button>
</div>
</div>
</div>
</section>
@@ -151,6 +169,16 @@ const lang: Locale = "de";
<span class="verwaltung-stat-label">Brauchen Aufmerksamkeit</span>
</div>
</div>
{/* Handgebautes SVG-Sparkline-Chart (kein npm-Paket nötig, komplett lokal) — zeigt den
Umsatzverlauf der letzten 14 Tage auf einen Blick, plus Prozent-Vergleich zur
Vorwoche direkt daneben. */}
<div class="verwaltung-sparkline-wrap">
<div class="verwaltung-sparkline-head">
<span class="small">📉 Umsatztrend — letzte 14 Tage</span>
<span class="verwaltung-trend" id="verwaltung-week-trend"></span>
</div>
<svg id="verwaltung-sparkline" viewBox="0 0 280 60" preserveAspectRatio="none" aria-hidden="true"></svg>
</div>
</div>
</div>
@@ -291,6 +319,12 @@ const lang: Locale = "de";
</section>
</div>
{/* Meilenstein-Feier (z.B. "Bestellung Nr. 50!"): Konfetti-Overlay + Erfolgs-Banner, siehe
pruefeMeilenstein() im Skript unten. Bewusst außerhalb von #verwaltung-content, damit sie
unabhängig vom Lade-/Fehlerzustand jederzeit sichtbar sein können. */}
<div id="verwaltung-confetti" aria-hidden="true"></div>
<div class="verwaltung-milestone-toast" id="verwaltung-milestone-toast" role="status" aria-live="polite" hidden></div>
</Layout>
<script type="module">
@@ -326,6 +360,173 @@ const lang: Locale = "de";
const ALTER_SCHWELLE_TAGE = 3;
const MS_PRO_TAG = 24 * 60 * 60 * 1000;
// 04.08.2026, dritte Ausbaustufe auf ausdrücklichen Wunsch ("richtig speziell für VanVan
// machen, die Erfahrung um 10000% verbessern"): persönliche Begrüßung, Umsatz-Sparkline +
// Wochenvergleich, animierte Kennzahlen, Status-Fortschrittsbalken mit Ein-Klick-Weiterschalten,
// Konfetti bei runden Bestellzahlen-Meilensteinen, Tab-Titel-Badge für offene Bestellungen,
// sowie optionaler Ton/Desktop-Benachrichtigung bei neuen Bestellungen — alles handgebaut, ohne
// externe Bibliotheken oder Kosten (SVG-Sparkline, Web-Audio-Ton statt Audio-Datei, Notification-
// API direkt vom Browser).
const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const urspruenglicherTitel = document.title;
const SOUND_KEY = "vandiy_verwaltung_sound";
const NOTIFY_KEY = "vandiy_verwaltung_notify";
const MILESTONE_KEY = "vandiy_verwaltung_last_milestone";
// Nächster logischer Status beim Ein-Klick-Weiterschalten-Knopf auf jeder Bestellkarte —
// deckt sich mit der Reihenfolge aus bucket() oben, nur feiner (bearbeitung/versandVorbereitet
// sind im Bucket zusammengefasst, hier aber zwei echte Zwischenschritte).
const STATUS_NEXT = {
zahlungOffen: "bezahlt",
bezahlt: "bearbeitung",
bearbeitung: "versandVorbereitet",
versandVorbereitet: "versendet",
versendet: "abgeschlossen",
abgeschlossen: null,
storniert: null,
};
function leseRolle() {
const treffer = document.cookie.match(/(?:^|;\s*)vandiy_gate_role=([^;]+)/);
return treffer ? decodeURIComponent(treffer[1]) : null;
}
function zeitgruss() {
const stunde = new Date().getHours();
if (stunde < 5) return { text: "Na, noch wach", icon: "🌙" };
if (stunde < 11) return { text: "Guten Morgen", icon: "☀️" };
if (stunde < 14) return { text: "Guten Tag", icon: "🌤️" };
if (stunde < 18) return { text: "Schönen Nachmittag", icon: "🌼" };
if (stunde < 22) return { text: "Guten Abend", icon: "🌇" };
return { text: "Na, noch wach", icon: "🌙" };
}
function renderGreeting() {
const rolle = leseRolle();
const name = rolle === "vanvan" ? "VanVan" : rolle === "qciga" ? "qciga" : null;
const { text, icon } = zeitgruss();
const el = document.getElementById("verwaltung-greeting");
if (el) el.textContent = name ? `${icon} ${text}, ${name}!` : `${icon} ${text}!`;
}
renderGreeting();
// ── Ton bei neuen Bestellungen: kein Audio-File nötig, ein kurzes, warmes Zwei-Ton-Glöckchen
// wird direkt per Web-Audio-API synthetisiert. ──
function toneEnabled() { return localStorage.getItem(SOUND_KEY) === "1"; }
function notifyEnabled() { return localStorage.getItem(NOTIFY_KEY) === "1"; }
function spieleGlockenton() {
try {
const AudioCtx = window.AudioContext || window.webkitAudioContext;
if (!AudioCtx) return;
const ctx = new AudioCtx();
const jetzt = ctx.currentTime;
[880, 1320].forEach((freq, i) => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = "sine";
osc.frequency.value = freq;
const start = jetzt + i * 0.1;
gain.gain.setValueAtTime(0.0001, start);
gain.gain.exponentialRampToValueAtTime(0.16, start + 0.02);
gain.gain.exponentialRampToValueAtTime(0.0001, start + 0.55);
osc.connect(gain).connect(ctx.destination);
osc.start(start);
osc.stop(start + 0.6);
});
setTimeout(() => ctx.close(), 900);
} catch {
// Browser ohne Web-Audio-Unterstützung — kein Problem, der Ton ist nur ein Bonus.
}
}
const soundToggleBtn = document.getElementById("verwaltung-sound-toggle");
const notifyToggleBtn = document.getElementById("verwaltung-notify-toggle");
function aktualisiereToggleUI() {
const sound = toneEnabled();
if (soundToggleBtn) {
soundToggleBtn.setAttribute("aria-pressed", String(sound));
soundToggleBtn.textContent = sound ? "🔊 Ton an" : "🔈 Ton aus";
soundToggleBtn.classList.toggle("is-active", sound);
}
const notify = notifyEnabled() && typeof Notification !== "undefined" && Notification.permission === "granted";
if (notifyToggleBtn) {
notifyToggleBtn.setAttribute("aria-pressed", String(notify));
notifyToggleBtn.textContent = notify ? "🔔 Benachrichtigungen an" : "🔕 Benachrichtigungen aus";
notifyToggleBtn.classList.toggle("is-active", notify);
}
}
soundToggleBtn?.addEventListener("click", () => {
localStorage.setItem(SOUND_KEY, toneEnabled() ? "0" : "1");
aktualisiereToggleUI();
if (toneEnabled()) spieleGlockenton();
});
notifyToggleBtn?.addEventListener("click", async () => {
if (typeof Notification === "undefined") {
alert("Dein Browser unterstützt leider keine Desktop-Benachrichtigungen.");
return;
}
if (Notification.permission === "denied") {
alert("Benachrichtigungen sind für diese Seite im Browser blockiert — bitte in den Website-Einstellungen des Browsers wieder erlauben.");
return;
}
if (Notification.permission !== "granted") {
const ergebnis = await Notification.requestPermission();
if (ergebnis !== "granted") { aktualisiereToggleUI(); return; }
}
localStorage.setItem(NOTIFY_KEY, notifyEnabled() ? "0" : "1");
aktualisiereToggleUI();
});
aktualisiereToggleUI();
// ── Meilenstein-Feier: Konfetti + Banner, sobald eine runde Bestell-Gesamtzahl erreicht wird
// (10er-Schritte bis 100, 25er bis 500, danach 50er). Zählt ALLE Bestellungen (auch
// stornierte/noch unbezahlte) — es geht um "die wievielte Bestellung ist reingekommen",
// nicht um Umsatz. Feuert bewusst NICHT beim allerersten Laden dieser neuen Funktion (sonst
// würde ein längst bestehender Bestand sofort ein Feuerwerk auslösen) — erst ab der
// nächsten tatsächlich neu überschrittenen Schwelle. ──
function istMeilenstein(n) {
if (n < 10) return false;
if (n <= 100) return n % 10 === 0;
if (n <= 500) return n % 25 === 0;
return n % 50 === 0;
}
function starteKonfetti() {
const container = document.getElementById("verwaltung-confetti");
if (!container) return;
const FARBEN = ["#e3b23c", "#9b7fd6", "#5fd3ec", "#e7879a", "#6ecb8f"];
for (let i = 0; i < 60; i++) {
const stueck = document.createElement("span");
stueck.className = "verwaltung-confetti-piece";
stueck.style.left = `${Math.random() * 100}vw`;
stueck.style.background = FARBEN[Math.floor(Math.random() * FARBEN.length)];
stueck.style.animationDuration = `${2.2 + Math.random() * 1.6}s`;
stueck.style.animationDelay = `${Math.random() * 0.5}s`;
stueck.style.setProperty("--drift", `${Math.random() * 160 - 80}px`);
stueck.style.setProperty("--rot", `${Math.random() * 720 - 360}deg`);
stueck.addEventListener("animationend", () => stueck.remove());
container.appendChild(stueck);
}
}
function zeigeMeilenstein(n) {
const toast = document.getElementById("verwaltung-milestone-toast");
if (!toast) return;
toast.innerHTML = `🎉 <strong>Bestellung Nr. ${n}!</strong> Ihr seid großartig — weiter so! 🧶`;
toast.hidden = false;
requestAnimationFrame(() => toast.classList.add("is-visible"));
if (!prefersReducedMotion) starteKonfetti();
setTimeout(() => {
toast.classList.remove("is-visible");
setTimeout(() => { toast.hidden = true; }, 500);
}, 5000);
}
function pruefeMeilenstein(gesamtAnzahl) {
const letzter = Number(localStorage.getItem(MILESTONE_KEY) || 0);
localStorage.setItem(MILESTONE_KEY, String(gesamtAnzahl));
if (gesamtAnzahl <= letzter || letzter === 0) return;
let erreicht = null;
for (let n = letzter + 1; n <= gesamtAnzahl; n++) {
if (istMeilenstein(n)) erreicht = n;
}
if (erreicht) zeigeMeilenstein(erreicht);
}
function bucket(status) {
if (status === "storniert") return "storniert";
if (status === "zahlungOffen") return "zahlungOffen";
@@ -352,6 +553,9 @@ const lang: Locale = "de";
let letzteLaender = [];
let letzteKunden = [];
let letzteAktive = [];
// Merkt sich den zuletzt ANGEZEIGTEN Wert jeder animierten Kennzahl, damit setNumber() beim
// nächsten render() weiß, von wo aus hochgezählt werden soll (statt immer wieder ab 0).
const vorherigeZahlen = {};
async function ladeUndRendere() {
loadingEl.style.display = "";
@@ -367,6 +571,7 @@ const lang: Locale = "de";
bekannteIds = new Set(alleBestellungen.map((b) => b.id));
erstesLaden = false;
render();
pruefeMeilenstein(alleBestellungen.length);
loadingEl.style.display = "none";
contentEl.style.display = "";
} catch (err) {
@@ -390,6 +595,15 @@ const lang: Locale = "de";
const banner = document.getElementById("verwaltung-new-banner");
document.getElementById("verwaltung-new-count").textContent = String(neue.length);
banner.style.display = "flex";
if (toneEnabled()) spieleGlockenton();
if (notifyEnabled() && typeof Notification !== "undefined" && Notification.permission === "granted") {
const benachrichtigung = new Notification(
neue.length > 1 ? `🧶 ${neue.length} neue Bestellungen` : "🧶 Neue Bestellung",
{ body: neue.slice(0, 3).map((b) => `${b.bestellnummer || `#${b.id}`} · ${b.kunde_name}`).join("\n"), icon: "/favicon-32x32.png" }
);
benachrichtigung.onclick = () => { window.focus(); benachrichtigung.close(); };
}
}
} catch {
// Stiller Fehlschlag — der nächste reguläre Refresh/Retry fängt es wieder ein, kein Grund
@@ -403,6 +617,31 @@ const lang: Locale = "de";
if (el) el.textContent = text;
}
// ── Zahlen zählen sanft hoch statt abrupt zu springen — macht die Kennzahlen-Kacheln beim
// Laden/Aktualisieren spürbar lebendiger. Reines requestAnimationFrame, kein Paket nötig,
// respektiert prefers-reduced-motion (dann sofortiger Sprung wie vorher). ──
function animateNumber(el, von, bis, format) {
const formatFn = format || ((n) => String(Math.round(n)));
if (von === bis || prefersReducedMotion) { el.textContent = formatFn(bis); return; }
const start = performance.now();
const dauer = 700;
function tick(jetzt) {
const t = Math.min(1, (jetzt - start) / dauer);
const eased = 1 - Math.pow(1 - t, 3);
el.textContent = formatFn(von + (bis - von) * eased);
if (t < 1) requestAnimationFrame(tick);
else el.textContent = formatFn(bis);
}
requestAnimationFrame(tick);
}
function setNumber(id, zahl, format) {
const el = document.getElementById(id);
if (!el) return;
const von = vorherigeZahlen[id] ?? 0;
vorherigeZahlen[id] = zahl;
animateNumber(el, von, zahl, format);
}
function sortiere(liste, modus) {
const kopie = [...liste];
switch (modus) {
@@ -413,11 +652,67 @@ const lang: Locale = "de";
}
}
// ── Umsatz-Sparkline (letzte 14 Tage) + Wochenvergleich: reines handgebautes SVG, kein
// Chart-Paket nötig. "aktive" = weder storniert noch (bei Überweisung) unbezahlt. ──
function renderSparkline(aktive) {
const svg = document.getElementById("verwaltung-sparkline");
const trendEl = document.getElementById("verwaltung-week-trend");
if (!svg || !trendEl) return;
const heute = new Date();
heute.setHours(0, 0, 0, 0);
const tage = Array.from({ length: 14 }, (_, i) => {
const tag = new Date(heute);
tag.setDate(tag.getDate() - (13 - i));
return tag.getTime();
});
const werte = tage.map((start) => {
const ende = start + MS_PRO_TAG;
return aktive
.filter((b) => { const t = new Date(b.created_at).getTime(); return t >= start && t < ende; })
.reduce((s, b) => s + Number(b.summe), 0);
});
const max = Math.max(1, ...werte);
const w = 280, h = 60, pad = 4;
const stepX = (w - pad * 2) / (werte.length - 1);
const punkte = werte.map((v, i) => [pad + i * stepX, h - pad - (v / max) * (h - pad * 2)]);
const linePath = punkte.map((p, i) => `${i === 0 ? "M" : "L"}${p[0].toFixed(1)},${p[1].toFixed(1)}`).join(" ");
const areaPath = `${linePath} L${punkte[punkte.length - 1][0].toFixed(1)},${h - pad} L${punkte[0][0].toFixed(1)},${h - pad} Z`;
svg.innerHTML = `
<defs>
<linearGradient id="verwaltung-sparkline-grad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" style="stop-color:var(--c-accent); stop-opacity:0.45" />
<stop offset="100%" style="stop-color:var(--c-accent); stop-opacity:0" />
</linearGradient>
</defs>
<path d="${areaPath}" fill="url(#verwaltung-sparkline-grad)" stroke="none"></path>
<path d="${linePath}" fill="none" stroke="var(--c-accent)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
`;
const letzte7 = werte.slice(7).reduce((s, v) => s + v, 0);
const vorherige7 = werte.slice(0, 7).reduce((s, v) => s + v, 0);
if (letzte7 === 0 && vorherige7 === 0) {
trendEl.textContent = "";
trendEl.className = "verwaltung-trend";
} else if (vorherige7 === 0) {
trendEl.textContent = "🚀 neu diese Woche";
trendEl.className = "verwaltung-trend verwaltung-trend-up";
} else {
const delta = Math.round(((letzte7 - vorherige7) / vorherige7) * 100);
const auf = delta >= 0;
trendEl.textContent = `${auf ? "▲" : "▼"} ${Math.abs(delta)}% ggü. Vorwoche`;
trendEl.className = `verwaltung-trend ${auf ? "verwaltung-trend-up" : "verwaltung-trend-down"}`;
}
}
function render() {
// "aktive" = zählt für Umsatz/Auswertung: weder storniert noch (bei Überweisung) noch gar
// nicht bezahlt — sonst würde Geld gezeigt, das VanVan noch gar nicht hat.
const aktive = alleBestellungen.filter((b) => b.status !== "storniert" && b.status !== "zahlungOffen");
letzteAktive = aktive;
renderSparkline(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
@@ -430,11 +725,17 @@ const lang: Locale = "de";
// ── Buckets für die Stat-Kacheln + Filterleiste ──
const buckets = { zahlungOffen: 0, offen: 0, inBearbeitung: 0, versendet: 0, abgeschlossen: 0, storniert: 0 };
alleBestellungen.forEach((b) => { buckets[bucket(b.status)]++; });
setText("stat-zahlungOffen", String(buckets.zahlungOffen));
setText("stat-offen", String(buckets.offen));
setText("stat-inBearbeitung", String(buckets.inBearbeitung));
setText("stat-versendet", String(buckets.versendet));
setText("stat-abgeschlossen", String(buckets.abgeschlossen));
setNumber("stat-zahlungOffen", buckets.zahlungOffen);
setNumber("stat-offen", buckets.offen);
setNumber("stat-inBearbeitung", buckets.inBearbeitung);
setNumber("stat-versendet", buckets.versendet);
setNumber("stat-abgeschlossen", buckets.abgeschlossen);
// Tab-Titel zeigt auf einen Blick, wie viel es zu tun gibt — auch wenn der Tab gerade nicht
// aktiv ist (z.B. weil man in einem anderen Fenster arbeitet).
const dringend = buckets.zahlungOffen + buckets.offen;
document.title = dringend > 0 ? `🔴 ${dringend} · ${urspruenglicherTitel}` : urspruenglicherTitel;
setText("filter-count-alle", String(alleBestellungen.length));
setText("filter-count-zahlungOffen", String(buckets.zahlungOffen));
setText("filter-count-offen", String(buckets.offen));
@@ -447,9 +748,14 @@ const lang: Locale = "de";
// zählen bewusst NICHT mit — sonst würde der Umsatz Geld zeigen, das noch gar nicht da
// ist. ──
const gesamtumsatz = aktive.reduce((s, b) => s + Number(b.summe), 0);
setText("stat-umsatz", formatPrice(gesamtumsatz, lang));
setNumber("stat-umsatz", gesamtumsatz, (n) => formatPrice(n, lang));
setText("stat-umsatz-label", `Gesamtumsatz (${aktive.length} Bestellung${aktive.length === 1 ? "" : "en"})`);
setText("stat-avg", aktive.length > 0 ? formatPrice(gesamtumsatz / aktive.length, lang) : "");
if (aktive.length > 0) {
setNumber("stat-avg", gesamtumsatz / aktive.length, (n) => formatPrice(n, lang));
} else {
setText("stat-avg", "");
vorherigeZahlen["stat-avg"] = 0;
}
// ── Heute / letzte 7 Tage / diesen Monat + "braucht Aufmerksamkeit" ──
const jetzt = Date.now();
@@ -462,9 +768,9 @@ const lang: Locale = "de";
const monatsStart = new Date(heuteDatum.getFullYear(), heuteDatum.getMonth(), 1).getTime();
const monatCount = aktive.filter((b) => new Date(b.created_at).getTime() >= monatsStart).length;
const monatUmsatz = aktive.filter((b) => new Date(b.created_at).getTime() >= monatsStart).reduce((s, b) => s + Number(b.summe), 0);
setText("stat-heute", String(heuteCount));
setText("stat-7tage", String(siebenTageCount));
setText("stat-monat", String(monatCount));
setNumber("stat-heute", heuteCount);
setNumber("stat-7tage", siebenTageCount);
setNumber("stat-monat", monatCount);
setText(
"stat-monat-label",
`Diesen Monat${monatCount > 0 ? ` (${formatPrice(monatUmsatz, lang)})` : ""}`
@@ -480,10 +786,11 @@ const lang: Locale = "de";
);
const attentionTile = document.getElementById("stat-attention-tile");
if (attentionIds.size > 0) {
setText("stat-attention", String(attentionIds.size));
setNumber("stat-attention", attentionIds.size);
attentionTile.style.display = "";
} else {
attentionTile.style.display = "none";
vorherigeZahlen["stat-attention"] = 0;
}
// ── Stammkundinnen: E-Mail kommt in mehr als einer Bestellung vor. ──
@@ -525,6 +832,7 @@ const lang: Locale = "de";
<span class="badge order-status order-status-${b.status}">${STATUS_LABEL[b.status] || b.status}</span>
</div>
<ul class="order-items">${artikelHtml}</ul>
${stepperHtml(b.status)}
<div class="verwaltung-copy-row">
<button type="button" class="btn btn-outline btn-xs verwaltung-copy-btn" data-copy="${escapeHtml(b.kunde_email)}">📧 E-Mail kopieren</button>
<button type="button" class="btn btn-outline btn-xs verwaltung-copy-btn" data-copy="${escapeHtml(`${b.kunde_name}\n${adresse}`)}">📍 Adresse kopieren</button>
@@ -541,6 +849,7 @@ const lang: Locale = "de";
<select class="verwaltung-status-select" data-order-id="${b.id}">
${Object.entries(STATUS_LABEL).map(([val, label]) => `<option value="${val}" ${val === b.status ? "selected" : ""}>${label}</option>`).join("")}
</select>
${STATUS_NEXT[b.status] ? `<button type="button" class="btn btn-primary btn-xs verwaltung-advance-btn" data-order-id="${b.id}" data-next-status="${STATUS_NEXT[b.status]}">→ ${STATUS_LABEL[STATUS_NEXT[b.status]]}</button>` : ""}
<span class="small verwaltung-status-saved" data-order-id="${b.id}" style="display:none;">✅ gespeichert</span>
</div>
</div>
@@ -551,6 +860,9 @@ const lang: Locale = "de";
listEl.querySelectorAll(".verwaltung-status-select").forEach((select) => {
select.addEventListener("change", () => statusAendern(select));
});
listEl.querySelectorAll(".verwaltung-advance-btn").forEach((btn) => {
btn.addEventListener("click", () => statusVorwaerts(btn));
});
listEl.querySelectorAll(".verwaltung-copy-btn").forEach((btn) => {
btn.addEventListener("click", () => kopieren(btn));
});
@@ -742,15 +1054,43 @@ const lang: Locale = "de";
document.querySelectorAll("[data-kunden-tile]").forEach((t) => t.classList.toggle("is-active", t.dataset.kundenTile === email));
}
// ── Visueller Status-Fortschrittsbalken pro Bestellkarte — auf einen Blick sehen, wo eine
// Bestellung im Ablauf steht, statt es aus einem Dropdown-Text ablesen zu müssen. Nutzt
// dieselben vier Stufen wie bucket()/die Filterleiste oben, damit beides zusammenpasst.
// "zahlungOffen" und "storniert" fallen aus dem linearen Ablauf raus und bekommen einen
// eigenen Hinweis statt der Punkte-Reihe. ──
const STEPPER_STUFEN = [
{ key: "offen", label: "Offen", icon: "🆕" },
{ key: "inBearbeitung", label: "Bearbeitung", icon: "⚙️" },
{ key: "versendet", label: "Versendet", icon: "🚚" },
{ key: "abgeschlossen", label: "Fertig", icon: "✅" },
];
function stepperHtml(status) {
if (status === "storniert") {
return `<div class="verwaltung-stepper verwaltung-stepper-storniert">🚫 Storniert</div>`;
}
if (status === "zahlungOffen") {
return `<div class="verwaltung-stepper verwaltung-stepper-pending">⏳ Wartet auf Zahlungseingang</div>`;
}
const aktuelleStufe = bucket(status);
const idx = STEPPER_STUFEN.findIndex((s) => s.key === aktuelleStufe);
return `<div class="verwaltung-stepper">
${STEPPER_STUFEN.map((s, i) => `
<span class="verwaltung-stepper-step ${i < idx ? "is-done" : i === idx ? "is-current" : ""}">
<span class="verwaltung-stepper-dot" aria-hidden="true">${i < idx ? "✓" : s.icon}</span>
<span class="verwaltung-stepper-label">${s.label}</span>
</span>${i < STEPPER_STUFEN.length - 1 ? `<span class="verwaltung-stepper-line ${i < idx ? "is-done" : ""}" aria-hidden="true"></span>` : ""}
`).join("")}
</div>`;
}
function escapeHtml(str) {
return String(str ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
}
async function statusAendern(select) {
const orderId = select.dataset.orderId;
const neuerStatus = select.value;
select.disabled = true;
try {
// Gemeinsamer Kern für BEIDE Wege, den Status zu ändern (Dropdown ODER der neue "→ nächster
// Schritt"-Schnellknopf) — spart Doppelcode und garantiert, dass beide exakt gleich speichern.
async function speichereStatus(orderId, neuerStatus) {
const res = await fetch(`/api/verwaltung/orders/${orderId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
@@ -758,11 +1098,17 @@ const lang: Locale = "de";
});
const data = await res.json().catch(() => null);
if (!res.ok || !data?.ok) throw new Error(data?.error || "Speichern fehlgeschlagen.");
const b = alleBestellungen.find((o) => String(o.id) === String(orderId));
if (b) b.status = neuerStatus;
render();
}
async function statusAendern(select) {
const orderId = select.dataset.orderId;
const neuerStatus = select.value;
select.disabled = true;
try {
await speichereStatus(orderId, neuerStatus);
render();
const savedHint = document.querySelector(`.verwaltung-status-saved[data-order-id="${orderId}"]`);
if (savedHint) {
savedHint.style.display = "inline";
@@ -774,6 +1120,23 @@ const lang: Locale = "de";
}
}
// Ein-Klick-Weiterschalten zum nächsten logischen Status (siehe STATUS_NEXT oben) — direkt auf
// der Bestellkarte, ohne erst das Dropdown öffnen zu müssen. Das Dropdown bleibt für gezielte
// Sprünge (z.B. direkt auf "Storniert") weiterhin verfügbar.
async function statusVorwaerts(btn) {
const orderId = btn.dataset.orderId;
const neuerStatus = btn.dataset.nextStatus;
if (!neuerStatus) return;
btn.disabled = true;
try {
await speichereStatus(orderId, neuerStatus);
render();
} catch (err) {
alert("⚠️ Status konnte nicht gespeichert werden: " + (err instanceof Error ? err.message : String(err)));
btn.disabled = false;
}
}
async function trackingSpeichern(orderId) {
const input = document.querySelector(`.verwaltung-tracking-input[data-order-id="${orderId}"]`);
const saveBtn = document.querySelector(`.verwaltung-tracking-save[data-order-id="${orderId}"]`);
+159
View File
@@ -3163,6 +3163,48 @@ a:focus-visible, button:focus-visible {
/* Interne Bestellverwaltung (/verwaltung/) — Status-Kacheln, Filterleiste, Auswertungs-Balken. */
/* 04.08.2026: Van's-Logo als riesiges, sehr dezentes Wasserzeichen im GESAMTEN Seitenhintergrund
— fix positioniert (bleibt beim Scrollen an Ort und Stelle, läuft "unter" der Seite durch),
zwei gegenläufig rotierende Ebenen in Gold/Violett. Exakt derselbe Einfärbe-Trick wie auf der
Zugangscode-Seite (public/verwaltung-code.html): invert() dreht das schwarze Logo auf weißem
Grund um, mix-blend-mode:screen lässt den (jetzt schwarzen) ehemals-weißen Kreis komplett
verschwinden, sepia()+saturate()+hue-rotate() färbt die übrig bleibenden (jetzt weißen) Linien
warm ein. z-index -1, damit die Seite selbst (alle Boxen haben eigene, deckende Hintergründe)
ganz normal darüber liegt — sichtbar wird das Wasserzeichen nur in den Zwischenräumen. */
.verwaltung-logo-bg {
position: fixed;
top: 50%;
left: 50%;
background: url("/logo.png") center / contain no-repeat;
mix-blend-mode: screen;
pointer-events: none;
z-index: -1;
}
.verwaltung-logo-bg-1 {
width: min(1500px, 220vw);
height: min(1500px, 220vw);
filter: invert(1) sepia(1) saturate(6) hue-rotate(358deg) brightness(1.35);
opacity: 0.09;
animation: verwaltung-logo-spin-1 110s linear infinite;
}
.verwaltung-logo-bg-2 {
width: min(950px, 150vw);
height: min(950px, 150vw);
filter: invert(1) sepia(1) saturate(7) hue-rotate(233deg) brightness(1.15);
opacity: 0.08;
animation: verwaltung-logo-spin-2 75s linear infinite;
}
@keyframes verwaltung-logo-spin-1 {
from { transform: translate(-50%, -50%) rotate(0deg); }
to { transform: translate(-50%, -50%) rotate(360deg); }
}
@keyframes verwaltung-logo-spin-2 {
from { transform: translate(-50%, -50%) rotate(360deg); }
to { transform: translate(-50%, -50%) rotate(0deg); }
}
@media (prefers-reduced-motion: reduce) { .verwaltung-logo-bg-1, .verwaltung-logo-bg-2 { animation: none; } }
@media print { .verwaltung-logo-bg { display: none; } }
/* "Kisten" — jede zusammengehörige Gruppe (Bestellstatus, Kennzahlen, Bestellliste, Bestseller,
Herkunft, Stammkundschaft) bekommt ihre eigene, klar abgegrenzte Box mit eigener Akzentfarbe
(--box-color, pro Kiste per inline style gesetzt) statt lose auf der Seite zu stehen. Auf
@@ -3553,3 +3595,120 @@ a:focus-visible, button:focus-visible {
.verwaltung-order-foot { flex-wrap: wrap; }
.verwaltung-tracking-edit { display: flex; align-items: center; gap: 0.4rem; }
.verwaltung-tracking-input { width: 160px; padding: 0.4em 0.6em; font-size: 0.82rem; }
/* ─── 04.08.2026, dritte Ausbaustufe: "richtig speziell für VanVan" ───────────────────────────
Persönliche Begrüßung, Ton-/Benachrichtigungs-Schalter, Umsatz-Sparkline + Wochenvergleich,
Status-Fortschrittsbalken, Meilenstein-Konfetti — siehe src/pages/verwaltung/index.astro. */
.verwaltung-greeting {
margin: 0.9rem 0 0;
font-family: var(--font-head);
font-size: 1.15rem;
font-weight: 600;
color: var(--c-accent);
}
.verwaltung-hero-settings { display: flex; gap: 0.6rem; flex-wrap: wrap; margin-top: 1.2rem; position: relative; z-index: 1; }
.verwaltung-toggle-btn {
display: inline-flex;
align-items: center;
gap: 0.4em;
padding: 0.45em 0.9em;
border-radius: 999px;
border: 1px solid rgba(244, 241, 247, 0.18);
background: rgba(244, 241, 247, 0.05);
color: var(--c-text-muted);
font-size: 0.82rem;
font-weight: 600;
cursor: pointer;
transition: border-color 0.2s var(--ease), color 0.2s var(--ease), background 0.2s var(--ease);
}
.verwaltung-toggle-btn:hover { border-color: var(--c-accent); color: var(--c-text); }
.verwaltung-toggle-btn.is-active {
border-color: color-mix(in srgb, var(--c-accent) 55%, transparent);
background: color-mix(in srgb, var(--c-accent) 16%, transparent);
color: var(--c-accent);
}
/* Sparkline: eigenes SVG-Chart, komplett handgebaut (siehe renderSparkline() im Skript). */
.verwaltung-sparkline-wrap { margin-top: 1.2rem; padding-top: 1rem; border-top: 1px solid rgba(244, 241, 247, 0.08); }
.verwaltung-sparkline-head { display: flex; align-items: center; justify-content: space-between; gap: 0.8rem; margin-bottom: 0.5rem; flex-wrap: wrap; }
#verwaltung-sparkline { width: 100%; height: 60px; display: block; }
.verwaltung-trend { font-size: 0.82rem; font-weight: 700; padding: 0.2em 0.7em; border-radius: 999px; }
.verwaltung-trend-up { color: #7ec8a0; background: color-mix(in srgb, #6ecb8f 20%, transparent); }
.verwaltung-trend-down { color: #ffb0ba; background: color-mix(in srgb, var(--c-sale) 20%, transparent); }
/* Status-Fortschrittsbalken auf jeder Bestellkarte — vier Punkte, aktueller Schritt hervorgehoben,
vergangene mit Häkchen. zahlungOffen/storniert fallen aus dem linearen Ablauf raus und
bekommen stattdessen einen einzeiligen Hinweis (siehe stepperHtml() im Skript). */
.verwaltung-stepper { display: flex; align-items: center; gap: 0.2rem; margin: 0.9rem 0; flex-wrap: wrap; }
.verwaltung-stepper-step { display: flex; flex-direction: column; align-items: center; gap: 0.25rem; min-width: 3.6rem; }
.verwaltung-stepper-dot {
display: flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border-radius: 50%;
border: 1.5px solid rgba(244, 241, 247, 0.18);
background: rgba(244, 241, 247, 0.04);
font-size: 0.85rem;
color: var(--c-text-muted);
transition: border-color 0.2s var(--ease), background 0.2s var(--ease), color 0.2s var(--ease);
}
.verwaltung-stepper-step.is-done .verwaltung-stepper-dot {
border-color: color-mix(in srgb, #6ecb8f 55%, transparent);
background: color-mix(in srgb, #6ecb8f 22%, transparent);
color: #7ec8a0;
}
.verwaltung-stepper-step.is-current .verwaltung-stepper-dot {
border-color: var(--c-accent);
background: color-mix(in srgb, var(--c-accent) 22%, transparent);
color: var(--c-accent);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--c-accent) 22%, transparent);
}
.verwaltung-stepper-label { font-size: 0.68rem; color: var(--c-text-muted); text-align: center; }
.verwaltung-stepper-step.is-current .verwaltung-stepper-label { color: var(--c-text); font-weight: 700; }
.verwaltung-stepper-line { flex: 1; height: 2px; min-width: 0.8rem; background: rgba(244, 241, 247, 0.14); margin-bottom: 1.1rem; }
.verwaltung-stepper-line.is-done { background: color-mix(in srgb, #6ecb8f 55%, transparent); }
.verwaltung-stepper-pending { font-size: 0.85rem; color: #f0c375; margin: 0.9rem 0; }
.verwaltung-stepper-storniert { font-size: 0.85rem; color: var(--c-sale); margin: 0.9rem 0; }
.verwaltung-advance-btn { white-space: nowrap; }
/* Meilenstein-Konfetti: leichte, selbstgebaute Fall-Animation ohne externe Bibliothek. */
#verwaltung-confetti { position: fixed; inset: 0; pointer-events: none; z-index: 500; overflow: hidden; }
.verwaltung-confetti-piece {
position: fixed;
top: -20px;
width: 9px;
height: 14px;
border-radius: 2px;
opacity: 0.95;
animation-name: verwaltung-confetti-fall;
animation-timing-function: ease-in;
animation-fill-mode: forwards;
}
@keyframes verwaltung-confetti-fall {
0% { transform: translate(0, 0) rotate(0deg); opacity: 0.95; }
100% { transform: translate(var(--drift), 105vh) rotate(var(--rot)); opacity: 0.25; }
}
.verwaltung-milestone-toast {
position: fixed;
left: 50%;
bottom: 1.6rem;
transform: translate(-50%, 1.4rem);
z-index: 501;
max-width: min(90vw, 480px);
padding: 0.9rem 1.3rem;
border-radius: var(--radius-m, 16px);
border: 1.5px solid color-mix(in srgb, var(--c-accent) 45%, transparent);
background: linear-gradient(165deg, var(--c-anthracite-light), var(--c-anthracite));
box-shadow: 0 20px 50px -20px rgba(0, 0, 0, 0.7);
text-align: center;
opacity: 0;
transition: opacity 0.4s var(--ease), transform 0.4s var(--ease);
}
.verwaltung-milestone-toast.is-visible { opacity: 1; transform: translate(-50%, 0); }
@media (prefers-reduced-motion: reduce) {
.verwaltung-confetti-piece { display: none; }
.verwaltung-milestone-toast { transition: opacity 0.2s linear; transform: translate(-50%, 0); }
}