From 29e09f2ed4b7e6ef34fe7e6bf6ab4197ea170f56 Mon Sep 17 00:00:00 2001 From: Dicken Date: Wed, 17 Jun 2026 15:24:22 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20Statistik=20komplett=20=C3=BCberarbeitet?= =?UTF-8?q?=20-=20korrekte=20Filterung,=20Diagramme=20nach=20Zeitraum,=20G?= =?UTF-8?q?ranularit=C3=A4t?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/tools/statistik/routes.js | 152 ++++++++++++++++---------- frontend/src/tools/statistik.jsx | 19 +++- 2 files changed, 112 insertions(+), 59 deletions(-) diff --git a/backend/src/tools/statistik/routes.js b/backend/src/tools/statistik/routes.js index d186d0e..64379c8 100644 --- a/backend/src/tools/statistik/routes.js +++ b/backend/src/tools/statistik/routes.js @@ -30,8 +30,7 @@ router.delete('/expenses/:id', authenticate, (req, res) => { res.json({ ok: true }); }); -// ── Hilfsfunktion: revenue eines Auftrags ───────────────────────────────── -// Preis: custom_price direkt auf order ODER Summe aus order_items +// ── Revenue eines Auftrags ──────────────────────────────────────────────── function getOrderRevenue(order) { if (order.custom_price != null && order.custom_price > 0) return parseFloat(order.custom_price); const r = db.prepare( @@ -50,34 +49,42 @@ function getOrderBaseCost(orderId) { return r?.s || 0; } +function round2(n) { return Math.round((n||0) * 100) / 100; } + // ── Statistik-Übersicht ──────────────────────────────────────────────────── router.get('/overview', authenticate, (req, res) => { const { from, to } = req.query; const u = uid(req); - // Alle Aufträge des Users - const allOrders = db.prepare('SELECT * FROM orders WHERE user_id=? ORDER BY created_at DESC').all(u); - - // Zeitraum-Filter anwenden: - // bezahlte Aufträge → bezahlt_am im Zeitraum - // offene Aufträge → created_at im Zeitraum + // Datums-Vergleich: ISO-String slice(0,10) = YYYY-MM-DD const inRange = (dateStr) => { - if (!dateStr) return false; - if (!from && !to) return true; - const d = dateStr.slice(0,10); + if (!dateStr) return !from && !to; // kein Datum + kein Filter = ja; kein Datum + Filter = nein + const d = dateStr.slice(0, 10); if (from && d < from) return false; if (to && d > to) return false; return true; }; - const orders = allOrders.filter(o => { - if (o.bezahlt) return inRange(o.bezahlt_am || o.created_at); - return inRange(o.created_at); + // Alle Aufträge + const allOrders = db.prepare('SELECT * FROM orders WHERE user_id=? ORDER BY created_at DESC').all(u); + + // Zeitraum-Filterung: + // - Bezahlte Aufträge: Datum = bezahlt_am (wann wurde Geld erhalten) + // - Offene Aufträge (noch nicht bezahlt): Datum = created_at (wann erstellt) + const filteredOrders = allOrders.filter(o => { + if (o.bezahlt) { + // Bezahlt → nach Bezahldatum filtern + return inRange(o.bezahlt_am || o.created_at); + } else { + // Offen → nach Erstelldatum filtern + return inRange(o.created_at); + } }); - const paidOrders = orders.filter(o => !!o.bezahlt); - const openOrders = orders.filter(o => !o.bezahlt && o.status !== 'warteliste'); + const paidOrders = filteredOrders.filter(o => !!o.bezahlt); + const openOrders = filteredOrders.filter(o => !o.bezahlt && o.status !== 'warteliste'); + // KPI-Werte für den gewählten Zeitraum const totalRevenue = paidOrders.reduce((s,o) => s + getOrderRevenue(o), 0); const openRevenue = openOrders.reduce((s,o) => s + getOrderRevenue(o), 0); const totalBaseCost = paidOrders.reduce((s,o) => s + getOrderBaseCost(o.id), 0); @@ -92,73 +99,106 @@ router.get('/overview', authenticate, (req, res) => { const totalExpenses = expenses.reduce((s,e) => s + e.amount, 0); const netProfit = totalProfit - totalExpenses; - // Status-Verteilung (gefilterte Aufträge) + // Status-Verteilung im Zeitraum const byStatus = { warteliste:0, in_arbeit:0, fertig:0, bezahlt:0, abgeschlossen:0 }; - for (const o of orders) { + for (const o of filteredOrders) { if (!!o.bezahlt && !!o.abgeholt) byStatus.abgeschlossen++; else if (!!o.bezahlt) byStatus.bezahlt++; else if (o.status in byStatus) byStatus[o.status]++; } - // Ausgaben nach Kategorie (gefiltert) + // Ausgaben nach Kategorie (Zeitraum) const byCategory = {}; for (const e of expenses) { byCategory[e.category] = (byCategory[e.category] || 0) + e.amount; } - // Monatliche Daten: IMMER alle Daten (letzte 12 Monate) für das Balkendiagramm - // Revenue korrekt: für jeden bezahlten Auftrag getOrderRevenue aufrufen - const monthBuckets = {}; - for (const o of allOrders) { - // Monat: für bezahlte = bezahlt_am, für offene = created_at - const dateStr = o.bezahlt ? (o.bezahlt_am || o.created_at) : o.created_at; - const month = dateStr?.slice(0,7); - if (!month) continue; - if (!monthBuckets[month]) monthBuckets[month] = { month, revenue:0, orders:0, expenses:0 }; - if (o.bezahlt) { - monthBuckets[month].revenue += getOrderRevenue(o); + // ── Diagramm-Daten: immer nach dem relevanten Datum gruppiert ──────────── + // Für Diagramme: alle Aufträge (ungefiltert) in Monatsbuckets einordnen + // damit das Balkendiagramm den vollen Kontext zeigt (letzte 12 Monate) + // ABER: wenn ein konkreter Zeitraum gewählt ist, zeigen wir nur diesen Zeitraum + // aufgeteilt in Tages/Wochen/Monats-Buckets je nach Länge + + // Granularität bestimmen + let granularity = 'month'; // Standard + if (from && to) { + const days = (new Date(to) - new Date(from)) / 86400000; + if (days <= 14) granularity = 'day'; + else if (days <= 92) granularity = 'week'; + } + + function getBucketKey(dateStr) { + if (!dateStr) return null; + const d = dateStr.slice(0, 10); + if (granularity === 'day') return d; + if (granularity === 'week') { + // ISO-Woche: Montag der Woche + const dt = new Date(d); + const day = dt.getDay() || 7; + dt.setDate(dt.getDate() - day + 1); + return dt.toISOString().slice(0,10); } - monthBuckets[month].orders++; + return d.slice(0, 7); // YYYY-MM } - // Ausgaben in Monats-Buckets - const allExpenses = db.prepare('SELECT * FROM expenses WHERE user_id=?').all(u); - for (const e of allExpenses) { - const month = e.date?.slice(0,7); - if (!month) continue; - if (!monthBuckets[month]) monthBuckets[month] = { month, revenue:0, orders:0, expenses:0 }; - monthBuckets[month].expenses += e.amount; + // Buckets aus gefilterten Aufträgen (damit der Zeitraum stimmt) + const buckets = {}; + + // Auch alle Aufträge für Diagramm wenn kein Filter (zeige letzte 12 Monate) + const diagramOrders = (from || to) ? filteredOrders : allOrders; + + for (const o of diagramOrders) { + const dateStr = o.bezahlt ? (o.bezahlt_am || o.created_at) : o.created_at; + const key = getBucketKey(dateStr); + if (!key) continue; + if (!buckets[key]) buckets[key] = { month:key, revenue:0, expenses:0, orders:0 }; + if (o.bezahlt) buckets[key].revenue += getOrderRevenue(o); + buckets[key].orders++; } - const monthlyData = Object.values(monthBuckets) + // Ausgaben in Buckets (gefiltert) + const diagramExpenses = (from || to) ? expenses : db.prepare('SELECT * FROM expenses WHERE user_id=?').all(u); + for (const e of diagramExpenses) { + const key = getBucketKey(e.date); + if (!key) continue; + if (!buckets[key]) buckets[key] = { month:key, revenue:0, expenses:0, orders:0 }; + buckets[key].expenses += e.amount; + } + + // Sortiert + ohne Filter auf letzte 12 Monate begrenzen + let monthlyData = Object.values(buckets) .sort((a,b) => a.month.localeCompare(b.month)) - .slice(-12) - .map(m => ({ ...m, revenue: Math.round(m.revenue*100)/100, expenses: Math.round(m.expenses*100)/100 })); + .map(m => ({ ...m, revenue: round2(m.revenue), expenses: round2(m.expenses) })); - // Kumulierter Nettogewinn + if (!from && !to) monthlyData = monthlyData.slice(-12); + + // Kumulierter Nettogewinn (pro Bucket) let cum = 0; const cumulativeData = monthlyData.map(m => { cum += m.revenue - m.expenses; - return { month: m.month, value: Math.round(cum*100)/100 }; + return { month: m.month, value: round2(cum) }; }); - // Aufträge für Tabelle: mit korrektem revenue - const recentOrders = orders.slice(0,30).map(o => ({ - ...o, - revenue: getOrderRevenue(o), - base_cost: getOrderBaseCost(o.id), + // Aufträge-Tabelle mit korrekten Werten + const recentOrders = filteredOrders.slice(0, 50).map(o => ({ + id: o.id, name: o.name, status: o.status, + bezahlt: o.bezahlt, abgeholt: o.abgeholt, + created_at: o.created_at, bezahlt_am: o.bezahlt_am, + revenue: round2(getOrderRevenue(o)), + base_cost: round2(getOrderBaseCost(o.id)), })); res.json({ - totalRevenue: Math.round(totalRevenue*100)/100, - openRevenue: Math.round(openRevenue*100)/100, - totalBaseCost: Math.round(totalBaseCost*100)/100, - totalProfit: Math.round(totalProfit*100)/100, - totalExpenses: Math.round(totalExpenses*100)/100, - netProfit: Math.round(netProfit*100)/100, - totalOrders: orders.length, + totalRevenue: round2(totalRevenue), + openRevenue: round2(openRevenue), + totalBaseCost: round2(totalBaseCost), + totalProfit: round2(totalProfit), + totalExpenses: round2(totalExpenses), + netProfit: round2(netProfit), + totalOrders: filteredOrders.length, + granularity, byStatus, byCategory, monthlyData, cumulativeData, - expenses: expenses.slice(0,100), + expenses: expenses.slice(0, 100), recentOrders, }); }); diff --git a/frontend/src/tools/statistik.jsx b/frontend/src/tools/statistik.jsx index 28e4568..693c172 100644 --- a/frontend/src/tools/statistik.jsx +++ b/frontend/src/tools/statistik.jsx @@ -22,7 +22,20 @@ const CATS = ['Filament','Zubehör','Ersatzteile','Werkzeug','Sonstiges']; const CAT_COLORS = { Filament:'#4ecdc4', Zubehör:'#ffe66d', Ersatzteile:'#ff6b9d', Werkzeug:'#60a5fa', Sonstiges:'#c084fc' }; const fmt = v => `${(v||0).toFixed(2)} €`; -const fmtMonth = m => { if (!m) return ''; const [y,mo] = m.split('-'); return ['Jan','Feb','Mär','Apr','Mai','Jun','Jul','Aug','Sep','Okt','Nov','Dez'][parseInt(mo)-1]+' '+y.slice(2); }; +const fmtMonth = m => { + if (!m) return ''; + // YYYY-MM → "Jan 26" + if (/^\d{4}-\d{2}$/.test(m)) { + const [y,mo] = m.split('-'); + return ['Jan','Feb','Mär','Apr','Mai','Jun','Jul','Aug','Sep','Okt','Nov','Dez'][parseInt(mo)-1]+' '+y.slice(2); + } + // YYYY-MM-DD → "15.06." + if (/^\d{4}-\d{2}-\d{2}$/.test(m)) { + const [,mo,d] = m.split('-'); + return `${d}.${mo}.`; + } + return m; +}; const PERIODS = [ { id:'week', label:'Diese Woche' }, @@ -230,7 +243,7 @@ export default function Statistik({ mobile }) { {/* Balkendiagramm Einnahmen vs Ausgaben */} {data.monthlyData.length > 0 && (
-
EINNAHMEN VS. AUSGABEN (MONATLICH)
+
EINNAHMEN VS. AUSGABEN {data.granularity==='day'?'(TÄGLICH)':data.granularity==='week'?'(WÖCHENTLICH)':'(MONATLICH)'}
@@ -249,7 +262,7 @@ export default function Statistik({ mobile }) {
{data.cumulativeData.length > 1 && (
-
KUMULIERTER NETTOGEWINN
+
NETTOGEWINN IM ZEITVERLAUF