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
// 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"
@@ -28,9 +36,13 @@ const lang: Locale = "de";
so, auch wenn der Rest der Seite mal öffentlich wird. Alles unten sind ECHTE
Bestellungen aus der Datenbank, live vom Checkout befüllt.
</p>
<span class="eyebrow">Verwaltung</span>
<h1>📊 Bestellverwaltung</h1>
<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 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>
@@ -77,12 +89,62 @@ const lang: Locale = "de";
<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>
@@ -93,6 +155,7 @@ const lang: Locale = "de";
</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>
@@ -113,12 +176,23 @@ const lang: Locale = "de";
</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 = {
@@ -131,6 +205,17 @@ const lang: Locale = "de";
};
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";
@@ -144,13 +229,18 @@ const lang: Locale = "de";
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";
contentEl.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);
@@ -158,6 +248,8 @@ const lang: Locale = "de";
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 = "";
@@ -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) {
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");
@@ -190,12 +314,36 @@ const lang: Locale = "de";
setText("filter-count-abgeschlossen", String(buckets.abgeschlossen));
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);
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) : "");
// ── 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 emptyEl = document.getElementById("verwaltung-empty");
if (alleBestellungen.length === 0) {
@@ -203,24 +351,45 @@ const lang: Locale = "de";
emptyEl.style.display = "";
} else {
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 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}" 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>
<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>
<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("")}
@@ -235,6 +404,18 @@ const lang: Locale = "de";
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) ──
@@ -310,7 +491,38 @@ const lang: Locale = "de";
</div>
`).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) {
@@ -330,8 +542,6 @@ const lang: Locale = "de";
const data = await res.json().catch(() => null);
if (!res.ok || !data?.ok) throw new Error(data?.error || "Speichern fehlgeschlagen.");
// Lokal aktualisieren + alles neu rendern (Buckets/Auswertung/Filter bleiben konsistent),
// statt einen kompletten Server-Reload zu brauchen.
const b = alleBestellungen.find((o) => String(o.id) === String(orderId));
if (b) b.status = neuerStatus;
render();
@@ -347,27 +557,155 @@ const lang: Locale = "de";
}
}
function anwendenFilter(filter) {
document.querySelectorAll(".verwaltung-filter-btn").forEach((btn) => {
btn.classList.toggle("is-active", btn.dataset.filter === filter);
});
document.querySelectorAll("#verwaltung-order-list .order-card").forEach((card) => {
card.style.display = filter === "alle" || card.dataset.bucket === filter ? "" : "none";
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 btn = e.target.closest("[data-filter]");
if (!btn) return;
const f = btn.dataset.filter;
if (!f) return;
anwendenFilter(f);
if (btn.classList.contains("verwaltung-stat-tile")) {
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>
+112
View File
@@ -2860,7 +2860,50 @@ a:focus-visible, button:focus-visible {
@media print { #vandiy-music-widget { display: none !important; } }
/* 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-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 {
display: flex;
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-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-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; }