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
|
// 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,10 +36,14 @@ 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>
|
||||||
|
<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>
|
<span class="eyebrow">Verwaltung</span>
|
||||||
<h1>📊 Bestellverwaltung</h1>
|
<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>
|
<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>
|
||||||
|
|
||||||
<section class="section-tight" id="verwaltung-loading">
|
<section class="section-tight" id="verwaltung-loading">
|
||||||
@@ -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;
|
||||||
|
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() }),
|
||||||
});
|
});
|
||||||
document.querySelectorAll("#verwaltung-order-list .order-card").forEach((card) => {
|
const data = await res.json().catch(() => null);
|
||||||
card.style.display = filter === "alle" || card.dataset.bucket === filter ? "" : "none";
|
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>
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
Reference in New Issue
Block a user