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/"
|
||||
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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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}"]`);
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user