Auf ausdrücklichen Wunsch ("perfektioniere diese Seite aufs Maximum") die interne
Verwaltungsseite um alles erweitert, was ein echtes kleines Shop-Team im Alltag
braucht, aber vorher fehlte:
- Suchfeld (Bestellnummer/Name/E-Mail), Zeitraum-Filter (Heute/7/30 Tage), vier
Sortiermodi — alle gleichzeitig mit dem bestehenden Status-Filter kombinierbar.
- CSV-Export der aktuell gefilterten Liste (für Buchhaltung/Steuer, Excel-taugliche
UTF-8-Kodierung mit BOM).
- Tracking-Nummer direkt in der Bestellkarte editierbar (Enter oder Speichern-Knopf).
- Ein-Klick-Kopieren von E-Mail/Lieferadresse fürs Versandlabel.
- Echte PDF-Rechnung pro Bestellung per Klick (nutzt dieselbe jsPDF-Funktion wie im
Kundenkonto, keine neue Abhängigkeit).
- Zwei neue Kennzahlen-Kacheln (Bestellungen heute/letzte 7 Tage, Ø Bestellwert) plus
eine automatisch erscheinende Warn-Kachel für offene Bestellungen, die schon länger
liegen (Schwelle 3 Tage) — sowohl als Kachel als auch als Warn-Chip direkt an der
betroffenen Bestellung.
- "🆕 Neu"- und "🔁 Stammkundin"-Kennzeichnung pro Bestellung, neuer Abschnitt "Beste
Kundinnen" (Umsatz nach wiederkehrender Kundschaft).
- Dezenter Hintergrund-Hinweis ("🔔 X neue Bestellungen"), wenn während des Arbeitens
neue Bestellungen eingehen — unterbricht die aktuelle Ansicht/Filterung nicht.
- Neuer, deutlich premiumerer Seitenkopf (Farbverlauf-Rand, Funkeln) passend zum Rest
der Seite.
Alle neuen Funktionen einzeln end-to-end gegen die echte lokale D1-Datenbank getestet
(Suche, Zeitraum, Sortierung, Tracking-Speichern, Kopieren, PDF-Erzeugung, Warn-/Neu-/
Stammkunden-Kennzeichnung) — keine Konsolenfehler, Screenshot-verifiziert.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
712 lines
36 KiB
Plaintext
712 lines
36 KiB
Plaintext
---
|
||
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">
|
||
<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>
|
||
|
||
{/* Zweite Kennzahlen-Reihe — 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-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>
|
||
<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>
|
||
</section>
|
||
|
||
<section class="section-tight">
|
||
<div class="container">
|
||
<h2>📦 Alle Bestellungen</h2>
|
||
|
||
<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="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>
|
||
|
||
<p class="small" id="verwaltung-empty" style="display:none;">Noch keine Bestellungen eingegangen.</p>
|
||
<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>
|
||
</section>
|
||
|
||
<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>
|
||
</section>
|
||
|
||
<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>
|
||
</section>
|
||
|
||
<section class="section-tight">
|
||
<div class="container">
|
||
<h2>👑 Beste Kundinnen</h2>
|
||
<p class="small" id="verwaltung-kunden-empty" style="display:none;">Noch keine wiederkehrende Kundschaft.</p>
|
||
<div class="card verwaltung-kat-card" id="verwaltung-kunden-card" style="display:none;">
|
||
<div class="verwaltung-bar-list" id="verwaltung-kunden-list"></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;
|
||
|
||
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");
|
||
|
||
// ── 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 + "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;
|
||
setText("stat-heute", String(heuteCount));
|
||
setText("stat-7tage", String(siebenTageCount));
|
||
|
||
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),
|
||
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("");
|
||
|
||
// ── 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);
|
||
const maxKundenUmsatz = Math.max(1, ...besteKunden.map((k) => k.summe));
|
||
const kundenCard = document.getElementById("verwaltung-kunden-card");
|
||
const kundenEmpty = document.getElementById("verwaltung-kunden-empty");
|
||
const kundenList = document.getElementById("verwaltung-kunden-list");
|
||
if (besteKunden.length === 0) {
|
||
kundenCard.style.display = "none";
|
||
kundenEmpty.style.display = "";
|
||
} else {
|
||
kundenCard.style.display = "";
|
||
kundenEmpty.style.display = "none";
|
||
kundenList.innerHTML = besteKunden.map((k) => `
|
||
<div class="verwaltung-bar-row">
|
||
<div class="verwaltung-bar-head"><span>👑 ${escapeHtml(k.name)} <span class="small">(${escapeHtml(k.email)})</span></span><span class="small">${formatPrice(k.summe, lang)} · ${k.anzahl}× bestellt</span></div>
|
||
<div class="verwaltung-bar-track"><div class="verwaltung-bar-fill verwaltung-bar-fill-kunden" style="width:${Math.round((k.summe / maxKundenUmsatz) * 100)}%"></div></div>
|
||
</div>
|
||
`).join("");
|
||
}
|
||
|
||
wendeFilterAn();
|
||
}
|
||
|
||
function escapeHtml(str) {
|
||
return String(str ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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;
|
||
|
||
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 matchZeitraum = !zeitraumMs || jetzt - new Date(card.dataset.created).getTime() <= 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" });
|
||
}
|
||
});
|
||
|
||
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);
|
||
|
||
// ── 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>
|