Interne Bestellverwaltung mit Status-Übersicht + Auswertung (/verwaltung/)
Neue Seite, dauerhaft nur für qciga/vanvan sichtbar (functions/_middleware.js schützt /verwaltung/* jetzt IMMER — unabhängig vom SITE_PUBLIC-Schalter, mit dem der Rest der Seite später öffentlich geschaltet wird): - Status-Kacheln: Offen / In Bearbeitung / Versendet / Abgeschlossen (klickbar als Filter) + Gesamtumsatz-Kachel - Filterbare Bestellliste (nutzt dieselben Status-Badges/-Farben wie die Kunden-Kontoseite) - Auswertung "Meistverkaufte Produkte je Kategorie" (Balken nach verkaufter Menge, aus den echten Produktdaten berechnet) - Auswertung "Bestellungen nach Land" (DE/AT/CH/LU, Balken + Prozent) Wie an anderer Stelle im Projekt: es gibt noch keine echte, geräteüber- greifende Bestell-Datenbank (Phase 1) — die Seite zeigt realistische Beispieldaten in exakt der Struktur, die echte Bestellungen später haben werden (Cloudflare Worker + D1), klar als solche gekennzeichnet. Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
533724654a
commit
d3efaf8aba
@@ -25,6 +25,9 @@
|
||||
Adminbereich hat sein eigenes, echtes Login (GitHub-OAuth über den
|
||||
cms-auth-Worker) und funktioniert für VanVan bereits einwandfrei; eine
|
||||
zusätzliche Schranke davor würde nur doppelt verwirren.
|
||||
- /verwaltung/* (interne Bestellübersicht) bleibt IMMER geschützt — auch
|
||||
wenn der Rest der Seite später per SITE_PUBLIC=true freigeschaltet wird
|
||||
(siehe unten). Diese Seite ist nie für Kund:innen gedacht.
|
||||
|
||||
Die Signatur (HMAC-SHA256 über Web Crypto, in jeder Workers/Pages-Runtime
|
||||
eingebaut) verhindert, dass jemand sich einfach selbst ein
|
||||
@@ -139,9 +142,20 @@ export async function onRequest(context) {
|
||||
return next();
|
||||
}
|
||||
|
||||
// Fail-open-Absicherung: fehlen die Zugangs-Secrets (z.B. noch nicht gesetzt), lieber die
|
||||
// Schranke NICHT scharf schalten, als sich selbst aus der eigenen Seite auszusperren.
|
||||
if (!env.SITE_ACCESS_SECRET || (!env.SITE_ACCESS_CODE_QCIGA && !env.SITE_ACCESS_CODE_VANVAN)) {
|
||||
// Fail-open-Absicherung: fehlen die Zugangs-Secrets komplett (z.B. noch nicht gesetzt), lieber
|
||||
// GAR KEINE Schranke scharf schalten, als sich selbst aus der eigenen Seite auszusperren.
|
||||
const codesKonfiguriert = !!(env.SITE_ACCESS_SECRET && (env.SITE_ACCESS_CODE_QCIGA || env.SITE_ACCESS_CODE_VANVAN));
|
||||
if (!codesKonfiguriert) {
|
||||
return next();
|
||||
}
|
||||
|
||||
// /verwaltung/* (Bestellübersicht — nur für qciga/vanvan) bleibt IMMER geschützt, unabhängig
|
||||
// vom SITE_PUBLIC-Schalter unten. Sobald der Shop live geht, setzt man einfach das Secret
|
||||
// SITE_PUBLIC=true (wrangler pages secret put) — dann öffnet sich der Rest der Seite für alle,
|
||||
// aber /verwaltung/ bleibt weiterhin komplett hinter dem Zugangscode.
|
||||
const immerGeschuetzterPfad = url.pathname.startsWith("/verwaltung");
|
||||
const sitePublic = env.SITE_PUBLIC === "true";
|
||||
if (sitePublic && !immerGeschuetzterPfad) {
|
||||
return next();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
---
|
||||
import Layout from "../../layouts/Layout.astro";
|
||||
import { getProduct } from "../../data/products";
|
||||
import { getCategory } from "../../data/categories";
|
||||
import { formatPrice } from "../../i18n/format";
|
||||
import type { Locale } from "../../i18n/config";
|
||||
|
||||
const lang: Locale = "de";
|
||||
|
||||
// ─── Interne Bestellverwaltung — NUR für qciga/vanvan (siehe functions/_middleware.js, diese
|
||||
// Seite bleibt immer hinter dem Zugangscode, auch wenn der Rest der Seite mal öffentlich wird). ───
|
||||
//
|
||||
// TODO Phase 2: Es gibt noch keine echte, geräteübergreifende Bestell-Datenbank (siehe
|
||||
// previewNote in konto/angemeldet.astro) — Bestellungen aus dem Warenkorb landen aktuell nirgends
|
||||
// zentral, jede Person sieht nur ihre eigenen im eigenen Browser. Diese Seite zeigt deshalb
|
||||
// realistische BEISPIELDATEN, exakt in der Struktur, die eine echte Bestellliste später haben
|
||||
// wird (Cloudflare Worker + D1, siehe Tech-Stack-Entscheidung) — Layout/Auswertung sind schon
|
||||
// jetzt "fertig", es muss später nur noch echte Daten statt der Beispiele einspeisen.
|
||||
|
||||
type Status = "bezahlt" | "bearbeitung" | "versandVorbereitet" | "versendet" | "abgeschlossen";
|
||||
type Land = "de" | "at" | "ch" | "lu";
|
||||
|
||||
interface BeispielBestellung {
|
||||
nummer: string;
|
||||
datum: string; // YYYY-MM-DD
|
||||
land: Land;
|
||||
status: Status;
|
||||
artikel: { slug: string; menge: number }[];
|
||||
}
|
||||
|
||||
const bestellungenRoh: BeispielBestellung[] = [
|
||||
{ nummer: "VDB-2026-0201", datum: "2026-08-02", land: "de", status: "bezahlt", artikel: [{ slug: "kleiner-teddy", menge: 2 }] },
|
||||
{ nummer: "VDB-2026-0200", datum: "2026-08-02", land: "at", status: "bearbeitung", artikel: [{ slug: "haekeldecke-boho", menge: 1 }, { slug: "baumwollgarn-bundle", menge: 1 }] },
|
||||
{ nummer: "VDB-2026-0199", datum: "2026-08-01", land: "de", status: "versandVorbereitet", artikel: [{ slug: "hasidog-plushie-blaueroverall", menge: 1 }] },
|
||||
{ nummer: "VDB-2026-0198", datum: "2026-08-01", land: "ch", status: "versendet", artikel: [{ slug: "makramee-ohrringe", menge: 2 }, { slug: "perlen-armband-hellblau", menge: 1 }] },
|
||||
{ nummer: "VDB-2026-0197", datum: "2026-07-31", land: "de", status: "abgeschlossen", artikel: [{ slug: "grosser-teddy", menge: 1 }] },
|
||||
{ nummer: "VDB-2026-0196", datum: "2026-07-31", land: "de", status: "abgeschlossen", artikel: [{ slug: "fuchs-plushie-mini", menge: 3 }] },
|
||||
{ nummer: "VDB-2026-0195", datum: "2026-07-30", land: "lu", status: "bezahlt", artikel: [{ slug: "stoffreste-bundle", menge: 1 }, { slug: "haekelnadel-set-ergonomisch", menge: 1 }] },
|
||||
{ nummer: "VDB-2026-0194", datum: "2026-07-30", land: "de", status: "abgeschlossen", artikel: [{ slug: "wolke-plushie-lavendel", menge: 1 }] },
|
||||
{ nummer: "VDB-2026-0193", datum: "2026-07-29", land: "at", status: "versendet", artikel: [{ slug: "kleiner-teddy", menge: 1 }] },
|
||||
{ nummer: "VDB-2026-0192", datum: "2026-07-29", land: "de", status: "abgeschlossen", artikel: [{ slug: "hasidog-plushie-blaueroverall", menge: 2 }] },
|
||||
{ nummer: "VDB-2026-0191", datum: "2026-07-28", land: "ch", status: "bearbeitung", artikel: [{ slug: "haekel-untersetzer-set", menge: 1 }, { slug: "baumwollgarn-bundle", menge: 2 }] },
|
||||
{ nummer: "VDB-2026-0190", datum: "2026-07-28", land: "de", status: "abgeschlossen", artikel: [{ slug: "perlen-armband-hellblau", menge: 3 }] },
|
||||
{ nummer: "VDB-2026-0189", datum: "2026-07-27", land: "de", status: "abgeschlossen", artikel: [{ slug: "fuchs-plushie-mini", menge: 1 }] },
|
||||
{ nummer: "VDB-2026-0188", datum: "2026-07-26", land: "de", status: "abgeschlossen", artikel: [{ slug: "grosser-teddy", menge: 2 }] },
|
||||
{ nummer: "VDB-2026-0187", datum: "2026-07-25", land: "at", status: "abgeschlossen", artikel: [{ slug: "makramee-ohrringe", menge: 1 }] },
|
||||
{ nummer: "VDB-2026-0186", datum: "2026-07-24", land: "de", status: "abgeschlossen", artikel: [{ slug: "hasidog-plushie-blaueroverall", menge: 1 }, { slug: "kleiner-teddy", menge: 1 }] },
|
||||
{ nummer: "VDB-2026-0185", datum: "2026-07-23", land: "ch", status: "abgeschlossen", artikel: [{ slug: "haekeldecke-boho", menge: 1 }] },
|
||||
{ nummer: "VDB-2026-0184", datum: "2026-07-22", land: "de", status: "versendet", artikel: [{ slug: "stoffreste-bundle", menge: 2 }] },
|
||||
{ nummer: "VDB-2026-0183", datum: "2026-07-21", land: "de", status: "abgeschlossen", artikel: [{ slug: "fuchs-plushie-mini", menge: 2 }] },
|
||||
{ nummer: "VDB-2026-0182", datum: "2026-07-20", land: "lu", status: "abgeschlossen", artikel: [{ slug: "wolke-plushie-lavendel", menge: 1 }, { slug: "perlen-armband-hellblau", menge: 1 }] },
|
||||
];
|
||||
|
||||
const bestellungen = bestellungenRoh.map((b) => {
|
||||
const artikel = b.artikel.map((a) => {
|
||||
const p = getProduct(a.slug);
|
||||
return { ...a, name: p?.name[lang] ?? a.slug, preis: p?.preis ?? 0, kategorie: p?.kategorie ?? "" };
|
||||
});
|
||||
return { ...b, artikel, summe: artikel.reduce((s, a) => s + a.preis * a.menge, 0) };
|
||||
});
|
||||
|
||||
// Vier Buckets statt der fünf feingranularen Status — genau wie gewünscht: offen / in Bearbeitung
|
||||
// / versendet / abgeschlossen. "bearbeitung" + "versandVorbereitet" zählen zusammen als
|
||||
// "in Bearbeitung" (beides "wird gerade vorbereitet", nur mit unterschiedlichem Detailgrad).
|
||||
function bucket(status: Status): "offen" | "inBearbeitung" | "versendet" | "abgeschlossen" {
|
||||
if (status === "bezahlt") return "offen";
|
||||
if (status === "bearbeitung" || status === "versandVorbereitet") return "inBearbeitung";
|
||||
if (status === "versendet") return "versendet";
|
||||
return "abgeschlossen";
|
||||
}
|
||||
const buckets = { offen: 0, inBearbeitung: 0, versendet: 0, abgeschlossen: 0 };
|
||||
bestellungen.forEach((b) => { buckets[bucket(b.status)]++; });
|
||||
|
||||
const gesamtumsatz = bestellungen.reduce((s, b) => s + b.summe, 0);
|
||||
const abgeschlosseneUmsaetze = bestellungen.filter((b) => b.status === "abgeschlossen");
|
||||
const durchschnittsbestellwert = bestellungen.length > 0 ? gesamtumsatz / bestellungen.length : 0;
|
||||
|
||||
const statusLabel: Record<Status, string> = {
|
||||
bezahlt: "Bezahlt", bearbeitung: "In Bearbeitung", versandVorbereitet: "Versand wird vorbereitet",
|
||||
versendet: "Versendet", abgeschlossen: "Abgeschlossen",
|
||||
};
|
||||
|
||||
// ── Auswertung 1: meistverkaufte Produkte je Kategorie (nach verkaufter Menge) ──
|
||||
const mengeProSlug = new Map<string, number>();
|
||||
bestellungen.forEach((b) => b.artikel.forEach((a) => mengeProSlug.set(a.slug, (mengeProSlug.get(a.slug) ?? 0) + a.menge)));
|
||||
|
||||
const kategorienMitProdukten = new Map<string, { slug: string; name: string; menge: number }[]>();
|
||||
mengeProSlug.forEach((menge, slug) => {
|
||||
const p = getProduct(slug);
|
||||
if (!p) return;
|
||||
const liste = kategorienMitProdukten.get(p.kategorie) ?? [];
|
||||
liste.push({ slug, name: p.name[lang], menge });
|
||||
kategorienMitProdukten.set(p.kategorie, liste);
|
||||
});
|
||||
const auswertungKategorien = Array.from(kategorienMitProdukten.entries())
|
||||
.map(([kategorieSlug, produkte]) => {
|
||||
const kat = getCategory(kategorieSlug);
|
||||
const sortiert = produkte.sort((a, b) => b.menge - a.menge);
|
||||
const maxMenge = sortiert[0]?.menge ?? 1;
|
||||
return {
|
||||
slug: kategorieSlug,
|
||||
name: kat?.name[lang] ?? kategorieSlug,
|
||||
icon: kat?.icon ?? "📦",
|
||||
gesamtMenge: sortiert.reduce((s, p) => s + p.menge, 0),
|
||||
produkte: sortiert.slice(0, 4).map((p) => ({ ...p, prozent: Math.round((p.menge / maxMenge) * 100) })),
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.gesamtMenge - a.gesamtMenge);
|
||||
|
||||
// ── Auswertung 2: Bestellungen nach Land ──
|
||||
const landName: Record<Land, string> = { de: "Deutschland", at: "Österreich", ch: "Schweiz", lu: "Luxemburg" };
|
||||
const landFlagge: Record<Land, string> = { de: "🇩🇪", at: "🇦🇹", ch: "🇨🇭", lu: "🇱🇺" };
|
||||
const landCounts = new Map<Land, number>();
|
||||
bestellungen.forEach((b) => landCounts.set(b.land, (landCounts.get(b.land) ?? 0) + 1));
|
||||
const maxLandCount = Math.max(...Array.from(landCounts.values()));
|
||||
const auswertungLaender = (Array.from(landCounts.entries()) as [Land, number][])
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([land, count]) => ({
|
||||
land, name: landName[land], flagge: landFlagge[land], count,
|
||||
prozent: Math.round((count / bestellungen.length) * 100),
|
||||
balken: Math.round((count / maxLandCount) * 100),
|
||||
}));
|
||||
---
|
||||
<Layout
|
||||
title="Bestellverwaltung"
|
||||
description="Interne Bestellübersicht — nur für qciga und VanVan sichtbar."
|
||||
lang={lang}
|
||||
path="/verwaltung/"
|
||||
noindex={true}
|
||||
>
|
||||
<section class="section-tight">
|
||||
<div class="container">
|
||||
<p class="todo-note">
|
||||
🔒 Interner Bereich, nur für dich und VanVan sichtbar (siehe Zugangscode-Gate). Es gibt
|
||||
noch keine echte, geräteübergreifende Bestell-Datenbank (Phase 1) — die Bestellungen und
|
||||
Auswertungen unten sind realistische BEISPIELDATEN, damit du siehst, wie die Übersicht
|
||||
aussehen wird. Echte Bestellungen laufen automatisch hier ein, sobald die Zahlungs- und
|
||||
Datenbank-Anbindung (Cloudflare Worker + D1) in Phase 2 steht.
|
||||
</p>
|
||||
<span class="eyebrow">Verwaltung</span>
|
||||
<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>
|
||||
</section>
|
||||
|
||||
<section class="section-tight">
|
||||
<div class="container">
|
||||
<div class="verwaltung-stat-grid">
|
||||
<button type="button" class="card verwaltung-stat-tile" data-filter="offen">
|
||||
<span class="verwaltung-stat-icon" aria-hidden="true">🆕</span>
|
||||
<span class="verwaltung-stat-value">{buckets.offen}</span>
|
||||
<span class="verwaltung-stat-label">Offen</span>
|
||||
</button>
|
||||
<button type="button" class="card verwaltung-stat-tile" data-filter="inBearbeitung">
|
||||
<span class="verwaltung-stat-icon" aria-hidden="true">⚙️</span>
|
||||
<span class="verwaltung-stat-value">{buckets.inBearbeitung}</span>
|
||||
<span class="verwaltung-stat-label">In Bearbeitung</span>
|
||||
</button>
|
||||
<button type="button" class="card verwaltung-stat-tile" data-filter="versendet">
|
||||
<span class="verwaltung-stat-icon" aria-hidden="true">🚚</span>
|
||||
<span class="verwaltung-stat-value">{buckets.versendet}</span>
|
||||
<span class="verwaltung-stat-label">Versendet</span>
|
||||
</button>
|
||||
<button type="button" class="card verwaltung-stat-tile" data-filter="abgeschlossen">
|
||||
<span class="verwaltung-stat-icon" aria-hidden="true">✅</span>
|
||||
<span class="verwaltung-stat-value">{buckets.abgeschlossen}</span>
|
||||
<span class="verwaltung-stat-label">Abgeschlossen</span>
|
||||
</button>
|
||||
<div class="card verwaltung-stat-tile verwaltung-stat-tile-highlight">
|
||||
<span class="verwaltung-stat-icon" aria-hidden="true">💶</span>
|
||||
<span class="verwaltung-stat-value">{formatPrice(gesamtumsatz, lang)}</span>
|
||||
<span class="verwaltung-stat-label">Gesamtumsatz ({bestellungen.length} Bestellungen)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section-tight">
|
||||
<div class="container">
|
||||
<h2>📦 Alle Bestellungen</h2>
|
||||
<nav class="verwaltung-filter" aria-label="Nach Status filtern">
|
||||
<button type="button" class="verwaltung-filter-btn is-active" data-filter="alle">Alle ({bestellungen.length})</button>
|
||||
<button type="button" class="verwaltung-filter-btn" data-filter="offen">Offen ({buckets.offen})</button>
|
||||
<button type="button" class="verwaltung-filter-btn" data-filter="inBearbeitung">In Bearbeitung ({buckets.inBearbeitung})</button>
|
||||
<button type="button" class="verwaltung-filter-btn" data-filter="versendet">Versendet ({buckets.versendet})</button>
|
||||
<button type="button" class="verwaltung-filter-btn" data-filter="abgeschlossen">Abgeschlossen ({buckets.abgeschlossen})</button>
|
||||
</nav>
|
||||
|
||||
<div class="order-list" id="verwaltung-order-list">
|
||||
{bestellungen.map((b) => (
|
||||
<div class={`card order-card order-card-${b.status}`} data-bucket={bucket(b.status)}>
|
||||
<div class="order-card-head">
|
||||
<div>
|
||||
<span class="order-no">{landFlagge[b.land]} {b.nummer}</span>
|
||||
<span class="small order-meta">{b.datum} · {landName[b.land]}</span>
|
||||
</div>
|
||||
<span class={`badge order-status order-status-${b.status}`}>{statusLabel[b.status]}</span>
|
||||
</div>
|
||||
<ul class="order-items">
|
||||
{b.artikel.map((a) => (
|
||||
<li><span>{a.menge}× {a.name}</span><span class="order-item-price">{formatPrice(a.preis * a.menge, lang)}</span></li>
|
||||
))}
|
||||
</ul>
|
||||
<div class="order-card-foot">
|
||||
<span class="order-total">Summe: <strong>{formatPrice(b.summe, lang)}</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section-tight">
|
||||
<div class="container">
|
||||
<h2>🏆 Meistverkaufte Produkte je Kategorie</h2>
|
||||
<div class="verwaltung-auswertung-grid">
|
||||
{auswertungKategorien.map((k) => (
|
||||
<div class="card verwaltung-kat-card">
|
||||
<h3>{k.icon} {k.name}</h3>
|
||||
<div class="verwaltung-bar-list">
|
||||
{k.produkte.map((p) => (
|
||||
<div class="verwaltung-bar-row">
|
||||
<div class="verwaltung-bar-head"><span>{p.name}</span><span class="small">{p.menge}× verkauft</span></div>
|
||||
<div class="verwaltung-bar-track"><div class="verwaltung-bar-fill" style={`width:${p.prozent}%`}></div></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section-tight">
|
||||
<div class="container">
|
||||
<h2>🌍 Bestellungen nach Land</h2>
|
||||
<div class="card verwaltung-kat-card">
|
||||
<div class="verwaltung-bar-list">
|
||||
{auswertungLaender.map((l) => (
|
||||
<div class="verwaltung-bar-row">
|
||||
<div class="verwaltung-bar-head"><span>{l.flagge} {l.name}</span><span class="small">{l.count} Bestellungen · {l.prozent}%</span></div>
|
||||
<div class="verwaltung-bar-track"><div class="verwaltung-bar-fill verwaltung-bar-fill-land" style={`width:${l.balken}%`}></div></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</Layout>
|
||||
|
||||
<script>
|
||||
const filterBtns = document.querySelectorAll<HTMLButtonElement>("[data-filter]");
|
||||
const cards = Array.from(document.querySelectorAll<HTMLElement>("#verwaltung-order-list .order-card"));
|
||||
|
||||
function setFilter(filter: string) {
|
||||
document.querySelectorAll<HTMLButtonElement>(".verwaltung-filter-btn").forEach((btn) => {
|
||||
btn.classList.toggle("is-active", btn.dataset.filter === filter);
|
||||
});
|
||||
cards.forEach((card) => {
|
||||
card.style.display = filter === "alle" || card.dataset.bucket === filter ? "" : "none";
|
||||
});
|
||||
}
|
||||
|
||||
filterBtns.forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const f = btn.dataset.filter;
|
||||
if (f) {
|
||||
setFilter(f);
|
||||
if (btn.classList.contains("verwaltung-stat-tile")) {
|
||||
document.getElementById("verwaltung-order-list")?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@@ -2697,3 +2697,49 @@ a:focus-visible, button:focus-visible {
|
||||
}
|
||||
#vandiy-preview-panel button[data-preview]:hover { background: color-mix(in srgb, var(--c-accent) 14%, transparent); }
|
||||
@media (max-width: 500px) { #vandiy-preview-switcher { right: 0.7rem; bottom: 0.7rem; } }
|
||||
|
||||
/* Interne Bestellverwaltung (/verwaltung/) — Status-Kacheln, Filterleiste, Auswertungs-Balken. */
|
||||
.verwaltung-stat-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 0.9rem; margin: 1.2rem 0; }
|
||||
.verwaltung-stat-tile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.3rem;
|
||||
padding: 1.1rem 1.2rem;
|
||||
text-align: left;
|
||||
border: 1.5px solid rgba(244, 241, 247, 0.12);
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s var(--ease), border-color 0.2s var(--ease);
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
.verwaltung-stat-tile:hover { transform: translateY(-3px); border-color: var(--c-accent); }
|
||||
.verwaltung-stat-tile-highlight { border-color: color-mix(in srgb, #e3b23c 45%, transparent); background: color-mix(in srgb, #e3b23c 10%, var(--c-anthracite-light)); cursor: default; }
|
||||
.verwaltung-stat-tile-highlight:hover { transform: none; border-color: color-mix(in srgb, #e3b23c 45%, transparent); }
|
||||
.verwaltung-stat-icon { font-size: 1.6rem; }
|
||||
.verwaltung-stat-value { font-family: var(--font-head); font-size: 1.9rem; font-weight: 700; line-height: 1; }
|
||||
.verwaltung-stat-label { font-size: 0.85rem; color: var(--c-text-muted); }
|
||||
|
||||
.verwaltung-filter { display: flex; gap: 0.6rem; flex-wrap: wrap; margin: 1.2rem 0; }
|
||||
.verwaltung-filter-btn {
|
||||
padding: 0.5em 1em;
|
||||
border-radius: 999px;
|
||||
border: 1.5px solid rgba(244, 241, 247, 0.16);
|
||||
background: transparent;
|
||||
color: var(--c-text-muted);
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s var(--ease);
|
||||
}
|
||||
.verwaltung-filter-btn:hover { border-color: var(--c-accent); color: var(--c-text); }
|
||||
.verwaltung-filter-btn.is-active { background: var(--c-accent); border-color: var(--c-accent); color: var(--c-black); }
|
||||
|
||||
.verwaltung-auswertung-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 1rem; margin-top: 1.2rem; }
|
||||
.verwaltung-kat-card { display: flex; flex-direction: column; gap: 0.9rem; }
|
||||
.verwaltung-kat-card h3 { margin: 0; }
|
||||
.verwaltung-bar-list { display: flex; flex-direction: column; gap: 0.8rem; }
|
||||
.verwaltung-bar-head { display: flex; justify-content: space-between; gap: 1rem; font-size: 0.9rem; margin-bottom: 0.3rem; }
|
||||
.verwaltung-bar-track { height: 9px; border-radius: 999px; background: rgba(244, 241, 247, 0.08); overflow: hidden; }
|
||||
.verwaltung-bar-fill { height: 100%; border-radius: 999px; background: linear-gradient(90deg, var(--c-accent), #9b7fd6); transition: width 0.6s var(--ease); }
|
||||
.verwaltung-bar-fill-land { background: linear-gradient(90deg, #e3b23c, #f6dda0); }
|
||||
|
||||
Reference in New Issue
Block a user