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:
co-authored by
Claude Sonnet 5
parent
aeb76a277d
commit
e37d12b4c7
@@ -29,6 +29,15 @@ const lang: Locale = "de";
|
|||||||
path="/verwaltung/"
|
path="/verwaltung/"
|
||||||
noindex={true}
|
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">
|
<section class="section-tight">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<p class="todo-note">
|
<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-1" aria-hidden="true">✨</span>
|
||||||
<span class="verwaltung-hero-sparkle verwaltung-hero-sparkle-2" aria-hidden="true">✨</span>
|
<span class="verwaltung-hero-sparkle verwaltung-hero-sparkle-2" aria-hidden="true">✨</span>
|
||||||
<span class="eyebrow">Verwaltung</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>
|
<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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -151,6 +169,16 @@ const lang: Locale = "de";
|
|||||||
<span class="verwaltung-stat-label">Brauchen Aufmerksamkeit</span>
|
<span class="verwaltung-stat-label">Brauchen Aufmerksamkeit</span>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -291,6 +319,12 @@ const lang: Locale = "de";
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
</div>
|
</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>
|
</Layout>
|
||||||
|
|
||||||
<script type="module">
|
<script type="module">
|
||||||
@@ -326,6 +360,173 @@ const lang: Locale = "de";
|
|||||||
const ALTER_SCHWELLE_TAGE = 3;
|
const ALTER_SCHWELLE_TAGE = 3;
|
||||||
const MS_PRO_TAG = 24 * 60 * 60 * 1000;
|
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) {
|
function bucket(status) {
|
||||||
if (status === "storniert") return "storniert";
|
if (status === "storniert") return "storniert";
|
||||||
if (status === "zahlungOffen") return "zahlungOffen";
|
if (status === "zahlungOffen") return "zahlungOffen";
|
||||||
@@ -352,6 +553,9 @@ const lang: Locale = "de";
|
|||||||
let letzteLaender = [];
|
let letzteLaender = [];
|
||||||
let letzteKunden = [];
|
let letzteKunden = [];
|
||||||
let letzteAktive = [];
|
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() {
|
async function ladeUndRendere() {
|
||||||
loadingEl.style.display = "";
|
loadingEl.style.display = "";
|
||||||
@@ -367,6 +571,7 @@ const lang: Locale = "de";
|
|||||||
bekannteIds = new Set(alleBestellungen.map((b) => b.id));
|
bekannteIds = new Set(alleBestellungen.map((b) => b.id));
|
||||||
erstesLaden = false;
|
erstesLaden = false;
|
||||||
render();
|
render();
|
||||||
|
pruefeMeilenstein(alleBestellungen.length);
|
||||||
loadingEl.style.display = "none";
|
loadingEl.style.display = "none";
|
||||||
contentEl.style.display = "";
|
contentEl.style.display = "";
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -390,6 +595,15 @@ const lang: Locale = "de";
|
|||||||
const banner = document.getElementById("verwaltung-new-banner");
|
const banner = document.getElementById("verwaltung-new-banner");
|
||||||
document.getElementById("verwaltung-new-count").textContent = String(neue.length);
|
document.getElementById("verwaltung-new-count").textContent = String(neue.length);
|
||||||
banner.style.display = "flex";
|
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 {
|
} catch {
|
||||||
// Stiller Fehlschlag — der nächste reguläre Refresh/Retry fängt es wieder ein, kein Grund
|
// 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;
|
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) {
|
function sortiere(liste, modus) {
|
||||||
const kopie = [...liste];
|
const kopie = [...liste];
|
||||||
switch (modus) {
|
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() {
|
function render() {
|
||||||
// "aktive" = zählt für Umsatz/Auswertung: weder storniert noch (bei Überweisung) noch gar
|
// "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.
|
// nicht bezahlt — sonst würde Geld gezeigt, das VanVan noch gar nicht hat.
|
||||||
const aktive = alleBestellungen.filter((b) => b.status !== "storniert" && b.status !== "zahlungOffen");
|
const aktive = alleBestellungen.filter((b) => b.status !== "storniert" && b.status !== "zahlungOffen");
|
||||||
letzteAktive = aktive;
|
letzteAktive = aktive;
|
||||||
|
renderSparkline(aktive);
|
||||||
|
|
||||||
// Die Auswertungs-Detail-Panels zeigen ggf. veraltete Daten, sobald sich die Bestellliste
|
// 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
|
// ä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 ──
|
// ── Buckets für die Stat-Kacheln + Filterleiste ──
|
||||||
const buckets = { zahlungOffen: 0, offen: 0, inBearbeitung: 0, versendet: 0, abgeschlossen: 0, storniert: 0 };
|
const buckets = { zahlungOffen: 0, offen: 0, inBearbeitung: 0, versendet: 0, abgeschlossen: 0, storniert: 0 };
|
||||||
alleBestellungen.forEach((b) => { buckets[bucket(b.status)]++; });
|
alleBestellungen.forEach((b) => { buckets[bucket(b.status)]++; });
|
||||||
setText("stat-zahlungOffen", String(buckets.zahlungOffen));
|
setNumber("stat-zahlungOffen", buckets.zahlungOffen);
|
||||||
setText("stat-offen", String(buckets.offen));
|
setNumber("stat-offen", buckets.offen);
|
||||||
setText("stat-inBearbeitung", String(buckets.inBearbeitung));
|
setNumber("stat-inBearbeitung", buckets.inBearbeitung);
|
||||||
setText("stat-versendet", String(buckets.versendet));
|
setNumber("stat-versendet", buckets.versendet);
|
||||||
setText("stat-abgeschlossen", String(buckets.abgeschlossen));
|
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-alle", String(alleBestellungen.length));
|
||||||
setText("filter-count-zahlungOffen", String(buckets.zahlungOffen));
|
setText("filter-count-zahlungOffen", String(buckets.zahlungOffen));
|
||||||
setText("filter-count-offen", String(buckets.offen));
|
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
|
// zählen bewusst NICHT mit — sonst würde der Umsatz Geld zeigen, das noch gar nicht da
|
||||||
// ist. ──
|
// ist. ──
|
||||||
const gesamtumsatz = aktive.reduce((s, b) => s + Number(b.summe), 0);
|
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-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" ──
|
// ── Heute / letzte 7 Tage / diesen Monat + "braucht Aufmerksamkeit" ──
|
||||||
const jetzt = Date.now();
|
const jetzt = Date.now();
|
||||||
@@ -462,9 +768,9 @@ const lang: Locale = "de";
|
|||||||
const monatsStart = new Date(heuteDatum.getFullYear(), heuteDatum.getMonth(), 1).getTime();
|
const monatsStart = new Date(heuteDatum.getFullYear(), heuteDatum.getMonth(), 1).getTime();
|
||||||
const monatCount = aktive.filter((b) => new Date(b.created_at).getTime() >= monatsStart).length;
|
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);
|
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));
|
setNumber("stat-heute", heuteCount);
|
||||||
setText("stat-7tage", String(siebenTageCount));
|
setNumber("stat-7tage", siebenTageCount);
|
||||||
setText("stat-monat", String(monatCount));
|
setNumber("stat-monat", monatCount);
|
||||||
setText(
|
setText(
|
||||||
"stat-monat-label",
|
"stat-monat-label",
|
||||||
`Diesen Monat${monatCount > 0 ? ` (${formatPrice(monatUmsatz, lang)})` : ""}`
|
`Diesen Monat${monatCount > 0 ? ` (${formatPrice(monatUmsatz, lang)})` : ""}`
|
||||||
@@ -480,10 +786,11 @@ const lang: Locale = "de";
|
|||||||
);
|
);
|
||||||
const attentionTile = document.getElementById("stat-attention-tile");
|
const attentionTile = document.getElementById("stat-attention-tile");
|
||||||
if (attentionIds.size > 0) {
|
if (attentionIds.size > 0) {
|
||||||
setText("stat-attention", String(attentionIds.size));
|
setNumber("stat-attention", attentionIds.size);
|
||||||
attentionTile.style.display = "";
|
attentionTile.style.display = "";
|
||||||
} else {
|
} else {
|
||||||
attentionTile.style.display = "none";
|
attentionTile.style.display = "none";
|
||||||
|
vorherigeZahlen["stat-attention"] = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Stammkundinnen: E-Mail kommt in mehr als einer Bestellung vor. ──
|
// ── 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>
|
<span class="badge order-status order-status-${b.status}">${STATUS_LABEL[b.status] || b.status}</span>
|
||||||
</div>
|
</div>
|
||||||
<ul class="order-items">${artikelHtml}</ul>
|
<ul class="order-items">${artikelHtml}</ul>
|
||||||
|
${stepperHtml(b.status)}
|
||||||
<div class="verwaltung-copy-row">
|
<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_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>
|
<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}">
|
<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("")}
|
${Object.entries(STATUS_LABEL).map(([val, label]) => `<option value="${val}" ${val === b.status ? "selected" : ""}>${label}</option>`).join("")}
|
||||||
</select>
|
</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>
|
<span class="small verwaltung-status-saved" data-order-id="${b.id}" style="display:none;">✅ gespeichert</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -551,6 +860,9 @@ const lang: Locale = "de";
|
|||||||
listEl.querySelectorAll(".verwaltung-status-select").forEach((select) => {
|
listEl.querySelectorAll(".verwaltung-status-select").forEach((select) => {
|
||||||
select.addEventListener("change", () => statusAendern(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) => {
|
listEl.querySelectorAll(".verwaltung-copy-btn").forEach((btn) => {
|
||||||
btn.addEventListener("click", () => kopieren(btn));
|
btn.addEventListener("click", () => kopieren(btn));
|
||||||
});
|
});
|
||||||
@@ -742,27 +1054,61 @@ const lang: Locale = "de";
|
|||||||
document.querySelectorAll("[data-kunden-tile]").forEach((t) => t.classList.toggle("is-active", t.dataset.kundenTile === email));
|
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) {
|
function escapeHtml(str) {
|
||||||
return String(str ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
return String(str ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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" },
|
||||||
|
body: JSON.stringify({ status: neuerStatus }),
|
||||||
|
});
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
async function statusAendern(select) {
|
async function statusAendern(select) {
|
||||||
const orderId = select.dataset.orderId;
|
const orderId = select.dataset.orderId;
|
||||||
const neuerStatus = select.value;
|
const neuerStatus = select.value;
|
||||||
select.disabled = true;
|
select.disabled = true;
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/verwaltung/orders/${orderId}`, {
|
await speichereStatus(orderId, neuerStatus);
|
||||||
method: "PATCH",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ status: neuerStatus }),
|
|
||||||
});
|
|
||||||
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();
|
render();
|
||||||
|
|
||||||
const savedHint = document.querySelector(`.verwaltung-status-saved[data-order-id="${orderId}"]`);
|
const savedHint = document.querySelector(`.verwaltung-status-saved[data-order-id="${orderId}"]`);
|
||||||
if (savedHint) {
|
if (savedHint) {
|
||||||
savedHint.style.display = "inline";
|
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) {
|
async function trackingSpeichern(orderId) {
|
||||||
const input = document.querySelector(`.verwaltung-tracking-input[data-order-id="${orderId}"]`);
|
const input = document.querySelector(`.verwaltung-tracking-input[data-order-id="${orderId}"]`);
|
||||||
const saveBtn = document.querySelector(`.verwaltung-tracking-save[data-order-id="${orderId}"]`);
|
const saveBtn = document.querySelector(`.verwaltung-tracking-save[data-order-id="${orderId}"]`);
|
||||||
|
|||||||
@@ -3163,6 +3163,48 @@ a:focus-visible, button:focus-visible {
|
|||||||
|
|
||||||
/* Interne Bestellverwaltung (/verwaltung/) — Status-Kacheln, Filterleiste, Auswertungs-Balken. */
|
/* 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,
|
/* "Kisten" — jede zusammengehörige Gruppe (Bestellstatus, Kennzahlen, Bestellliste, Bestseller,
|
||||||
Herkunft, Stammkundschaft) bekommt ihre eigene, klar abgegrenzte Box mit eigener Akzentfarbe
|
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
|
(--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-order-foot { flex-wrap: wrap; }
|
||||||
.verwaltung-tracking-edit { display: flex; align-items: center; gap: 0.4rem; }
|
.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; }
|
.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); }
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user