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:
qcigano
2026-08-03 19:27:21 +02:00
co-authored by Claude Sonnet 5
parent 533724654a
commit d3efaf8aba
3 changed files with 338 additions and 3 deletions
+275
View File
@@ -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>