Files
vans-diy-bastelbedarf/src/pages/verwaltung/index.astro
T
qciganoandClaude Sonnet 5 056ff6e574 Verwaltung: neue Kennzahlen-Kachel "Diesen Monat"
Bisher gab es nur "Heute"/"Letzte 7 Tage" — der ganze aktuelle Kalendermonat
fehlte. Neue Kachel zeigt Bestellanzahl + Umsatz diesen Monat, anklickbar wie
die anderen Kennzahlen-Kacheln (filtert die Bestellliste direkt danach). Auch
als neue Option "Dieser Monat" im Zeitraum-Dropdown ergänzt — bewusst als
echter Kalendermonat (ab dem 1.) berechnet, nicht einfach "letzte 30 Tage".

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-03 23:26:44 +02:00

950 lines
50 KiB
Plaintext
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 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,
// UND zusätzlich schützt dieselbe Middleware die dazugehörige API unter /api/verwaltung/*). ───
//
// 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.
//
// 03.08.2026, zweite Ausbaustufe: auf ausdrücklichen Wunsch ("perfektioniere diese Seite aufs
// Maximum") um alles erweitert, was ein echtes kleines Shop-Team im Alltag wirklich braucht und
// vorher fehlte — Suche, Zeitraum-Filter, Sortierung, CSV-Export für die Buchhaltung, direkt
// editierbare Tracking-Nummer, Ein-Klick-Kopieren von E-Mail/Adresse fürs Versandlabel, eine
// echte PDF-Rechnung pro Bestellung (nutzt dieselbe jsPDF-Funktion wie im Kundenkonto), Warnung
// bei offenen Bestellungen, die schon länger liegen, Stammkundinnen-Kennzeichnung, und ein
// dezenter Hinweis, wenn im Hintergrund neue Bestellungen eingetrudelt sind.
---
<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) — 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>
<div class="verwaltung-hero">
<span class="verwaltung-hero-sparkle verwaltung-hero-sparkle-1" aria-hidden="true">✨</span>
<span class="verwaltung-hero-sparkle verwaltung-hero-sparkle-2" aria-hidden="true">✨</span>
<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>
</div>
</section>
<section class="section-tight" id="verwaltung-loading">
<div class="container">
<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">
{/* Jede zusammengehörige Gruppe bekommt jetzt ihre eigene, farblich eigenständige "Kiste"
(siehe .verwaltung-box in global.css) — statt loser Kacheln/Absätze auf der Seite. */}
<div class="verwaltung-box" style="--box-color: var(--c-accent);">
<div class="verwaltung-box-head">
<span class="verwaltung-box-icon" aria-hidden="true">📊</span>
<h2>Bestellstatus</h2>
</div>
<div class="verwaltung-box-body">
<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>
{/* Zweite Kennzahlen-Kiste — Dinge, die im ersten Entwurf fehlten, aber im Alltag sehr
wichtig sind: wie viel kommt gerade neu rein, und wie hoch ist der durchschnittliche
Bestellwert (wichtig für Preis-/Werbeentscheidungen). "Heute"/"7 Tage" sind zusätzlich
anklickbar und setzen direkt den Zeitraum-Filter unten. */}
<div class="verwaltung-box" style="--box-color: #9b7fd6;">
<div class="verwaltung-box-head">
<span class="verwaltung-box-icon" aria-hidden="true">📈</span>
<h2>Kennzahlen</h2>
</div>
<div class="verwaltung-box-body">
<div class="verwaltung-stat-grid verwaltung-stat-grid-secondary">
<button type="button" class="card verwaltung-stat-tile verwaltung-stat-tile-mini" data-zeitraum="heute">
<span class="verwaltung-stat-icon" aria-hidden="true">📅</span>
<span class="verwaltung-stat-value" id="stat-heute">0</span>
<span class="verwaltung-stat-label">Bestellungen heute</span>
</button>
<button type="button" class="card verwaltung-stat-tile verwaltung-stat-tile-mini" data-zeitraum="7tage">
<span class="verwaltung-stat-icon" aria-hidden="true">🗓️</span>
<span class="verwaltung-stat-value" id="stat-7tage">0</span>
<span class="verwaltung-stat-label">Letzte 7 Tage</span>
</button>
<button type="button" class="card verwaltung-stat-tile verwaltung-stat-tile-mini" data-zeitraum="monat">
<span class="verwaltung-stat-icon" aria-hidden="true">📆</span>
<span class="verwaltung-stat-value" id="stat-monat">0</span>
<span class="verwaltung-stat-label" id="stat-monat-label">Diesen Monat</span>
</button>
<div class="card verwaltung-stat-tile verwaltung-stat-tile-mini">
<span class="verwaltung-stat-icon" aria-hidden="true">📈</span>
<span class="verwaltung-stat-value" id="stat-avg"></span>
<span class="verwaltung-stat-label">Ø Bestellwert</span>
</div>
<div class="card verwaltung-stat-tile verwaltung-stat-tile-mini" id="stat-attention-tile" style="display:none;">
<span class="verwaltung-stat-icon" aria-hidden="true">⚠️</span>
<span class="verwaltung-stat-value" id="stat-attention">0</span>
<span class="verwaltung-stat-label">Brauchen Aufmerksamkeit</span>
</div>
</div>
</div>
</div>
{/* Auswertung (Bestseller/Herkunft/Stammkundschaft): jede Kiste ist jetzt SELBST der
Klick-Auslöser (Wunsch: "es sollten Kisten sein wo man drauf drückt und dann gehen die
anderen Sachen auf") — kein separater Knopf mehr davor. Jede Box startet eingeklappt
(nur Kopfzeile sichtbar) und klappt beim Klick auf die Kopfzeile ihren eigenen Inhalt
auf, unabhängig von den anderen beiden Kisten. Bewusst in DERSELBEN Section wie die
Bestellstatus-/Kennzahlen-Kisten oben (nicht in einer eigenen <section>), sonst
addieren sich die Section-Innenabstände zu einer viel zu großen Lücke dazwischen. */}
<div id="verwaltung-auswertung-wrapper">
<div class="verwaltung-box verwaltung-box-collapsible is-collapsed" style="--box-color: #e3b23c;" data-collapsible-box>
<button type="button" class="verwaltung-box-head verwaltung-box-head-btn" aria-expanded="false">
<span class="verwaltung-box-icon" aria-hidden="true">🏆</span>
<h2>Bestseller je Kategorie</h2>
<span class="verwaltung-box-chevron" aria-hidden="true">▾</span>
</button>
<div class="verwaltung-box-body">
<div class="verwaltung-box-body-inner">
<div class="verwaltung-box-empty" id="verwaltung-kat-empty" style="display:none;">
<span class="verwaltung-box-empty-icon" aria-hidden="true">🧶</span>
<p>Noch keine ausgewerteten Verkäufe.</p>
<p class="small">Hier siehst du bald, welche Produkte je Kategorie am besten laufen (stornierte Bestellungen zählen nicht mit).</p>
</div>
{/* Jede Kategorie ist eine eigene Kachel im selben Stil wie die Bestellstatus-
Kacheln oben — Klick öffnet direkt darunter die volle Produktliste. */}
<div class="verwaltung-analyse-grid" id="verwaltung-kat-grid"></div>
<div class="verwaltung-analyse-detail" id="verwaltung-kat-detail" hidden></div>
</div>
</div>
</div>
<div class="verwaltung-box verwaltung-box-collapsible is-collapsed" style="--box-color: #6ecb8f;" data-collapsible-box>
<button type="button" class="verwaltung-box-head verwaltung-box-head-btn" aria-expanded="false">
<span class="verwaltung-box-icon" aria-hidden="true">🌍</span>
<h2>Herkunft der Bestellungen</h2>
<span class="verwaltung-box-chevron" aria-hidden="true">▾</span>
</button>
<div class="verwaltung-box-body">
<div class="verwaltung-box-body-inner">
<div class="verwaltung-box-empty" id="verwaltung-land-empty" style="display:none;">
<span class="verwaltung-box-empty-icon" aria-hidden="true">🗺️</span>
<p>Noch keine Bestellungen.</p>
<p class="small">Sobald Bestellungen eingehen, siehst du hier den Anteil je Land (DE/AT/CH/LU).</p>
</div>
<div class="verwaltung-analyse-grid" id="verwaltung-land-grid"></div>
<div class="verwaltung-analyse-detail" id="verwaltung-land-detail" hidden></div>
</div>
</div>
</div>
<div class="verwaltung-box verwaltung-box-collapsible is-collapsed" style="--box-color: var(--c-sale);" data-collapsible-box>
<button type="button" class="verwaltung-box-head verwaltung-box-head-btn" aria-expanded="false">
<span class="verwaltung-box-icon" aria-hidden="true">👑</span>
<h2>Stammkundschaft</h2>
<span class="verwaltung-box-chevron" aria-hidden="true">▾</span>
</button>
<div class="verwaltung-box-body">
<div class="verwaltung-box-body-inner">
<div class="verwaltung-box-empty" id="verwaltung-kunden-empty" style="display:none;">
<span class="verwaltung-box-empty-icon" aria-hidden="true">💌</span>
<p>Noch keine wiederkehrende Kundschaft.</p>
<p class="small">Sobald jemand zum zweiten Mal bestellt, erscheint sie hier — sortiert nach bisherigem Umsatz.</p>
</div>
<div class="verwaltung-analyse-grid" id="verwaltung-kunden-grid"></div>
<div class="verwaltung-analyse-detail" id="verwaltung-kunden-detail" hidden></div>
</div>
</div>
</div>
</div>
</div>
</section>
<section class="section-tight">
<div class="container">
<div class="verwaltung-box" style="--box-color: var(--c-accent);">
<div class="verwaltung-box-head">
<span class="verwaltung-box-icon" aria-hidden="true">📦</span>
<h2>Alle Bestellungen</h2>
</div>
<div class="verwaltung-box-body">
<div class="verwaltung-new-banner" id="verwaltung-new-banner" style="display:none;">
<span>🔔 <strong id="verwaltung-new-count"></strong> neue Bestellung(en) seit dem letzten Laden</span>
<button type="button" class="btn btn-primary btn-sm" id="verwaltung-new-refresh-btn">Jetzt aktualisieren</button>
</div>
<div class="verwaltung-toolbar">
<input type="search" id="verwaltung-search" class="verwaltung-input" placeholder="🔍 Suche nach Bestellnummer, Name oder E-Mail …" />
<select id="verwaltung-zeitraum" class="verwaltung-input verwaltung-select">
<option value="alle">Alle Zeiträume</option>
<option value="heute">Heute</option>
<option value="7tage">Letzte 7 Tage</option>
<option value="monat">Dieser Monat</option>
<option value="30tage">Letzte 30 Tage</option>
</select>
<select id="verwaltung-sort" class="verwaltung-input verwaltung-select">
<option value="neu">Neueste zuerst</option>
<option value="alt">Älteste zuerst</option>
<option value="wert-ab">Höchster Wert zuerst</option>
<option value="wert-auf">Niedrigster Wert zuerst</option>
</select>
<button type="button" class="btn btn-outline btn-sm" id="verwaltung-csv-btn">📄 CSV-Export</button>
</div>
<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="verwaltung-box-empty" id="verwaltung-empty" style="display:none;">
<span class="verwaltung-box-empty-icon" aria-hidden="true">📭</span>
<p>Noch keine Bestellungen eingegangen.</p>
<p class="small">Sobald die erste Bestellung im Checkout abgeschickt wird, erscheint sie sofort hier.</p>
</div>
<p class="small" id="verwaltung-no-match" style="display:none;">Keine Bestellung passt zur aktuellen Suche/Filterung.</p>
<div class="order-list" id="verwaltung-order-list"></div>
</div>
</div>
</div>
</section>
</div>
</Layout>
<script type="module">
import { getCategory } from "../../data/categories";
import { formatPrice } from "../../i18n/format";
import { rechnungAlsPdfHerunterladen } from "../../scripts/invoice-pdf";
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: "🇱🇺" };
const ZAHLUNGSART_LABEL = { paypal: "💙 PayPal", klarna: "🩷 Klarna", kreditkarte: "💳 Kreditkarte", ueberweisung: "🏦 Überweisung" };
const INVOICE_LABELS = {
title: "Rechnung", numberLabel: "Rechnungsnummer", dateLabel: "Rechnungsdatum",
sellerLabel: "Verkäufer", billToLabel: "Rechnungsempfänger",
itemLabel: "Artikel", qtyLabel: "Menge", unitPriceLabel: "Einzelpreis", sumLabel: "Gesamtbetrag",
totalLabel: "Gesamt", vatNote: "Gemäß §19 UStG wird keine Umsatzsteuer berechnet und ausgewiesen.",
};
// Ab wie vielen Tagen eine noch offene ("bezahlt") Bestellung als "braucht Aufmerksamkeit"
// markiert wird — soll verhindern, dass mal eine Bestellung vergessen wird.
const ALTER_SCHWELLE_TAGE = 3;
const MS_PRO_TAG = 24 * 60 * 60 * 1000;
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");
const searchInput = document.getElementById("verwaltung-search");
const zeitraumSelect = document.getElementById("verwaltung-zeitraum");
const sortSelect = document.getElementById("verwaltung-sort");
let alleBestellungen = [];
let bekannteIds = new Set();
let erstesLaden = true;
// Merkt sich die zuletzt berechneten Auswertungsdaten, damit die Detail-Panels (Klick auf eine
// Kategorie-/Land-/Kundschafts-Kachel) darauf zugreifen können, ohne alles neu zu berechnen.
let letzteKategorien = [];
let letzteLaender = [];
let letzteKunden = [];
let letzteAktive = [];
async function ladeUndRendere() {
loadingEl.style.display = "";
errorEl.style.display = "none";
document.getElementById("verwaltung-new-banner").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;
bekannteIds = new Set(alleBestellungen.map((b) => b.id));
erstesLaden = false;
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));
}
}
// Leiser Hintergrund-Check alle 45s: lädt neu, vergleicht nur die IDs, ERSETZT aber nicht
// sofort die Ansicht (damit man beim Durchsehen/Filtern nicht unterbrochen wird) — stattdessen
// erscheint nur ein dezenter Hinweis-Banner mit "Jetzt aktualisieren"-Knopf.
async function pruefeNeueBestellungenImHintergrund() {
if (erstesLaden || document.hidden) return;
try {
const res = await fetch("/api/verwaltung/orders");
const data = await res.json().catch(() => null);
if (!res.ok || !data?.ok) return;
const neue = data.orders.filter((b) => !bekannteIds.has(b.id));
if (neue.length > 0) {
const banner = document.getElementById("verwaltung-new-banner");
document.getElementById("verwaltung-new-count").textContent = String(neue.length);
banner.style.display = "flex";
}
} catch {
// Stiller Fehlschlag — der nächste reguläre Refresh/Retry fängt es wieder ein, kein Grund
// die Ansicht deswegen zu stören.
}
}
setInterval(pruefeNeueBestellungenImHintergrund, 45000);
function setText(id, text) {
const el = document.getElementById(id);
if (el) el.textContent = text;
}
function sortiere(liste, modus) {
const kopie = [...liste];
switch (modus) {
case "alt": return kopie.sort((a, b) => new Date(a.created_at) - new Date(b.created_at));
case "wert-ab": return kopie.sort((a, b) => Number(b.summe) - Number(a.summe));
case "wert-auf": return kopie.sort((a, b) => Number(a.summe) - Number(b.summe));
default: return kopie.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
}
}
function render() {
const aktive = alleBestellungen.filter((b) => b.status !== "storniert");
letzteAktive = aktive;
// Die Auswertungs-Detail-Panels zeigen ggf. veraltete Daten, sobald sich die Bestellliste
// ändert (z.B. neuer Status) — bei jedem Rendern lieber wieder einklappen, statt falsche
// Zahlen stehen zu lassen. Ein erneuter Klick zeigt die aktuellen Daten sofort wieder an.
["kat", "land", "kunden"].forEach((gruppe) => {
document.getElementById(`verwaltung-${gruppe}-detail`)?.setAttribute("hidden", "");
});
document.querySelectorAll(".verwaltung-stat-tile.is-active").forEach((t) => t.classList.remove("is-active"));
// ── 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 + Ø Bestellwert: 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"})`);
setText("stat-avg", aktive.length > 0 ? formatPrice(gesamtumsatz / aktive.length, lang) : "");
// ── Heute / letzte 7 Tage / diesen Monat + "braucht Aufmerksamkeit" ──
const jetzt = Date.now();
const heuteStr = new Date().toLocaleDateString("de-DE");
const heuteCount = aktive.filter((b) => new Date(b.created_at).toLocaleDateString("de-DE") === heuteStr).length;
const siebenTageCount = aktive.filter((b) => jetzt - new Date(b.created_at).getTime() <= 7 * MS_PRO_TAG).length;
// "Diesen Monat" = echter Kalendermonat (1. bis heute), nicht "letzte 30 Tage" — deshalb ein
// eigenes Feld statt einfach den 30-Tage-Wert zu verwenden.
const heuteDatum = new Date();
const monatsStart = new Date(heuteDatum.getFullYear(), heuteDatum.getMonth(), 1).getTime();
const monatCount = aktive.filter((b) => new Date(b.created_at).getTime() >= monatsStart).length;
const monatUmsatz = aktive.filter((b) => new Date(b.created_at).getTime() >= monatsStart).reduce((s, b) => s + Number(b.summe), 0);
setText("stat-heute", String(heuteCount));
setText("stat-7tage", String(siebenTageCount));
setText("stat-monat", String(monatCount));
setText(
"stat-monat-label",
`Diesen Monat${monatCount > 0 ? ` (${formatPrice(monatUmsatz, lang)})` : ""}`
);
const attentionIds = new Set(
aktive.filter((b) => b.status === "bezahlt" && jetzt - new Date(b.created_at).getTime() > ALTER_SCHWELLE_TAGE * MS_PRO_TAG).map((b) => b.id)
);
const attentionTile = document.getElementById("stat-attention-tile");
if (attentionIds.size > 0) {
setText("stat-attention", String(attentionIds.size));
attentionTile.style.display = "";
} else {
attentionTile.style.display = "none";
}
// ── Stammkundinnen: E-Mail kommt in mehr als einer Bestellung vor. ──
const emailCounts = new Map();
alleBestellungen.forEach((b) => emailCounts.set(b.kunde_email, (emailCounts.get(b.kunde_email) ?? 0) + 1));
// ── Bestellliste (sortiert nach aktuell gewähltem Modus) ──
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";
const sortiert = sortiere(alleBestellungen, sortSelect.value);
listEl.innerHTML = sortiert.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" });
const istNeu = jetzt - new Date(b.created_at).getTime() < MS_PRO_TAG;
const brauchtAufmerksamkeit = attentionIds.has(b.id);
const istStammkundin = (emailCounts.get(b.kunde_email) ?? 0) > 1;
const adresse = [b.strasse, `${b.plz} ${b.ort}`.trim(), LAND_NAME[land] || land].filter(Boolean).join(", ");
const suchtext = `${b.bestellnummer ?? ""} ${b.kunde_name} ${b.kunde_email}`.toLowerCase();
return `
<div class="card order-card order-card-${b.status}${brauchtAufmerksamkeit ? " order-card-attention" : ""}" data-bucket="${bucket(b.status)}" data-order-id="${b.id}" data-created="${b.created_at}" data-search="${escapeHtml(suchtext)}">
<div class="order-card-head">
<div>
<span class="order-no">${LAND_FLAGGE[land] || ""} ${escapeHtml(b.bestellnummer || `#${b.id}`)}</span>
${istNeu ? `<span class="verwaltung-chip verwaltung-chip-new">🆕 Neu</span>` : ""}
${istStammkundin ? `<span class="verwaltung-chip verwaltung-chip-stamm">🔁 Stammkundin</span>` : ""}
${brauchtAufmerksamkeit ? `<span class="verwaltung-chip verwaltung-chip-attention">⚠️ Braucht Aufmerksamkeit</span>` : ""}
<br />
<span class="small order-meta">${datum} · ${LAND_NAME[land] || land} · ${escapeHtml(b.kunde_name)} · ${escapeHtml(b.kunde_email)}${b.zahlungsart ? ` · ${ZAHLUNGSART_LABEL[b.zahlungsart] || escapeHtml(b.zahlungsart)}` : ""}</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="verwaltung-copy-row">
<button type="button" class="btn btn-outline btn-xs verwaltung-copy-btn" data-copy="${escapeHtml(b.kunde_email)}">📧 E-Mail kopieren</button>
<button type="button" class="btn btn-outline btn-xs verwaltung-copy-btn" data-copy="${escapeHtml(`${b.kunde_name}\n${adresse}`)}">📍 Adresse kopieren</button>
<button type="button" class="btn btn-outline btn-xs verwaltung-pdf-btn" data-order-id="${b.id}">🧾 Rechnung als PDF</button>
<span class="small verwaltung-copy-hint" data-hint-for="${b.id}" style="display:none;">✅ kopiert</span>
</div>
<div class="order-card-foot verwaltung-order-foot">
<span class="order-total">Summe: <strong>${formatPrice(Number(b.summe), lang)}</strong></span>
<div class="verwaltung-tracking-edit">
<input type="text" class="verwaltung-input verwaltung-tracking-input" data-order-id="${b.id}" placeholder="Tracking-Nummer" value="${escapeHtml(b.tracking || "")}" />
<button type="button" class="btn btn-outline btn-xs verwaltung-tracking-save" data-order-id="${b.id}">💾</button>
</div>
<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));
});
listEl.querySelectorAll(".verwaltung-copy-btn").forEach((btn) => {
btn.addEventListener("click", () => kopieren(btn));
});
listEl.querySelectorAll(".verwaltung-pdf-btn").forEach((btn) => {
btn.addEventListener("click", () => rechnungDrucken(btn.dataset.orderId));
});
listEl.querySelectorAll(".verwaltung-tracking-save").forEach((btn) => {
btn.addEventListener("click", () => trackingSpeichern(btn.dataset.orderId));
});
listEl.querySelectorAll(".verwaltung-tracking-input").forEach((input) => {
input.addEventListener("keydown", (e) => { if (e.key === "Enter") trackingSpeichern(input.dataset.orderId); });
});
}
// ── 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),
// Volle Liste (nicht mehr auf 4 gekürzt) — die Kachel selbst zeigt nur die Summe, die
// vollständige Rangliste erscheint erst im aufklappbaren Detail-Panel darunter.
produkte: sortiert.map((p) => ({ ...p, prozent: Math.round((p.menge / maxMenge) * 100) })),
};
})
.sort((a, b) => b.gesamtMenge - a.gesamtMenge);
letzteKategorien = auswertungKategorien;
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) => `
<button type="button" class="card verwaltung-stat-tile" data-kat-tile="${escapeHtml(k.slug)}">
<span class="verwaltung-stat-icon" aria-hidden="true">${k.icon}</span>
<span class="verwaltung-stat-value">${k.gesamtMenge}</span>
<span class="verwaltung-stat-label">${escapeHtml(k.name)}</span>
</button>
`).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),
}));
letzteLaender = auswertungLaender;
const landGrid = document.getElementById("verwaltung-land-grid");
const landEmpty = document.getElementById("verwaltung-land-empty");
if (auswertungLaender.length === 0) {
landGrid.innerHTML = "";
landEmpty.style.display = "";
} else {
landEmpty.style.display = "none";
landGrid.innerHTML = auswertungLaender.map((l) => `
<button type="button" class="card verwaltung-stat-tile" data-land-tile="${l.land}">
<span class="verwaltung-stat-icon" aria-hidden="true">${l.flagge}</span>
<span class="verwaltung-stat-value">${l.count}</span>
<span class="verwaltung-stat-label">${escapeHtml(l.name)} · ${l.prozent}%</span>
</button>
`).join("");
}
// ── NEU: Beste Kundinnen (Umsatz pro E-Mail, nur wer mehr als 1× bestellt hat) ──
const umsatzProKundin = new Map(); // email -> { name, summe, anzahl }
aktive.forEach((b) => {
const eintrag = umsatzProKundin.get(b.kunde_email) ?? { name: b.kunde_name, summe: 0, anzahl: 0 };
eintrag.summe += Number(b.summe);
eintrag.anzahl += 1;
umsatzProKundin.set(b.kunde_email, eintrag);
});
const besteKunden = Array.from(umsatzProKundin.entries())
.filter(([, v]) => v.anzahl > 1)
.map(([email, v]) => ({ email, ...v }))
.sort((a, b) => b.summe - a.summe)
.slice(0, 8);
letzteKunden = besteKunden;
const kundenEmpty = document.getElementById("verwaltung-kunden-empty");
const kundenGrid = document.getElementById("verwaltung-kunden-grid");
if (besteKunden.length === 0) {
kundenGrid.innerHTML = "";
kundenEmpty.style.display = "";
} else {
kundenEmpty.style.display = "none";
kundenGrid.innerHTML = besteKunden.map((k) => `
<button type="button" class="card verwaltung-stat-tile" data-kunden-tile="${escapeHtml(k.email)}">
<span class="verwaltung-stat-icon" aria-hidden="true">👑</span>
<span class="verwaltung-stat-value">${formatPrice(k.summe, lang)}</span>
<span class="verwaltung-stat-label">${escapeHtml(k.name)} · ${k.anzahl}×</span>
</button>
`).join("");
}
wendeFilterAn();
}
// ── Detail-Panels für die Auswertungs-Kacheln: erscheinen genau unter der jeweiligen Kachel-
// Reihe (siehe .verwaltung-analyse-detail), sobald man draufklickt — mit der VOLLSTÄNDIGEN
// Liste (nicht mehr nur den Top 4/8 wie vorher direkt auf der Kachel). ──
function zeigeKatDetail(slug) {
const k = letzteKategorien.find((x) => x.slug === slug);
const panel = document.getElementById("verwaltung-kat-detail");
if (!k) { panel.setAttribute("hidden", ""); return; }
panel.innerHTML = `
<div class="verwaltung-analyse-detail-head">
<h3>${k.icon} ${escapeHtml(k.name)} — ${k.produkte.length} Produkt${k.produkte.length === 1 ? "" : "e"}</h3>
<button type="button" class="verwaltung-analyse-close" data-close-detail="kat" aria-label="Schließen">✕</button>
</div>
<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>
`;
panel.removeAttribute("hidden");
document.querySelectorAll("[data-kat-tile]").forEach((t) => t.classList.toggle("is-active", t.dataset.katTile === slug));
}
function zeigeLandDetail(land) {
const l = letzteLaender.find((x) => x.land === land);
const panel = document.getElementById("verwaltung-land-detail");
if (!l) { panel.setAttribute("hidden", ""); return; }
const bestellungen = letzteAktive.filter((b) => b.land === land).sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
panel.innerHTML = `
<div class="verwaltung-analyse-detail-head">
<h3>${l.flagge} ${escapeHtml(l.name)} — ${bestellungen.length} Bestellung${bestellungen.length === 1 ? "" : "en"}</h3>
<button type="button" class="verwaltung-analyse-close" data-close-detail="land" aria-label="Schließen">✕</button>
</div>
${bestellungen.map((b) => `
<div class="verwaltung-analyse-order-row">
<span>${escapeHtml(b.bestellnummer || `#${b.id}`)} · ${escapeHtml(b.kunde_name)}</span>
<span class="small">${new Date(b.created_at).toLocaleDateString("de-DE")} · ${formatPrice(Number(b.summe), lang)}</span>
</div>
`).join("")}
`;
panel.removeAttribute("hidden");
document.querySelectorAll("[data-land-tile]").forEach((t) => t.classList.toggle("is-active", t.dataset.landTile === land));
}
function zeigeKundeDetail(email) {
const k = letzteKunden.find((x) => x.email === email);
const panel = document.getElementById("verwaltung-kunden-detail");
if (!k) { panel.setAttribute("hidden", ""); return; }
const bestellungen = letzteAktive.filter((b) => b.kunde_email === email).sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
panel.innerHTML = `
<div class="verwaltung-analyse-detail-head">
<h3>👑 ${escapeHtml(k.name)} <span class="small">(${escapeHtml(email)})</span> — ${bestellungen.length} Bestellung${bestellungen.length === 1 ? "" : "en"}</h3>
<button type="button" class="verwaltung-analyse-close" data-close-detail="kunden" aria-label="Schließen">✕</button>
</div>
${bestellungen.map((b) => `
<div class="verwaltung-analyse-order-row">
<span>${escapeHtml(b.bestellnummer || `#${b.id}`)}</span>
<span class="small">${new Date(b.created_at).toLocaleDateString("de-DE")} · ${formatPrice(Number(b.summe), lang)}</span>
</div>
`).join("")}
`;
panel.removeAttribute("hidden");
document.querySelectorAll("[data-kunden-tile]").forEach((t) => t.classList.toggle("is-active", t.dataset.kundenTile === email));
}
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.");
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;
}
}
async function trackingSpeichern(orderId) {
const input = document.querySelector(`.verwaltung-tracking-input[data-order-id="${orderId}"]`);
const saveBtn = document.querySelector(`.verwaltung-tracking-save[data-order-id="${orderId}"]`);
if (!input) return;
saveBtn.disabled = true;
try {
const res = await fetch(`/api/verwaltung/orders/${orderId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ tracking: input.value.trim() }),
});
const data = await res.json().catch(() => null);
if (!res.ok || !data?.ok) throw new Error(data?.error || "Speichern fehlgeschlagen.");
const b = alleBestellungen.find((o) => String(o.id) === String(orderId));
if (b) b.tracking = input.value.trim();
saveBtn.textContent = "✅";
setTimeout(() => { saveBtn.textContent = "💾"; }, 1500);
} catch (err) {
alert("⚠️ Tracking-Nummer konnte nicht gespeichert werden: " + (err instanceof Error ? err.message : String(err)));
} finally {
saveBtn.disabled = false;
}
}
async function kopieren(btn) {
const text = btn.dataset.copy;
try {
await navigator.clipboard.writeText(text);
const card = btn.closest(".order-card");
const hint = card?.querySelector(".verwaltung-copy-hint");
if (hint) {
hint.style.display = "inline";
setTimeout(() => { hint.style.display = "none"; }, 1600);
}
} catch {
alert("Kopieren nicht möglich — bitte manuell markieren:\n\n" + text);
}
}
function rechnungDrucken(orderId) {
const b = alleBestellungen.find((o) => String(o.id) === String(orderId));
if (!b) return;
rechnungAlsPdfHerunterladen({
nummer: b.bestellnummer || `#${b.id}`,
datum: new Date(b.created_at).toLocaleDateString("de-DE"),
artikel: b.artikel.map((a) => ({ name: a.name, menge: a.menge, preis: a.preis })),
summe: Number(b.summe),
empfaenger: b.kunde_name,
lang: "de",
labels: INVOICE_LABELS,
});
}
// ── Kombinierter Filter: Status-Kachel/Filterleiste + Suche + Zeitraum wirken alle
// gleichzeitig (UND-Verknüpft), rein clientseitig über die schon gerenderten Karten. ──
let aktuellerStatusFilter = "alle";
function wendeFilterAn() {
const suchtext = searchInput.value.trim().toLowerCase();
const zeitraum = zeitraumSelect.value;
const jetzt = Date.now();
const zeitraumMs = zeitraum === "heute" ? MS_PRO_TAG : zeitraum === "7tage" ? 7 * MS_PRO_TAG : zeitraum === "30tage" ? 30 * MS_PRO_TAG : null;
// "Dieser Monat" ist kein festes Zeitfenster (wie die anderen), sondern der echte
// Kalendermonat ab dem 1. — deshalb separat berechnet statt über zeitraumMs.
const heuteDatum = new Date();
const monatsStart = zeitraum === "monat" ? new Date(heuteDatum.getFullYear(), heuteDatum.getMonth(), 1).getTime() : null;
let sichtbar = 0;
document.querySelectorAll("#verwaltung-order-list .order-card").forEach((card) => {
const matchStatus = aktuellerStatusFilter === "alle" || card.dataset.bucket === aktuellerStatusFilter;
const matchSuche = !suchtext || card.dataset.search.includes(suchtext);
const erstelltMs = new Date(card.dataset.created).getTime();
const matchZeitraum = monatsStart !== null
? erstelltMs >= monatsStart
: !zeitraumMs || jetzt - erstelltMs <= zeitraumMs;
const zeigen = matchStatus && matchSuche && matchZeitraum;
card.style.display = zeigen ? "" : "none";
if (zeigen) sichtbar++;
});
const hatBestellungen = alleBestellungen.length > 0;
document.getElementById("verwaltung-no-match").style.display = hatBestellungen && sichtbar === 0 ? "" : "none";
}
document.addEventListener("click", (e) => {
const filterBtn = e.target.closest("[data-filter]");
if (filterBtn) {
const f = filterBtn.dataset.filter;
if (f) {
aktuellerStatusFilter = f;
document.querySelectorAll(".verwaltung-filter-btn").forEach((b) => b.classList.toggle("is-active", b.dataset.filter === f));
wendeFilterAn();
if (filterBtn.classList.contains("verwaltung-stat-tile")) {
document.getElementById("verwaltung-order-list")?.scrollIntoView({ behavior: "smooth", block: "start" });
}
}
return;
}
const zeitraumBtn = e.target.closest("[data-zeitraum]");
if (zeitraumBtn) {
zeitraumSelect.value = zeitraumBtn.dataset.zeitraum;
wendeFilterAn();
document.getElementById("verwaltung-order-list")?.scrollIntoView({ behavior: "smooth", block: "start" });
return;
}
// ── Auswertungs-Kacheln: Klick öffnet das Detail-Panel direkt darunter, nochmal draufklicken
// klappt es wieder ein. ──
const katTile = e.target.closest("[data-kat-tile]");
if (katTile) {
const schonOffen = katTile.classList.contains("is-active");
if (schonOffen) {
document.getElementById("verwaltung-kat-detail").setAttribute("hidden", "");
katTile.classList.remove("is-active");
} else {
zeigeKatDetail(katTile.dataset.katTile);
}
return;
}
const landTile = e.target.closest("[data-land-tile]");
if (landTile) {
const schonOffen = landTile.classList.contains("is-active");
if (schonOffen) {
document.getElementById("verwaltung-land-detail").setAttribute("hidden", "");
landTile.classList.remove("is-active");
} else {
zeigeLandDetail(landTile.dataset.landTile);
}
return;
}
const kundenTile = e.target.closest("[data-kunden-tile]");
if (kundenTile) {
const schonOffen = kundenTile.classList.contains("is-active");
if (schonOffen) {
document.getElementById("verwaltung-kunden-detail").setAttribute("hidden", "");
kundenTile.classList.remove("is-active");
} else {
zeigeKundeDetail(kundenTile.dataset.kundenTile);
}
return;
}
const closeDetailBtn = e.target.closest("[data-close-detail]");
if (closeDetailBtn) {
const gruppe = closeDetailBtn.dataset.closeDetail;
document.getElementById(`verwaltung-${gruppe}-detail`)?.setAttribute("hidden", "");
document.querySelectorAll(`[data-${gruppe}-tile]`).forEach((t) => t.classList.remove("is-active"));
return;
}
});
searchInput.addEventListener("input", wendeFilterAn);
zeitraumSelect.addEventListener("change", wendeFilterAn);
sortSelect.addEventListener("change", render);
document.getElementById("verwaltung-new-refresh-btn")?.addEventListener("click", ladeUndRendere);
document.getElementById("verwaltung-retry-btn")?.addEventListener("click", ladeUndRendere);
// ── Auswertung (Bestseller/Herkunft/Stammkundschaft): jede Kiste ist SELBST der Klick-
// Auslöser für ihren eigenen Inhalt — kein separater Knopf mehr davor. Alle drei starten
// eingeklappt (nur Kopfzeile sichtbar) und lassen sich unabhängig voneinander auf-/zuklappen. ──
document.querySelectorAll("[data-collapsible-box] > .verwaltung-box-head-btn").forEach((btn) => {
btn.addEventListener("click", () => {
const box = btn.closest("[data-collapsible-box]");
const wirdJetztGeoeffnet = box.classList.contains("is-collapsed");
box.classList.toggle("is-collapsed");
btn.setAttribute("aria-expanded", String(wirdJetztGeoeffnet));
if (wirdJetztGeoeffnet) {
box.scrollIntoView({ behavior: "smooth", block: "start" });
}
});
});
// ── CSV-Export: exportiert genau die aktuell sichtbare (gefilterte) Liste — für die
// Buchhaltung/Steuer, komplett clientseitig, keine Serverkosten. ──
document.getElementById("verwaltung-csv-btn").addEventListener("click", () => {
const sichtbareIds = Array.from(document.querySelectorAll("#verwaltung-order-list .order-card"))
.filter((c) => c.style.display !== "none")
.map((c) => c.dataset.orderId);
const bestellungen = alleBestellungen.filter((b) => sichtbareIds.includes(String(b.id)));
if (bestellungen.length === 0) {
alert("Keine Bestellungen zum Exportieren (aktuelle Filterung ergibt 0 Treffer).");
return;
}
const kopf = ["Bestellnummer", "Datum", "Status", "Kunde", "E-Mail", "Land", "PLZ", "Ort", "Straße", "Artikel", "Zwischensumme", "Versand", "Rabatt", "Summe", "Zahlungsart", "Tracking"];
const csvZeile = (werte) => werte.map((w) => `"${String(w ?? "").replace(/"/g, '""')}"`).join(";");
const zeilen = [csvZeile(kopf)];
bestellungen.forEach((b) => {
zeilen.push(csvZeile([
b.bestellnummer || `#${b.id}`,
new Date(b.created_at).toLocaleDateString("de-DE"),
STATUS_LABEL[b.status] || b.status,
b.kunde_name,
b.kunde_email,
LAND_NAME[b.land] || b.land,
b.plz,
b.ort,
b.strasse,
b.artikel.map((a) => `${a.menge}x ${a.name}`).join(" | "),
b.zwischensumme,
b.versandkosten,
b.rabatt_gesamt,
b.summe,
b.zahlungsart,
b.tracking || "",
]));
});
const csv = "" + zeilen.join("\r\n"); // BOM voran, damit Excel Umlaute korrekt zeigt
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `bestellungen-${new Date().toISOString().slice(0, 10)}.csv`;
a.click();
URL.revokeObjectURL(url);
});
ladeUndRendere();
</script>