Files
vans-diy-bastelbedarf/src/pages/verwaltung/index.astro
T
qciganoandClaude Sonnet 5 d3efaf8aba 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]>
2026-08-03 19:27:21 +02:00

276 lines
15 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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>