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]>
276 lines
15 KiB
Plaintext
276 lines
15 KiB
Plaintext
---
|
||
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>
|