diff --git a/backend/src/tools/statistik/routes.js b/backend/src/tools/statistik/routes.js index 3be54c1..d186d0e 100644 --- a/backend/src/tools/statistik/routes.js +++ b/backend/src/tools/statistik/routes.js @@ -12,8 +12,7 @@ router.get('/expenses', authenticate, (req, res) => { const p = [uid(req)]; if (from) { q += ' AND date>=?'; p.push(from); } if (to) { q += ' AND date<=?'; p.push(to); } - q += ' ORDER BY date DESC, id DESC'; - res.json(db.prepare(q).all(...p)); + res.json(db.prepare(q + ' ORDER BY date DESC, id DESC').all(...p)); }); router.post('/expenses', authenticate, (req, res) => { @@ -31,41 +30,60 @@ router.delete('/expenses/:id', authenticate, (req, res) => { res.json({ ok: true }); }); -// ── Statistik-Daten ──────────────────────────────────────────────────────── +// ── Hilfsfunktion: revenue eines Auftrags ───────────────────────────────── +// Preis: custom_price direkt auf order ODER Summe aus order_items +function getOrderRevenue(order) { + if (order.custom_price != null && order.custom_price > 0) return parseFloat(order.custom_price); + const r = db.prepare( + 'SELECT COALESCE(SUM(custom_price * stueckzahl), 0) as s FROM order_items WHERE order_id=? AND custom_price IS NOT NULL' + ).get(order.id); + return r?.s || 0; +} + +function getOrderBaseCost(orderId) { + const r = db.prepare(` + SELECT COALESCE(SUM(COALESCE(c.preis_freundschaft, oi.preis_freundschaft, 0) * oi.stueckzahl), 0) as s + FROM order_items oi + LEFT JOIN calculations c ON c.id = oi.calculation_id + WHERE oi.order_id = ? + `).get(orderId); + return r?.s || 0; +} + +// ── Statistik-Übersicht ──────────────────────────────────────────────────── router.get('/overview', authenticate, (req, res) => { const { from, to } = req.query; const u = uid(req); - // Bezahlte Aufträge: nach bezahlt_am filtern; offene: nach created_at - // Zeitraum-Filter: zeige Auftrag wenn er im Zeitraum bezahlt wurde (bezahlt) - // ODER noch offen ist und im Zeitraum erstellt wurde - let oq = `SELECT o.*, - COALESCE(o.custom_price, - (SELECT SUM(oi.custom_price * oi.stueckzahl) FROM order_items oi WHERE oi.order_id=o.id AND oi.custom_price IS NOT NULL) - ) as revenue, - (SELECT COALESCE(SUM(COALESCE(c.preis_freundschaft, oi.preis_freundschaft, 0) * oi.stueckzahl),0) - FROM order_items oi LEFT JOIN calculations c ON c.id=oi.calculation_id WHERE oi.order_id=o.id) as base_cost - FROM orders o WHERE o.user_id=?`; - const op = [u]; - if (from && to) { - oq += ` AND ( - (o.bezahlt=1 AND DATE(o.bezahlt_am)>=? AND DATE(o.bezahlt_am)<=?) - OR - (o.bezahlt=0 AND DATE(o.created_at)>=? AND DATE(o.created_at)<=?) - )`; - op.push(from, to, from, to); - } - oq += ' ORDER BY COALESCE(o.bezahlt_am, o.created_at) DESC'; - const orders = db.prepare(oq).all(...op); + // 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 + const inRange = (dateStr) => { + if (!dateStr) return false; + if (!from && !to) return true; + 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); + }); const paidOrders = orders.filter(o => !!o.bezahlt); const openOrders = orders.filter(o => !o.bezahlt && o.status !== 'warteliste'); - const totalRevenue = paidOrders.reduce((s,o) => s + (o.revenue || 0), 0); - const openRevenue = openOrders.reduce((s,o) => s + (o.revenue || 0), 0); - const totalBaseCost = paidOrders.reduce((s,o) => s + (o.base_cost || 0), 0); + 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); const totalProfit = totalRevenue - totalBaseCost; + // Ausgaben im Zeitraum let eq = 'SELECT * FROM expenses WHERE user_id=?'; const ep = [u]; if (from) { eq += ' AND date>=?'; ep.push(from); } @@ -74,6 +92,7 @@ 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) const byStatus = { warteliste:0, in_arbeit:0, fertig:0, bezahlt:0, abgeschlossen:0 }; for (const o of orders) { if (!!o.bezahlt && !!o.abgeholt) byStatus.abgeschlossen++; @@ -81,44 +100,66 @@ router.get('/overview', authenticate, (req, res) => { else if (o.status in byStatus) byStatus[o.status]++; } + // Ausgaben nach Kategorie (gefiltert) const byCategory = {}; for (const e of expenses) { byCategory[e.category] = (byCategory[e.category] || 0) + e.amount; } - // Monatliche Daten - const monthlyRev = db.prepare(` - SELECT strftime('%Y-%m', CASE WHEN bezahlt=1 THEN bezahlt_am ELSE created_at END) as month, - SUM(CASE WHEN bezahlt=1 THEN COALESCE(custom_price,0) ELSE 0 END) as revenue, - COUNT(*) as orders - FROM orders WHERE user_id=? GROUP BY month ORDER BY month - `).all(u); - - const monthlyExp = db.prepare(` - SELECT strftime('%Y-%m', date) as month, SUM(amount) as expenses - FROM expenses WHERE user_id=? GROUP BY month ORDER BY month - `).all(u); - - const monthlyMap = {}; - for (const m of monthlyRev) monthlyMap[m.month] = { month:m.month, revenue:m.revenue||0, orders:m.orders||0, expenses:0 }; - for (const m of monthlyExp) { - if (monthlyMap[m.month]) monthlyMap[m.month].expenses = m.expenses||0; - else monthlyMap[m.month] = { month:m.month, revenue:0, orders:0, expenses:m.expenses||0 }; + // 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); + } + monthBuckets[month].orders++; } - const monthlyData = Object.values(monthlyMap).sort((a,b)=>a.month.localeCompare(b.month)).slice(-12); + // 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; + } + + const monthlyData = Object.values(monthBuckets) + .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 })); + + // Kumulierter Nettogewinn let cum = 0; const cumulativeData = monthlyData.map(m => { - cum += (m.revenue||0) - (m.expenses||0); + cum += m.revenue - m.expenses; return { month: m.month, value: Math.round(cum*100)/100 }; }); + // 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), + })); + res.json({ - totalRevenue, openRevenue, totalBaseCost, totalProfit, - totalExpenses, netProfit, totalOrders: orders.length, + 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, byStatus, byCategory, monthlyData, cumulativeData, expenses: expenses.slice(0,100), - recentOrders: orders.slice(0,30), + recentOrders, }); }); diff --git a/frontend/src/tools/statistik.jsx b/frontend/src/tools/statistik.jsx index 11bc914..28e4568 100644 --- a/frontend/src/tools/statistik.jsx +++ b/frontend/src/tools/statistik.jsx @@ -25,19 +25,51 @@ 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 PERIODS = [ - { id:'week', label:'Woche' }, - { id:'month', label:'Monat' }, - { id:'year', label:'Jahr' }, - { id:'all', label:'Gesamt'}, + { id:'week', label:'Diese Woche' }, + { id:'month', label:'Dieser Monat' }, + { id:'year', label:'Dieses Jahr' }, + { id:'custom', label:'Eigener Zeitraum' }, + { id:'all', label:'Gesamt'}, ]; -function periodRange(id) { +// Generiere Monat-Optionen (letzten 24 Monate) +function getMonthOptions() { + const opts = []; + const now = new Date(); + for (let i = 0; i < 24; i++) { + const d = new Date(now.getFullYear(), now.getMonth() - i, 1); + const val = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}`; + const label = d.toLocaleDateString('de-DE', { month:'long', year:'numeric' }); + opts.push({ val, label }); + } + return opts; +} + +// Generiere Jahr-Optionen +function getYearOptions() { + const opts = []; + const now = new Date().getFullYear(); + for (let y = now; y >= now - 5; y--) opts.push(y); + return opts; +} + +function periodRange(id, custom) { const now = new Date(); const pad = n => String(n).padStart(2,'0'); const iso = d => `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}`; if (id==='week') { const d=new Date(now); d.setDate(d.getDate()-6); return { from:iso(d), to:iso(now) }; } if (id==='month') { const d=new Date(now.getFullYear(),now.getMonth(),1); return { from:iso(d), to:iso(now) }; } if (id==='year') { return { from:`${now.getFullYear()}-01-01`, to:iso(now) }; } + if (id==='custom' && custom) { + if (custom.type==='month' && custom.month) { + const [y,m] = custom.month.split('-').map(Number); + const last = new Date(y, m, 0); + return { from:`${y}-${pad(m)}-01`, to:iso(last) }; + } + if (custom.type==='year' && custom.year) { + return { from:`${custom.year}-01-01`, to:`${custom.year}-12-31` }; + } + } return {}; } @@ -53,21 +85,22 @@ function KPI({ label, value, color, sub }) { export default function Statistik({ mobile }) { const [period, setPeriod] = useState('month'); + const [custom, setCustom] = useState({ type:'month', month: new Date().toISOString().slice(0,7), year: new Date().getFullYear() }); const [data, setData] = useState(null); const [loading,setLoading] = useState(false); - const [tab, setTab] = useState('overview'); // overview | expenses | orders + const [tab, setTab] = useState('overview'); const [expForm,setExpForm] = useState({ date: new Date().toISOString().slice(0,10), category:'Filament', description:'', amount:'' }); const [saving, setSaving] = useState(false); const load = useCallback(() => { setLoading(true); - const { from, to } = periodRange(period); + const { from, to } = periodRange(period, custom); const q = from ? `?from=${from}&to=${to}` : ''; api('/tools/statistik/overview'+q) .then(d => setData(d)) .catch(()=>{}) .finally(()=>setLoading(false)); - }, [period]); + }, [period, custom]); useEffect(() => { load(); }, [load]); @@ -97,8 +130,8 @@ export default function Statistik({ mobile }) { 📊 3D-Druck Statistik {/* Zeitraum */} -
- {PERIODS.map(p => ( +
+ {PERIODS.filter(p=>p.id!=='custom').map(p => ( ))} + {/* Eigener Zeitraum */} +
+ + {custom.type==='month' && ( + + )} + {custom.type==='year' && ( + + )} +