fix: Statistik komplett überarbeitet - korrekte Filterung, Diagramme nach Zeitraum, Granularität
This commit is contained in:
@@ -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);
|
||||
// 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
|
||||
// ── 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);
|
||||
}
|
||||
return d.slice(0, 7); // YYYY-MM
|
||||
}
|
||||
|
||||
// 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 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);
|
||||
}
|
||||
monthBuckets[month].orders++;
|
||||
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++;
|
||||
}
|
||||
|
||||
// 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;
|
||||
// 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;
|
||||
}
|
||||
|
||||
const monthlyData = Object.values(monthBuckets)
|
||||
// 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,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 && (
|
||||
<div style={S.card}>
|
||||
<div style={S.head}>EINNAHMEN VS. AUSGABEN (MONATLICH)</div>
|
||||
<div style={S.head}>EINNAHMEN VS. AUSGABEN {data.granularity==='day'?'(TÄGLICH)':data.granularity==='week'?'(WÖCHENTLICH)':'(MONATLICH)'}</div>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<BarChart data={data.monthlyData} margin={{top:0,right:0,bottom:0,left:0}}>
|
||||
<XAxis dataKey="month" tickFormatter={fmtMonth} tick={{fill:'rgba(255,255,255,0.3)',fontSize:10}} axisLine={false} tickLine={false}/>
|
||||
@@ -249,7 +262,7 @@ export default function Statistik({ mobile }) {
|
||||
<div style={{display:'flex',gap:12,flexWrap:'wrap'}}>
|
||||
{data.cumulativeData.length > 1 && (
|
||||
<div style={{...S.card,flex:1,minWidth:200}}>
|
||||
<div style={S.head}>KUMULIERTER NETTOGEWINN</div>
|
||||
<div style={S.head}>NETTOGEWINN IM ZEITVERLAUF</div>
|
||||
<ResponsiveContainer width="100%" height={160}>
|
||||
<LineChart data={data.cumulativeData} margin={{top:5,right:5,bottom:0,left:0}}>
|
||||
<XAxis dataKey="month" tickFormatter={fmtMonth} tick={{fill:'rgba(255,255,255,0.3)',fontSize:9}} axisLine={false} tickLine={false}/>
|
||||
|
||||
Reference in New Issue
Block a user