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
@@ -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>
|
||||
Reference in New Issue
Block a user