From d3efaf8abae2e74647344fca2c2e7c6adc4a7a9c Mon Sep 17 00:00:00 2001 From: qcigano Date: Mon, 3 Aug 2026 19:27:21 +0200 Subject: [PATCH] =?UTF-8?q?Interne=20Bestellverwaltung=20mit=20Status-?= =?UTF-8?q?=C3=9Cbersicht=20+=20Auswertung=20(/verwaltung/)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- functions/_middleware.js | 20 ++- src/pages/verwaltung/index.astro | 275 +++++++++++++++++++++++++++++++ src/styles/global.css | 46 ++++++ 3 files changed, 338 insertions(+), 3 deletions(-) create mode 100644 src/pages/verwaltung/index.astro diff --git a/functions/_middleware.js b/functions/_middleware.js index ad5d958..039eaa3 100644 --- a/functions/_middleware.js +++ b/functions/_middleware.js @@ -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(); } diff --git a/src/pages/verwaltung/index.astro b/src/pages/verwaltung/index.astro new file mode 100644 index 0000000..cb81e74 --- /dev/null +++ b/src/pages/verwaltung/index.astro @@ -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 = { + 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(); +bestellungen.forEach((b) => b.artikel.forEach((a) => mengeProSlug.set(a.slug, (mengeProSlug.get(a.slug) ?? 0) + a.menge))); + +const kategorienMitProdukten = new Map(); +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 = { de: "Deutschland", at: "Österreich", ch: "Schweiz", lu: "Luxemburg" }; +const landFlagge: Record = { de: "🇩🇪", at: "🇦🇹", ch: "🇨🇭", lu: "🇱🇺" }; +const landCounts = new Map(); +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), + })); +--- + +
+
+

+ 🔒 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. +

+ Verwaltung +

📊 Bestellverwaltung

+

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.

+
+
+ +
+
+
+ + + + +
+ + {formatPrice(gesamtumsatz, lang)} + Gesamtumsatz ({bestellungen.length} Bestellungen) +
+
+
+
+ +
+
+

📦 Alle Bestellungen

+ + +
+ {bestellungen.map((b) => ( +
+
+
+ {landFlagge[b.land]} {b.nummer} + {b.datum} · {landName[b.land]} +
+ {statusLabel[b.status]} +
+
    + {b.artikel.map((a) => ( +
  • {a.menge}× {a.name}{formatPrice(a.preis * a.menge, lang)}
  • + ))} +
+
+ Summe: {formatPrice(b.summe, lang)} +
+
+ ))} +
+
+
+ +
+
+

🏆 Meistverkaufte Produkte je Kategorie

+
+ {auswertungKategorien.map((k) => ( +
+

{k.icon} {k.name}

+
+ {k.produkte.map((p) => ( +
+
{p.name}{p.menge}× verkauft
+
+
+ ))} +
+
+ ))} +
+
+
+ +
+
+

🌍 Bestellungen nach Land

+
+
+ {auswertungLaender.map((l) => ( +
+
{l.flagge} {l.name}{l.count} Bestellungen · {l.prozent}%
+
+
+ ))} +
+
+
+
+
+ + diff --git a/src/styles/global.css b/src/styles/global.css index 8dda709..49f036a 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -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); }