Echtes Bestellsystem: Cloudflare D1 statt Beispieldaten in der Verwaltung

Ersetzt die Phase-1-Beispieldaten in /verwaltung/ durch ein echtes, funktionierendes
Bestellsystem:

- Neue D1-Datenbank (vans-diy-bastelbedarf-db) mit orders/order_items-Tabellen,
  an das Pages-Projekt gebunden (wrangler.toml).
- Neue API-Routen (Pages Functions): POST /api/orders legt beim Checkout eine echte
  Bestellung an (mit Validierung); GET/PATCH /api/verwaltung/orders(/:id) listen bzw.
  ändern Bestellungen — beide zusätzlich zur Middleware nochmal eigenständig gegen die
  Zugangscode-Sitzung geprüft (Verteidigung in der Tiefe). Gemeinsame Auth-Logik aus
  _middleware.js nach functions/_shared/auth.js ausgelagert.
- Checkout (4 Sprachen) sendet beim Absenden die echte Bestellung an die API, zeigt bei
  einem Fehler eine klare Meldung statt einfach auf die Danke-Seite weiterzuleiten.
- /verwaltung/ lädt Bestellungen jetzt live per fetch(), mit Lade-/Fehlerzustand, und
  erlaubt direktes Ändern des Bestellstatus per Dropdown (sofort in der Datenbank
  gespeichert). Neuer Status "storniert" (zählt nicht in Umsatz/Auswertung mit).

