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:
co-authored by
Claude Sonnet 5
parent
7f7abd4d80
commit
9a2c75ebe6
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user