Bestellverwaltung radikal ausgebaut: Suche, Zeitraum, Sortierung, PDF, u.v.m.

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]>
This commit is contained in:
qcigano
2026-08-03 21:36:52 +02:00
co-authored by Claude Sonnet 5
parent 7f7abd4d80
commit 9a2c75ebe6
2 changed files with 474 additions and 24 deletions
+362 -24
View File
@@ -13,6 +13,14 @@ const lang: Locale = "de";
// ganze restliche Seite), lädt die eigentlichen Bestelldaten aber zur LAUFZEIT per fetch() aus // 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 // der Datenbank (siehe <script> unten) — es gibt hier also KEINE Beispieldaten mehr, sondern
// echte, live einlaufende Bestellungen aus dem Checkout. // 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 <Layout
title="Bestellverwaltung" title="Bestellverwaltung"
@@ -28,9 +36,13 @@ const lang: Locale = "de";
so, auch wenn der Rest der Seite mal öffentlich wird. Alles unten sind ECHTE so, auch wenn der Rest der Seite mal öffentlich wird. Alles unten sind ECHTE
Bestellungen aus der Datenbank, live vom Checkout befüllt. Bestellungen aus der Datenbank, live vom Checkout befüllt.
</p> </p>
<span class="eyebrow">Verwaltung</span> <div class="verwaltung-hero">
<h1>📊 Bestellverwaltung</h1> <span class="verwaltung-hero-sparkle verwaltung-hero-sparkle-1" aria-hidden="true">✨</span>
<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> <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> </div>
</section> </section>
@@ -77,12 +89,62 @@ const lang: Locale = "de";
<span class="verwaltung-stat-label" id="stat-umsatz-label">Gesamtumsatz</span> <span class="verwaltung-stat-label" id="stat-umsatz-label">Gesamtumsatz</span>
</div> </div>
</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> </div>
</section> </section>
<section class="section-tight"> <section class="section-tight">
<div class="container"> <div class="container">
<h2>📦 Alle Bestellungen</h2> <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"> <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 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="offen">Offen (<span id="filter-count-offen">0</span>)</button>
@@ -93,6 +155,7 @@ const lang: Locale = "de";
</nav> </nav>
<p class="small" id="verwaltung-empty" style="display:none;">Noch keine Bestellungen eingegangen.</p> <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 class="order-list" id="verwaltung-order-list"></div>
</div> </div>
</section> </section>
@@ -113,12 +176,23 @@ const lang: Locale = "de";
</div> </div>
</div> </div>
</section> </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> </div>
</Layout> </Layout>
<script type="module"> <script type="module">
import { getCategory } from "../../data/categories"; import { getCategory } from "../../data/categories";
import { formatPrice } from "../../i18n/format"; import { formatPrice } from "../../i18n/format";
import { rechnungAlsPdfHerunterladen } from "../../scripts/invoice-pdf";
const lang = "de"; const lang = "de";
const STATUS_LABEL = { const STATUS_LABEL = {
@@ -131,6 +205,17 @@ const lang: Locale = "de";
}; };
const LAND_NAME = { de: "Deutschland", at: "Österreich", ch: "Schweiz", lu: "Luxemburg" }; const LAND_NAME = { de: "Deutschland", at: "Österreich", ch: "Schweiz", lu: "Luxemburg" };
const LAND_FLAGGE = { de: "🇩🇪", at: "🇦🇹", ch: "🇨🇭", lu: "🇱🇺" }; 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) { function bucket(status) {
if (status === "storniert") return "storniert"; if (status === "storniert") return "storniert";
@@ -144,13 +229,18 @@ const lang: Locale = "de";
const errorEl = document.getElementById("verwaltung-error"); const errorEl = document.getElementById("verwaltung-error");
const errorTextEl = document.getElementById("verwaltung-error-text"); const errorTextEl = document.getElementById("verwaltung-error-text");
const contentEl = document.getElementById("verwaltung-content"); 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 alleBestellungen = [];
let bekannteIds = new Set();
let erstesLaden = true;
async function ladeUndRendere() { async function ladeUndRendere() {
loadingEl.style.display = ""; loadingEl.style.display = "";
errorEl.style.display = "none"; errorEl.style.display = "none";
contentEl.style.display = "none"; document.getElementById("verwaltung-new-banner").style.display = "none";
try { try {
const res = await fetch("/api/verwaltung/orders"); const res = await fetch("/api/verwaltung/orders");
const data = await res.json().catch(() => null); const data = await res.json().catch(() => null);
@@ -158,6 +248,8 @@ const lang: Locale = "de";
throw new Error(data?.error || `Server antwortete mit Status ${res.status}.`); throw new Error(data?.error || `Server antwortete mit Status ${res.status}.`);
} }
alleBestellungen = data.orders; alleBestellungen = data.orders;
bekannteIds = new Set(alleBestellungen.map((b) => b.id));
erstesLaden = false;
render(); render();
loadingEl.style.display = "none"; loadingEl.style.display = "none";
contentEl.style.display = ""; contentEl.style.display = "";
@@ -168,11 +260,43 @@ const lang: Locale = "de";
} }
} }
// 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) { function setText(id, text) {
const el = document.getElementById(id); const el = document.getElementById(id);
if (el) el.textContent = text; 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() { function render() {
const aktive = alleBestellungen.filter((b) => b.status !== "storniert"); const aktive = alleBestellungen.filter((b) => b.status !== "storniert");
@@ -190,12 +314,36 @@ const lang: Locale = "de";
setText("filter-count-abgeschlossen", String(buckets.abgeschlossen)); setText("filter-count-abgeschlossen", String(buckets.abgeschlossen));
setText("filter-count-storniert", String(buckets.storniert)); setText("filter-count-storniert", String(buckets.storniert));
// ── Umsatz: stornierte Bestellungen zählen bewusst NICHT mit. ── // ── Umsatz + Ø Bestellwert: stornierte Bestellungen zählen bewusst NICHT mit. ──
const gesamtumsatz = aktive.reduce((s, b) => s + Number(b.summe), 0); const gesamtumsatz = aktive.reduce((s, b) => s + Number(b.summe), 0);
setText("stat-umsatz", formatPrice(gesamtumsatz, lang)); setText("stat-umsatz", formatPrice(gesamtumsatz, lang));
setText("stat-umsatz-label", `Gesamtumsatz (${aktive.length} Bestellung${aktive.length === 1 ? "" : "en"})`); setText("stat-umsatz-label", `Gesamtumsatz (${aktive.length} Bestellung${aktive.length === 1 ? "" : "en"})`);
setText("stat-avg", aktive.length > 0 ? formatPrice(gesamtumsatz / aktive.length, lang) : "");
// ── Bestellliste ── // ── 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 listEl = document.getElementById("verwaltung-order-list");
const emptyEl = document.getElementById("verwaltung-empty"); const emptyEl = document.getElementById("verwaltung-empty");
if (alleBestellungen.length === 0) { if (alleBestellungen.length === 0) {
@@ -203,24 +351,45 @@ const lang: Locale = "de";
emptyEl.style.display = ""; emptyEl.style.display = "";
} else { } else {
emptyEl.style.display = "none"; emptyEl.style.display = "none";
listEl.innerHTML = alleBestellungen.map((b) => { const sortiert = sortiere(alleBestellungen, sortSelect.value);
listEl.innerHTML = sortiert.map((b) => {
const land = b.land; const land = b.land;
const artikelHtml = b.artikel.map((a) => 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>` `<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(""); ).join("");
const datum = new Date(b.created_at).toLocaleDateString("de-DE", { year: "numeric", month: "2-digit", day: "2-digit" }); 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 ` return `
<div class="card order-card order-card-${b.status}" data-bucket="${bucket(b.status)}" data-order-id="${b.id}"> <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 class="order-card-head">
<div> <div>
<span class="order-no">${LAND_FLAGGE[land] || ""} ${escapeHtml(b.bestellnummer || `#${b.id}`)}</span> <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> ${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> </div>
<span class="badge order-status order-status-${b.status}">${STATUS_LABEL[b.status] || b.status}</span> <span class="badge order-status order-status-${b.status}">${STATUS_LABEL[b.status] || b.status}</span>
</div> </div>
<ul class="order-items">${artikelHtml}</ul> <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"> <div class="order-card-foot verwaltung-order-foot">
<span class="order-total">Summe: <strong>${formatPrice(Number(b.summe), lang)}</strong></span> <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"> <div class="verwaltung-status-edit">
<select class="verwaltung-status-select" data-order-id="${b.id}"> <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("")} ${Object.entries(STATUS_LABEL).map(([val, label]) => `<option value="${val}" ${val === b.status ? "selected" : ""}>${label}</option>`).join("")}
@@ -235,6 +404,18 @@ const lang: Locale = "de";
listEl.querySelectorAll(".verwaltung-status-select").forEach((select) => { listEl.querySelectorAll(".verwaltung-status-select").forEach((select) => {
select.addEventListener("change", () => statusAendern(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) ── // ── Auswertung: meistverkaufte Produkte je Kategorie (stornierte Bestellungen ausgeschlossen) ──
@@ -310,7 +491,38 @@ const lang: Locale = "de";
</div> </div>
`).join(""); `).join("");
anwendenFilter(document.querySelector(".verwaltung-filter-btn.is-active")?.dataset.filter || "alle"); // ── 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) { function escapeHtml(str) {
@@ -330,8 +542,6 @@ const lang: Locale = "de";
const data = await res.json().catch(() => null); const data = await res.json().catch(() => null);
if (!res.ok || !data?.ok) throw new Error(data?.error || "Speichern fehlgeschlagen."); 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)); const b = alleBestellungen.find((o) => String(o.id) === String(orderId));
if (b) b.status = neuerStatus; if (b) b.status = neuerStatus;
render(); render();
@@ -347,27 +557,155 @@ const lang: Locale = "de";
} }
} }
function anwendenFilter(filter) { async function trackingSpeichern(orderId) {
document.querySelectorAll(".verwaltung-filter-btn").forEach((btn) => { const input = document.querySelector(`.verwaltung-tracking-input[data-order-id="${orderId}"]`);
btn.classList.toggle("is-active", btn.dataset.filter === filter); const saveBtn = document.querySelector(`.verwaltung-tracking-save[data-order-id="${orderId}"]`);
}); if (!input) return;
document.querySelectorAll("#verwaltung-order-list .order-card").forEach((card) => { saveBtn.disabled = true;
card.style.display = filter === "alle" || card.dataset.bucket === filter ? "" : "none"; 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) => { document.addEventListener("click", (e) => {
const btn = e.target.closest("[data-filter]"); const filterBtn = e.target.closest("[data-filter]");
if (!btn) return; if (filterBtn) {
const f = btn.dataset.filter; const f = filterBtn.dataset.filter;
if (!f) return; if (f) {
anwendenFilter(f); aktuellerStatusFilter = f;
if (btn.classList.contains("verwaltung-stat-tile")) { 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" }); 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); 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(); ladeUndRendere();
</script> </script>
+112
View File
@@ -2860,7 +2860,50 @@ a:focus-visible, button:focus-visible {
@media print { #vandiy-music-widget { display: none !important; } } @media print { #vandiy-music-widget { display: none !important; } }
/* Interne Bestellverwaltung (/verwaltung/) — Status-Kacheln, Filterleiste, Auswertungs-Balken. */ /* Interne Bestellverwaltung (/verwaltung/) — Status-Kacheln, Filterleiste, Auswertungs-Balken. */
/* Hero-Kopf: dieselbe "besondere" Formsprache wie die anderen Premium-Karten der Seite
(wanderndes Verlaufslicht + pulsierender Rand + Funkeln), passend zu einer Seite, die "alles
übertrifft" — nur dezenter/edler gehalten, weil es ein Arbeits-Dashboard ist, kein Verkaufs-
Element. */
.verwaltung-hero {
position: relative;
overflow: hidden;
margin-top: 1.2rem;
padding: 1.8rem 2rem;
border-radius: var(--radius-l);
border: 1.5px solid transparent;
background:
linear-gradient(155deg, var(--c-anthracite-light), var(--c-anthracite)) padding-box,
linear-gradient(120deg, #5fd3ec, #9b7fd6, #e3b23c, #5fd3ec) border-box;
background-size: 100% 100%, 300% 100%;
animation: verwaltung-hero-shift 10s ease-in-out infinite;
}
@keyframes verwaltung-hero-shift {
0%, 100% { background-position: 0 0, 0% 50%; }
50% { background-position: 0 0, 100% 50%; }
}
.verwaltung-hero .eyebrow,
.verwaltung-hero h1,
.verwaltung-hero .lead { position: relative; z-index: 1; }
.verwaltung-hero h1 { margin: 0.3rem 0 0.6rem; }
.verwaltung-hero .lead { margin: 0; max-width: 62ch; }
.verwaltung-hero-sparkle { position: absolute; font-size: 1rem; pointer-events: none; z-index: 1; }
.verwaltung-hero-sparkle-1 { top: 1.4rem; right: 2.2rem; animation: account-nav-sparkle-twinkle 2.6s ease-in-out infinite; }
.verwaltung-hero-sparkle-2 { bottom: 1.6rem; right: 5rem; font-size: 0.7rem; animation: account-nav-sparkle-twinkle 3.4s ease-in-out infinite 0.6s; }
@media (prefers-reduced-motion: reduce) { .verwaltung-hero, .verwaltung-hero-sparkle { animation: none; } }
.verwaltung-stat-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 0.9rem; margin: 1.2rem 0; } .verwaltung-stat-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 0.9rem; margin: 1.2rem 0; }
.verwaltung-stat-grid-secondary { margin-top: 0; }
.verwaltung-stat-tile-mini { padding: 0.8rem 1rem; }
.verwaltung-stat-tile-mini .verwaltung-stat-icon { font-size: 1.3rem; }
.verwaltung-stat-tile-mini .verwaltung-stat-value { font-size: 1.4rem; }
#stat-attention-tile { border-color: color-mix(in srgb, var(--c-sale) 50%, transparent); background: color-mix(in srgb, var(--c-sale) 10%, var(--c-anthracite-light)); animation: verwaltung-attention-pulse 2s ease-in-out infinite; }
#stat-attention-tile .verwaltung-stat-value { color: var(--c-sale); }
@keyframes verwaltung-attention-pulse {
0%, 100% { box-shadow: 0 0 0 1px color-mix(in srgb, var(--c-sale) 30%, transparent); }
50% { box-shadow: 0 0 16px -2px color-mix(in srgb, var(--c-sale) 55%, transparent); }
}
@media (prefers-reduced-motion: reduce) { #stat-attention-tile { animation: none; } }
.verwaltung-stat-tile { .verwaltung-stat-tile {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -2904,3 +2947,72 @@ a:focus-visible, button:focus-visible {
.verwaltung-bar-track { height: 9px; border-radius: 999px; background: rgba(244, 241, 247, 0.08); overflow: hidden; } .verwaltung-bar-track { height: 9px; border-radius: 999px; background: rgba(244, 241, 247, 0.08); overflow: hidden; }
.verwaltung-bar-fill { height: 100%; border-radius: 999px; background: linear-gradient(90deg, var(--c-accent), #9b7fd6); transition: width 0.6s var(--ease); } .verwaltung-bar-fill { height: 100%; border-radius: 999px; background: linear-gradient(90deg, var(--c-accent), #9b7fd6); transition: width 0.6s var(--ease); }
.verwaltung-bar-fill-land { background: linear-gradient(90deg, #e3b23c, #f6dda0); } .verwaltung-bar-fill-land { background: linear-gradient(90deg, #e3b23c, #f6dda0); }
.verwaltung-bar-fill-kunden { background: linear-gradient(90deg, #e3b23c, #9b7fd6); }
/* Hinweis-Banner "neue Bestellungen im Hintergrund entdeckt" — soll auffallen, aber nicht die
Ansicht selbst verändern, solange man nicht aktiv auf "Aktualisieren" klickt. */
.verwaltung-new-banner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
margin: 1rem 0;
padding: 0.8rem 1.1rem;
border-radius: var(--radius-m);
background: color-mix(in srgb, var(--c-accent) 16%, var(--c-anthracite-light));
border: 1.5px solid color-mix(in srgb, var(--c-accent) 45%, transparent);
animation: verwaltung-new-banner-glow 2.2s ease-in-out infinite;
}
@keyframes verwaltung-new-banner-glow {
0%, 100% { box-shadow: 0 0 0 1px color-mix(in srgb, var(--c-accent) 30%, transparent); }
50% { box-shadow: 0 0 18px -2px color-mix(in srgb, var(--c-accent) 55%, transparent); }
}
@media (prefers-reduced-motion: reduce) { .verwaltung-new-banner { animation: none; } }
/* Werkzeugleiste über der Bestellliste: Suche, Zeitraum, Sortierung, CSV-Export. */
.verwaltung-toolbar { display: flex; gap: 0.7rem; flex-wrap: wrap; align-items: center; margin: 1rem 0; }
.verwaltung-input {
padding: 0.65em 0.9em;
border-radius: var(--radius-s);
border: 1px solid rgba(244, 241, 247, 0.15);
background: var(--c-anthracite);
color: var(--c-text);
font-family: var(--font-body);
font-size: 0.9rem;
}
.verwaltung-input:focus { outline: 2px solid var(--c-accent); outline-offset: 2px; }
#verwaltung-search { flex: 1 1 260px; min-width: 200px; }
.verwaltung-select { cursor: pointer; }
.btn-xs { padding: 0.35em 0.7em; font-size: 0.78rem; white-space: nowrap; }
/* Chips direkt neben der Bestellnummer — "neu"/"Stammkundin"/"braucht Aufmerksamkeit" auf einen
Blick, ohne erst die Karte lesen zu müssen. */
.verwaltung-chip {
display: inline-flex;
align-items: center;
gap: 0.3em;
padding: 0.15em 0.6em;
border-radius: 999px;
font-size: 0.72rem;
font-weight: 700;
margin-left: 0.5em;
vertical-align: middle;
}
.verwaltung-chip-new { background: color-mix(in srgb, var(--c-accent) 28%, transparent); border: 1px solid color-mix(in srgb, var(--c-accent) 55%, transparent); color: #d3f3fb; }
.verwaltung-chip-stamm { background: color-mix(in srgb, #9b7fd6 28%, transparent); border: 1px solid color-mix(in srgb, #9b7fd6 55%, transparent); color: #e4dbfa; }
.verwaltung-chip-attention { background: color-mix(in srgb, var(--c-sale) 28%, transparent); border: 1px solid color-mix(in srgb, var(--c-sale) 55%, transparent); color: #ffd7de; }
/* Bestellkarte, die "Aufmerksamkeit braucht" (offen + schon länger liegen geblieben) — warmer
Rand statt der neutralen Statusfarbe, damit sie in der Liste sofort auffällt. */
.order-card-attention { --status-color: var(--c-sale); }
/* Kopier-/PDF-Zeile: schnelle Aktionen fürs Verpacken/Versenden, ohne erst irgendwo hinklicken
zu müssen. */
.verwaltung-copy-row { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; margin: 0.7rem 0; }
.verwaltung-copy-hint { color: #7ec8a0; font-weight: 600; }
.verwaltung-order-foot { flex-wrap: wrap; }
.verwaltung-tracking-edit { display: flex; align-items: center; gap: 0.4rem; }
.verwaltung-tracking-input { width: 160px; padding: 0.4em 0.6em; font-size: 0.82rem; }