Vollständig lokal end-to-end getestet (wrangler pages dev + lokale D1): echter
Browser-Checkout -> Bestellung landet in der Datenbank -> erscheint sofort in der
Verwaltung -> Status ändern bleibt nach Neuladen bestehen.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
qcigano
2026-08-03 20:32:46 +02:00
co-authored by Claude Sonnet 5
parent 9170b9bb22
commit fb7a7b76c2
14 changed files with 1030 additions and 333 deletions
+66 -10
View File
@@ -180,7 +180,7 @@ const t = useTranslations(lang);
<script type="module">
import { getCart, cartTotal, appliedCoupon, couponDiscount, setCouponCode, clearCoupon, appliedPartnerCode, partnerCodeDiscount, setPartnerCode, clearPartnerCode, totalDiscount, treuebonusDiscount, aboDiscount, istAusgewaehlt, zeilenpreisFuer } from "../../../scripts/cart";
import { formatPrice } from "../../../i18n/format";
import { products } from "../../../data/products";
import { products, getProduct } from "../../../data/products";
import { berechneVersandkosten } from "../../../data/versand";
import { bankverbindung, bankverbindungVollstaendig } from "../../../data/bankverbindung";
import { registriereAbgeschlosseneBestellung, treuebonusEinloesen } from "../../../scripts/account";
@@ -194,6 +194,8 @@ import { registriereAbgeschlosseneBestellung, treuebonusEinloesen } from "../../
const cart = getCart().filter(istAusgewaehlt);
const subtotal = cartTotal();
let currentTotal = 0;
let currentShipping = 0;
let currentRabattGesamt = 0;
function renderSummary() {
const land = landSelect.value;
@@ -206,6 +208,8 @@ import { registriereAbgeschlosseneBestellung, treuebonusEinloesen } from "../../
const aboDiscountBetrag = aboDiscount(subtotal);
const total = Math.max(0, subtotal - totalDiscount(subtotal)) + shipping;
currentTotal = total;
currentShipping = shipping;
currentRabattGesamt = totalDiscount(subtotal);
summary.innerHTML = `
${cart.map((i) => {
@@ -331,20 +335,72 @@ import { registriereAbgeschlosseneBestellung, treuebonusEinloesen } from "../../
renderSummary();
});
document.getElementById("checkout-form").addEventListener("submit", (e) => {
const orderForm = document.getElementById("checkout-form");
const submitBtn = orderForm.querySelector('button[type="submit"]');
const orderErrorEl = document.createElement("p");
orderErrorEl.className = "small";
orderErrorEl.style.color = "var(--c-sale)";
orderErrorEl.style.display = "none";
submitBtn.insertAdjacentElement("beforebegin", orderErrorEl);
orderForm.addEventListener("submit", async (e) => {
e.preventDefault();
if (cart.length === 0) {
alert(emptyCartAlert);
return;
}
// Treuebonus (siehe scripts/account.ts): Eine Bestellung zählt entweder für den NÄCHSTEN
// Durchlauf (registriereAbgeschlosseneBestellung) ODER löst eine bereits wartende Belohnung
// ein (treuebonusEinloesen) — nie beides gleichzeitig.
if (treuebonusDiscount() > 0) {
treuebonusEinloesen();
} else {
registriereAbgeschlosseneBestellung();
orderErrorEl.style.display = "none";
submitBtn.disabled = true;
const urspruenglicherBtnText = submitBtn.textContent;
submitBtn.textContent = "…";
const zahlungsart = document.querySelector('input[name="pay"]:checked')?.value || "";
const payload = {
land: landSelect.value,
kunde: {
name: `${document.getElementById("vorname").value.trim()} ${document.getElementById("nachname").value.trim()}`.trim(),
email: document.getElementById("email").value.trim(),
strasse: document.getElementById("strasse").value.trim(),
plz: document.getElementById("plz").value.trim(),
ort: document.getElementById("ort").value.trim(),
},
zahlungsart,
artikel: cart.map((i) => {
const produkt = getProduct(i.slug);
const einzelpreis = i.gratis ? 0 : zeilenpreisFuer(i) / i.menge;
return { slug: i.slug, name: i.name, kategorie: produkt?.kategorie ?? "", menge: i.menge, preis: einzelpreis, gratis: !!i.gratis };
}),
zwischensumme: subtotal,
versandkosten: currentShipping,
rabattGesamt: currentRabattGesamt,
summe: currentTotal,
treuebonusVerwendet: treuebonusDiscount() > 0,
aboRabattVerwendet: aboDiscount(subtotal) > 0,
};
try {
const res = await fetch("/api/orders", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const data = await res.json().catch(() => null);
if (!res.ok || !data?.ok) {
throw new Error(data?.error || "Bestellung konnte nicht gespeichert werden.");
}
if (treuebonusDiscount() > 0) {
treuebonusEinloesen();
} else {
registriereAbgeschlosseneBestellung();
}
location.href = thankYouPath;
} catch (err) {
orderErrorEl.textContent = "⚠️ " + (err instanceof Error ? err.message : "Bestellung konnte nicht gespeichert werden. Bitte nochmal versuchen.");
orderErrorEl.style.display = "block";
submitBtn.disabled = false;
submitBtn.textContent = urspruenglicherBtnText;
}
location.href = thankYouPath;
});
</script>
+78 -12
View File
@@ -183,7 +183,7 @@ const t = useTranslations(lang);
<script type="module">
import { getCart, cartTotal, appliedCoupon, couponDiscount, setCouponCode, clearCoupon, appliedPartnerCode, partnerCodeDiscount, setPartnerCode, clearPartnerCode, totalDiscount, treuebonusDiscount, aboDiscount, istAusgewaehlt, zeilenpreisFuer } from "../../scripts/cart";
import { formatPrice } from "../../i18n/format";
import { products } from "../../data/products";
import { products, getProduct } from "../../data/products";
import { berechneVersandkosten } from "../../data/versand";
import { bankverbindung, bankverbindungVollstaendig } from "../../data/bankverbindung";
import { registriereAbgeschlosseneBestellung, treuebonusEinloesen } from "../../scripts/account";
@@ -197,6 +197,10 @@ import { registriereAbgeschlosseneBestellung, treuebonusEinloesen } from "../../
const cart = getCart().filter(istAusgewaehlt);
const subtotal = cartTotal();
let currentTotal = 0;
// Für den echten Bestell-API-Aufruf beim Absenden gebraucht (siehe Submit-Handler unten) —
// renderSummary() aktualisiert diese bei jeder Land-/Gutschein-Änderung mit.
let currentShipping = 0;
let currentRabattGesamt = 0;
function renderSummary() {
const land = landSelect.value;
@@ -209,6 +213,8 @@ import { registriereAbgeschlosseneBestellung, treuebonusEinloesen } from "../../
const aboDiscountBetrag = aboDiscount(subtotal);
const total = Math.max(0, subtotal - totalDiscount(subtotal)) + shipping;
currentTotal = total;
currentShipping = shipping;
currentRabattGesamt = totalDiscount(subtotal);
summary.innerHTML = `
${cart.map((i) => {
@@ -334,22 +340,82 @@ import { registriereAbgeschlosseneBestellung, treuebonusEinloesen } from "../../
renderSummary();
});
document.getElementById("checkout-form").addEventListener("submit", (e) => {
const orderForm = document.getElementById("checkout-form");
const submitBtn = orderForm.querySelector('button[type="submit"]');
const orderErrorEl = document.createElement("p");
orderErrorEl.className = "small";
orderErrorEl.style.color = "var(--c-sale)";
orderErrorEl.style.display = "none";
submitBtn.insertAdjacentElement("beforebegin", orderErrorEl);
orderForm.addEventListener("submit", async (e) => {
e.preventDefault();
if (cart.length === 0) {
alert(emptyCartAlert);
return;
}
// Treuebonus (siehe scripts/account.ts): Eine Bestellung zählt entweder für den NÄCHSTEN
// Durchlauf (registriereAbgeschlosseneBestellung) ODER löst eine bereits wartende Belohnung
// ein (treuebonusEinloesen) — nie beides gleichzeitig. Nur eine erfolgreich abgeschickte,
// vollständig bezahlte Bestellung zählt hier; abgebrochene/nicht abgeschickte Bestellungen
// rufen diese Funktionen nie auf.
if (treuebonusDiscount() > 0) {
treuebonusEinloesen();
} else {
registriereAbgeschlosseneBestellung();
// Echte Bestellung in der Datenbank anlegen (siehe functions/api/orders.js) — Phase 1 hat
// kein echtes Zahlungs-Backend, jede vollständig abgeschickte Bestellung gilt sofort als
// "bezahlt" (Server setzt diesen Status automatisch). Erst NACH erfolgreicher Antwort geht
// es weiter — bei einem Fehler bleibt man im Checkout und sieht eine klare Meldung, statt
// trotzdem auf die Danke-Seite zu landen, obwohl die Bestellung nirgends gespeichert wurde.
orderErrorEl.style.display = "none";
submitBtn.disabled = true;
const urspruenglicherBtnText = submitBtn.textContent;
submitBtn.textContent = "…";
const zahlungsart = document.querySelector('input[name="pay"]:checked')?.value || "";
const payload = {
land: landSelect.value,
kunde: {
name: `${document.getElementById("vorname").value.trim()} ${document.getElementById("nachname").value.trim()}`.trim(),
email: document.getElementById("email").value.trim(),
strasse: document.getElementById("strasse").value.trim(),
plz: document.getElementById("plz").value.trim(),
ort: document.getElementById("ort").value.trim(),
},
zahlungsart,
artikel: cart.map((i) => {
const produkt = getProduct(i.slug);
const einzelpreis = i.gratis ? 0 : zeilenpreisFuer(i) / i.menge;
return { slug: i.slug, name: i.name, kategorie: produkt?.kategorie ?? "", menge: i.menge, preis: einzelpreis, gratis: !!i.gratis };
}),
zwischensumme: subtotal,
versandkosten: currentShipping,
rabattGesamt: currentRabattGesamt,
summe: currentTotal,
treuebonusVerwendet: treuebonusDiscount() > 0,
aboRabattVerwendet: aboDiscount(subtotal) > 0,
};
try {
const res = await fetch("/api/orders", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const data = await res.json().catch(() => null);
if (!res.ok || !data?.ok) {
throw new Error(data?.error || "Bestellung konnte nicht gespeichert werden.");
}
// Treuebonus (siehe scripts/account.ts): Eine Bestellung zählt entweder für den NÄCHSTEN
// Durchlauf (registriereAbgeschlosseneBestellung) ODER löst eine bereits wartende Belohnung
// ein (treuebonusEinloesen) — nie beides gleichzeitig. Läuft ERST NACH erfolgreichem
// Speichern der echten Bestellung, damit lokaler Kontostand und echte Bestelldaten nie
// auseinanderlaufen.
if (treuebonusDiscount() > 0) {
treuebonusEinloesen();
} else {
registriereAbgeschlosseneBestellung();
}
location.href = thankYouPath;
} catch (err) {
orderErrorEl.textContent = "⚠️ " + (err instanceof Error ? err.message : "Bestellung konnte nicht gespeichert werden. Bitte nochmal versuchen.");
orderErrorEl.style.display = "block";
submitBtn.disabled = false;
submitBtn.textContent = urspruenglicherBtnText;
}
location.href = thankYouPath;
});
</script>
+66 -10
View File
@@ -180,7 +180,7 @@ const t = useTranslations(lang);
<script type="module">
import { getCart, cartTotal, appliedCoupon, couponDiscount, setCouponCode, clearCoupon, appliedPartnerCode, partnerCodeDiscount, setPartnerCode, clearPartnerCode, totalDiscount, treuebonusDiscount, aboDiscount, istAusgewaehlt, zeilenpreisFuer } from "../../../scripts/cart";
import { formatPrice } from "../../../i18n/format";
import { products } from "../../../data/products";
import { products, getProduct } from "../../../data/products";
import { berechneVersandkosten } from "../../../data/versand";
import { bankverbindung, bankverbindungVollstaendig } from "../../../data/bankverbindung";
import { registriereAbgeschlosseneBestellung, treuebonusEinloesen } from "../../../scripts/account";
@@ -194,6 +194,8 @@ import { registriereAbgeschlosseneBestellung, treuebonusEinloesen } from "../../
const cart = getCart().filter(istAusgewaehlt);
const subtotal = cartTotal();
let currentTotal = 0;
let currentShipping = 0;
let currentRabattGesamt = 0;
function renderSummary() {
const land = landSelect.value;
@@ -206,6 +208,8 @@ import { registriereAbgeschlosseneBestellung, treuebonusEinloesen } from "../../
const aboDiscountBetrag = aboDiscount(subtotal);
const total = Math.max(0, subtotal - totalDiscount(subtotal)) + shipping;
currentTotal = total;
currentShipping = shipping;
currentRabattGesamt = totalDiscount(subtotal);
summary.innerHTML = `
${cart.map((i) => {
@@ -331,20 +335,72 @@ import { registriereAbgeschlosseneBestellung, treuebonusEinloesen } from "../../
renderSummary();
});
document.getElementById("checkout-form").addEventListener("submit", (e) => {
const orderForm = document.getElementById("checkout-form");
const submitBtn = orderForm.querySelector('button[type="submit"]');
const orderErrorEl = document.createElement("p");
orderErrorEl.className = "small";
orderErrorEl.style.color = "var(--c-sale)";
orderErrorEl.style.display = "none";
submitBtn.insertAdjacentElement("beforebegin", orderErrorEl);
orderForm.addEventListener("submit", async (e) => {
e.preventDefault();
if (cart.length === 0) {
alert(emptyCartAlert);
return;
}
// Treuebonus (siehe scripts/account.ts): Eine Bestellung zählt entweder für den NÄCHSTEN
// Durchlauf (registriereAbgeschlosseneBestellung) ODER löst eine bereits wartende Belohnung
// ein (treuebonusEinloesen) — nie beides gleichzeitig.
if (treuebonusDiscount() > 0) {
treuebonusEinloesen();
} else {
registriereAbgeschlosseneBestellung();
orderErrorEl.style.display = "none";
submitBtn.disabled = true;
const urspruenglicherBtnText = submitBtn.textContent;
submitBtn.textContent = "…";
const zahlungsart = document.querySelector('input[name="pay"]:checked')?.value || "";
const payload = {
land: landSelect.value,
kunde: {
name: `${document.getElementById("vorname").value.trim()} ${document.getElementById("nachname").value.trim()}`.trim(),
email: document.getElementById("email").value.trim(),
strasse: document.getElementById("strasse").value.trim(),
plz: document.getElementById("plz").value.trim(),
ort: document.getElementById("ort").value.trim(),
},
zahlungsart,
artikel: cart.map((i) => {
const produkt = getProduct(i.slug);
const einzelpreis = i.gratis ? 0 : zeilenpreisFuer(i) / i.menge;
return { slug: i.slug, name: i.name, kategorie: produkt?.kategorie ?? "", menge: i.menge, preis: einzelpreis, gratis: !!i.gratis };
}),
zwischensumme: subtotal,
versandkosten: currentShipping,
rabattGesamt: currentRabattGesamt,
summe: currentTotal,
treuebonusVerwendet: treuebonusDiscount() > 0,
aboRabattVerwendet: aboDiscount(subtotal) > 0,
};
try {
const res = await fetch("/api/orders", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const data = await res.json().catch(() => null);
if (!res.ok || !data?.ok) {
throw new Error(data?.error || "Bestellung konnte nicht gespeichert werden.");
}
if (treuebonusDiscount() > 0) {
treuebonusEinloesen();
} else {
registriereAbgeschlosseneBestellung();
}
location.href = thankYouPath;
} catch (err) {
orderErrorEl.textContent = "⚠️ " + (err instanceof Error ? err.message : "Bestellung konnte nicht gespeichert werden. Bitte nochmal versuchen.");
orderErrorEl.style.display = "block";
submitBtn.disabled = false;
submitBtn.textContent = urspruenglicherBtnText;
}
location.href = thankYouPath;
});
</script>
+66 -10
View File
@@ -180,7 +180,7 @@ const t = useTranslations(lang);
<script type="module">
import { getCart, cartTotal, appliedCoupon, couponDiscount, setCouponCode, clearCoupon, appliedPartnerCode, partnerCodeDiscount, setPartnerCode, clearPartnerCode, totalDiscount, treuebonusDiscount, aboDiscount, istAusgewaehlt, zeilenpreisFuer } from "../../../scripts/cart";
import { formatPrice } from "../../../i18n/format";
import { products } from "../../../data/products";
import { products, getProduct } from "../../../data/products";
import { berechneVersandkosten } from "../../../data/versand";
import { bankverbindung, bankverbindungVollstaendig } from "../../../data/bankverbindung";
import { registriereAbgeschlosseneBestellung, treuebonusEinloesen } from "../../../scripts/account";
@@ -194,6 +194,8 @@ import { registriereAbgeschlosseneBestellung, treuebonusEinloesen } from "../../
const cart = getCart().filter(istAusgewaehlt);
const subtotal = cartTotal();
let currentTotal = 0;
let currentShipping = 0;
let currentRabattGesamt = 0;
function renderSummary() {
const land = landSelect.value;
@@ -206,6 +208,8 @@ import { registriereAbgeschlosseneBestellung, treuebonusEinloesen } from "../../
const aboDiscountBetrag = aboDiscount(subtotal);
const total = Math.max(0, subtotal - totalDiscount(subtotal)) + shipping;
currentTotal = total;
currentShipping = shipping;
currentRabattGesamt = totalDiscount(subtotal);
summary.innerHTML = `
${cart.map((i) => {
@@ -331,20 +335,72 @@ import { registriereAbgeschlosseneBestellung, treuebonusEinloesen } from "../../
renderSummary();
});
document.getElementById("checkout-form").addEventListener("submit", (e) => {
const orderForm = document.getElementById("checkout-form");
const submitBtn = orderForm.querySelector('button[type="submit"]');
const orderErrorEl = document.createElement("p");
orderErrorEl.className = "small";
orderErrorEl.style.color = "var(--c-sale)";
orderErrorEl.style.display = "none";
submitBtn.insertAdjacentElement("beforebegin", orderErrorEl);
orderForm.addEventListener("submit", async (e) => {
e.preventDefault();
if (cart.length === 0) {
alert(emptyCartAlert);
return;
}
// Treuebonus (siehe scripts/account.ts): Eine Bestellung zählt entweder für den NÄCHSTEN
// Durchlauf (registriereAbgeschlosseneBestellung) ODER löst eine bereits wartende Belohnung
// ein (treuebonusEinloesen) — nie beides gleichzeitig.
if (treuebonusDiscount() > 0) {
treuebonusEinloesen();
} else {
registriereAbgeschlosseneBestellung();
orderErrorEl.style.display = "none";
submitBtn.disabled = true;
const urspruenglicherBtnText = submitBtn.textContent;
submitBtn.textContent = "…";
const zahlungsart = document.querySelector('input[name="pay"]:checked')?.value || "";
const payload = {
land: landSelect.value,
kunde: {
name: `${document.getElementById("vorname").value.trim()} ${document.getElementById("nachname").value.trim()}`.trim(),
email: document.getElementById("email").value.trim(),
strasse: document.getElementById("strasse").value.trim(),
plz: document.getElementById("plz").value.trim(),
ort: document.getElementById("ort").value.trim(),
},
zahlungsart,
artikel: cart.map((i) => {
const produkt = getProduct(i.slug);
const einzelpreis = i.gratis ? 0 : zeilenpreisFuer(i) / i.menge;
return { slug: i.slug, name: i.name, kategorie: produkt?.kategorie ?? "", menge: i.menge, preis: einzelpreis, gratis: !!i.gratis };
}),
zwischensumme: subtotal,
versandkosten: currentShipping,
rabattGesamt: currentRabattGesamt,
summe: currentTotal,
treuebonusVerwendet: treuebonusDiscount() > 0,
aboRabattVerwendet: aboDiscount(subtotal) > 0,
};
try {
const res = await fetch("/api/orders", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const data = await res.json().catch(() => null);
if (!res.ok || !data?.ok) {
throw new Error(data?.error || "Bestellung konnte nicht gespeichert werden.");
}
if (treuebonusDiscount() > 0) {
treuebonusEinloesen();
} else {
registriereAbgeschlosseneBestellung();
}
location.href = thankYouPath;
} catch (err) {
orderErrorEl.textContent = "⚠️ " + (err instanceof Error ? err.message : "Bestellung konnte nicht gespeichert werden. Bitte nochmal versuchen.");
orderErrorEl.style.display = "block";
submitBtn.disabled = false;
submitBtn.textContent = urspruenglicherBtnText;
}
location.href = thankYouPath;
});
</script>
+325 -227
View File
@@ -1,125 +1,18 @@
---
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). ───
// Seite bleibt immer hinter dem Zugangscode, auch wenn der Rest der Seite mal öffentlich wird,
// UND zusätzlich schützt dieselbe Middleware die dazugehörige API unter /api/verwaltung/*). ───
//
// 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),
}));
// Echtes Bestellsystem seit Phase 2 (Cloudflare D1, siehe functions/api/orders.js +
// functions/api/verwaltung/): Diese Seite selbst ist als statische Astro-Seite gebaut (wie die
// ganze restliche Seite), lädt die eigentlichen Bestelldaten aber zur LAUFZEIT per fetch() aus
// der Datenbank (siehe <script> unten) — es gibt hier also KEINE Beispieldaten mehr, sondern
// echte, live einlaufende Bestellungen aus dem Checkout.
---
<Layout
title="Bestellverwaltung"
@@ -131,11 +24,9 @@ const auswertungLaender = (Array.from(landCounts.entries()) as [Land, number][])
<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.
🔒 Interner Bereich, nur für dich und VanVan sichtbar (siehe Zugangscode-Gate) — bleibt
so, auch wenn der Rest der Seite mal öffentlich wird. Alles unten sind ECHTE
Bestellungen aus der Datenbank, live vom Checkout befüllt.
</p>
<span class="eyebrow">Verwaltung</span>
<h1>📊 Bestellverwaltung</h1>
@@ -143,133 +34,340 @@ const auswertungLaender = (Array.from(landCounts.entries()) as [Land, number][])
</div>
</section>
<section class="section-tight">
<section class="section-tight" id="verwaltung-loading">
<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>
<p class="small">⏳ Bestellungen werden geladen …</p>
</div>
</section>
<section class="section-tight" id="verwaltung-error" style="display:none;">
<div class="container">
<p class="todo-note" style="border-color: var(--c-sale); color: var(--c-sale);" id="verwaltung-error-text"></p>
<button type="button" class="btn btn-outline btn-sm" id="verwaltung-retry-btn">🔄 Erneut versuchen</button>
</div>
</section>
<div id="verwaltung-content" style="display:none;">
<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" id="stat-offen">0</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" id="stat-inBearbeitung">0</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" id="stat-versendet">0</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" id="stat-abgeschlossen">0</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" id="stat-umsatz"></span>
<span class="verwaltung-stat-label" id="stat-umsatz-label">Gesamtumsatz</span>
</div>
</div>
</div>
</div>
</section>
</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>
<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 (<span id="filter-count-alle">0</span>)</button>
<button type="button" class="verwaltung-filter-btn" data-filter="offen">Offen (<span id="filter-count-offen">0</span>)</button>
<button type="button" class="verwaltung-filter-btn" data-filter="inBearbeitung">In Bearbeitung (<span id="filter-count-inBearbeitung">0</span>)</button>
<button type="button" class="verwaltung-filter-btn" data-filter="versendet">Versendet (<span id="filter-count-versendet">0</span>)</button>
<button type="button" class="verwaltung-filter-btn" data-filter="abgeschlossen">Abgeschlossen (<span id="filter-count-abgeschlossen">0</span>)</button>
<button type="button" class="verwaltung-filter-btn" data-filter="storniert">Storniert (<span id="filter-count-storniert">0</span>)</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>
))}
<p class="small" id="verwaltung-empty" style="display:none;">Noch keine Bestellungen eingegangen.</p>
<div class="order-list" id="verwaltung-order-list"></div>
</div>
</div>
</section>
</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>
))}
<section class="section-tight" id="verwaltung-auswertung-section">
<div class="container">
<h2>🏆 Meistverkaufte Produkte je Kategorie</h2>
<p class="small" id="verwaltung-kat-empty" style="display:none;">Noch keine ausgewerteten Verkäufe (stornierte Bestellungen zählen nicht mit).</p>
<div class="verwaltung-auswertung-grid" id="verwaltung-kat-grid"></div>
</div>
</div>
</section>
</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>
))}
<section class="section-tight">
<div class="container">
<h2>🌍 Bestellungen nach Land</h2>
<div class="card verwaltung-kat-card">
<div class="verwaltung-bar-list" id="verwaltung-land-list"></div>
</div>
</div>
</div>
</section>
</section>
</div>
</Layout>
<script>
const filterBtns = document.querySelectorAll<HTMLButtonElement>("[data-filter]");
const cards = Array.from(document.querySelectorAll<HTMLElement>("#verwaltung-order-list .order-card"));
<script type="module">
import { getCategory } from "../../data/categories";
import { formatPrice } from "../../i18n/format";
function setFilter(filter: string) {
document.querySelectorAll<HTMLButtonElement>(".verwaltung-filter-btn").forEach((btn) => {
const lang = "de";
const STATUS_LABEL = {
bezahlt: "Bezahlt",
bearbeitung: "In Bearbeitung",
versandVorbereitet: "Versand wird vorbereitet",
versendet: "Versendet",
abgeschlossen: "Abgeschlossen",
storniert: "Storniert",
};
const LAND_NAME = { de: "Deutschland", at: "Österreich", ch: "Schweiz", lu: "Luxemburg" };
const LAND_FLAGGE = { de: "🇩🇪", at: "🇦🇹", ch: "🇨🇭", lu: "🇱🇺" };
function bucket(status) {
if (status === "storniert") return "storniert";
if (status === "bezahlt") return "offen";
if (status === "bearbeitung" || status === "versandVorbereitet") return "inBearbeitung";
if (status === "versendet") return "versendet";
return "abgeschlossen";
}
const loadingEl = document.getElementById("verwaltung-loading");
const errorEl = document.getElementById("verwaltung-error");
const errorTextEl = document.getElementById("verwaltung-error-text");
const contentEl = document.getElementById("verwaltung-content");
let alleBestellungen = [];
async function ladeUndRendere() {
loadingEl.style.display = "";
errorEl.style.display = "none";
contentEl.style.display = "none";
try {
const res = await fetch("/api/verwaltung/orders");
const data = await res.json().catch(() => null);
if (!res.ok || !data?.ok) {
throw new Error(data?.error || `Server antwortete mit Status ${res.status}.`);
}
alleBestellungen = data.orders;
render();
loadingEl.style.display = "none";
contentEl.style.display = "";
} catch (err) {
loadingEl.style.display = "none";
errorEl.style.display = "";
errorTextEl.textContent = "⚠️ Bestellungen konnten nicht geladen werden: " + (err instanceof Error ? err.message : String(err));
}
}
function setText(id, text) {
const el = document.getElementById(id);
if (el) el.textContent = text;
}
function render() {
const aktive = alleBestellungen.filter((b) => b.status !== "storniert");
// ── Buckets für die Stat-Kacheln + Filterleiste ──
const buckets = { offen: 0, inBearbeitung: 0, versendet: 0, abgeschlossen: 0, storniert: 0 };
alleBestellungen.forEach((b) => { buckets[bucket(b.status)]++; });
setText("stat-offen", String(buckets.offen));
setText("stat-inBearbeitung", String(buckets.inBearbeitung));
setText("stat-versendet", String(buckets.versendet));
setText("stat-abgeschlossen", String(buckets.abgeschlossen));
setText("filter-count-alle", String(alleBestellungen.length));
setText("filter-count-offen", String(buckets.offen));
setText("filter-count-inBearbeitung", String(buckets.inBearbeitung));
setText("filter-count-versendet", String(buckets.versendet));
setText("filter-count-abgeschlossen", String(buckets.abgeschlossen));
setText("filter-count-storniert", String(buckets.storniert));
// ── Umsatz: stornierte Bestellungen zählen bewusst NICHT mit. ──
const gesamtumsatz = aktive.reduce((s, b) => s + Number(b.summe), 0);
setText("stat-umsatz", formatPrice(gesamtumsatz, lang));
setText("stat-umsatz-label", `Gesamtumsatz (${aktive.length} Bestellung${aktive.length === 1 ? "" : "en"})`);
// ── Bestellliste ──
const listEl = document.getElementById("verwaltung-order-list");
const emptyEl = document.getElementById("verwaltung-empty");
if (alleBestellungen.length === 0) {
listEl.innerHTML = "";
emptyEl.style.display = "";
} else {
emptyEl.style.display = "none";
listEl.innerHTML = alleBestellungen.map((b) => {
const land = b.land;
const artikelHtml = b.artikel.map((a) =>
`<li><span>${a.menge}× ${escapeHtml(a.name)}${a.gratis ? " <span class=\"small\">(gratis)</span>" : ""}</span><span class="order-item-price">${formatPrice(a.preis * a.menge, lang)}</span></li>`
).join("");
const datum = new Date(b.created_at).toLocaleDateString("de-DE", { year: "numeric", month: "2-digit", day: "2-digit" });
return `
<div class="card order-card order-card-${b.status}" data-bucket="${bucket(b.status)}" data-order-id="${b.id}">
<div class="order-card-head">
<div>
<span class="order-no">${LAND_FLAGGE[land] || ""} ${escapeHtml(b.bestellnummer || `#${b.id}`)}</span>
<span class="small order-meta">${datum} · ${LAND_NAME[land] || land} · ${escapeHtml(b.kunde_name)} · ${escapeHtml(b.kunde_email)}</span>
</div>
<span class="badge order-status order-status-${b.status}">${STATUS_LABEL[b.status] || b.status}</span>
</div>
<ul class="order-items">${artikelHtml}</ul>
<div class="order-card-foot verwaltung-order-foot">
<span class="order-total">Summe: <strong>${formatPrice(Number(b.summe), lang)}</strong></span>
<div class="verwaltung-status-edit">
<select class="verwaltung-status-select" data-order-id="${b.id}">
${Object.entries(STATUS_LABEL).map(([val, label]) => `<option value="${val}" ${val === b.status ? "selected" : ""}>${label}</option>`).join("")}
</select>
<span class="small verwaltung-status-saved" data-order-id="${b.id}" style="display:none;">✅ gespeichert</span>
</div>
</div>
</div>
`;
}).join("");
listEl.querySelectorAll(".verwaltung-status-select").forEach((select) => {
select.addEventListener("change", () => statusAendern(select));
});
}
// ── Auswertung: meistverkaufte Produkte je Kategorie (stornierte Bestellungen ausgeschlossen) ──
const mengeProSlug = new Map(); // slug -> { name, kategorie, menge }
aktive.forEach((b) => b.artikel.forEach((a) => {
const eintrag = mengeProSlug.get(a.produkt_slug) ?? { name: a.name, kategorie: a.kategorie_slug, menge: 0 };
eintrag.menge += a.menge;
mengeProSlug.set(a.produkt_slug, eintrag);
}));
const kategorienMitProdukten = new Map();
mengeProSlug.forEach((eintrag, slug) => {
if (!eintrag.kategorie) return;
const liste = kategorienMitProdukten.get(eintrag.kategorie) ?? [];
liste.push({ slug, name: eintrag.name, menge: eintrag.menge });
kategorienMitProdukten.set(eintrag.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);
const katGrid = document.getElementById("verwaltung-kat-grid");
const katEmpty = document.getElementById("verwaltung-kat-empty");
if (auswertungKategorien.length === 0) {
katGrid.innerHTML = "";
katEmpty.style.display = "";
} else {
katEmpty.style.display = "none";
katGrid.innerHTML = auswertungKategorien.map((k) => `
<div class="card verwaltung-kat-card">
<h3>${k.icon} ${escapeHtml(k.name)}</h3>
<div class="verwaltung-bar-list">
${k.produkte.map((p) => `
<div class="verwaltung-bar-row">
<div class="verwaltung-bar-head"><span>${escapeHtml(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>
`).join("")}
</div>
</div>
`).join("");
}
// ── Auswertung: Bestellungen nach Land (stornierte ausgeschlossen) ──
const landCounts = new Map();
aktive.forEach((b) => landCounts.set(b.land, (landCounts.get(b.land) ?? 0) + 1));
const maxLandCount = Math.max(1, ...Array.from(landCounts.values()));
const auswertungLaender = Array.from(landCounts.entries())
.sort((a, b) => b[1] - a[1])
.map(([land, count]) => ({
land, name: LAND_NAME[land] || land, flagge: LAND_FLAGGE[land] || "",
count, prozent: aktive.length > 0 ? Math.round((count / aktive.length) * 100) : 0,
balken: Math.round((count / maxLandCount) * 100),
}));
const landList = document.getElementById("verwaltung-land-list");
landList.innerHTML = auswertungLaender.length === 0
? `<p class="small">Noch keine Bestellungen.</p>`
: auswertungLaender.map((l) => `
<div class="verwaltung-bar-row">
<div class="verwaltung-bar-head"><span>${l.flagge} ${escapeHtml(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>
`).join("");
anwendenFilter(document.querySelector(".verwaltung-filter-btn.is-active")?.dataset.filter || "alle");
}
function escapeHtml(str) {
return String(str ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
}
async function statusAendern(select) {
const orderId = select.dataset.orderId;
const neuerStatus = select.value;
select.disabled = true;
try {
const res = await fetch(`/api/verwaltung/orders/${orderId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: neuerStatus }),
});
const data = await res.json().catch(() => null);
if (!res.ok || !data?.ok) throw new Error(data?.error || "Speichern fehlgeschlagen.");
// Lokal aktualisieren + alles neu rendern (Buckets/Auswertung/Filter bleiben konsistent),
// statt einen kompletten Server-Reload zu brauchen.
const b = alleBestellungen.find((o) => String(o.id) === String(orderId));
if (b) b.status = neuerStatus;
render();
const savedHint = document.querySelector(`.verwaltung-status-saved[data-order-id="${orderId}"]`);
if (savedHint) {
savedHint.style.display = "inline";
setTimeout(() => { savedHint.style.display = "none"; }, 2000);
}
} catch (err) {
alert("⚠️ Status konnte nicht gespeichert werden: " + (err instanceof Error ? err.message : String(err)));
select.disabled = false;
}
}
function anwendenFilter(filter) {
document.querySelectorAll(".verwaltung-filter-btn").forEach((btn) => {
btn.classList.toggle("is-active", btn.dataset.filter === filter);
});
cards.forEach((card) => {
document.querySelectorAll("#verwaltung-order-list .order-card").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" });
}
}
});
document.addEventListener("click", (e) => {
const btn = e.target.closest("[data-filter]");
if (!btn) return;
const f = btn.dataset.filter;
if (!f) return;
anwendenFilter(f);
if (btn.classList.contains("verwaltung-stat-tile")) {
document.getElementById("verwaltung-order-list")?.scrollIntoView({ behavior: "smooth", block: "start" });
}
});
document.getElementById("verwaltung-retry-btn")?.addEventListener("click", ladeUndRendere);
ladeUndRendere();
</script>