diff --git a/backend/src/db.js b/backend/src/db.js index 56075bc..20e3b97 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -590,4 +590,17 @@ db.exec(` ) `); +// 3D-Druck Ausgaben (Haushaltsbuch) +db.exec(` + CREATE TABLE IF NOT EXISTS expenses ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + date TEXT NOT NULL, + category TEXT NOT NULL DEFAULT 'Sonstiges', + description TEXT NOT NULL, + amount REAL NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) +`); + module.exports = db; diff --git a/backend/src/tools/statistik/routes.js b/backend/src/tools/statistik/routes.js new file mode 100644 index 0000000..e3c050c --- /dev/null +++ b/backend/src/tools/statistik/routes.js @@ -0,0 +1,116 @@ +const express = require('express'); +const router = express.Router(); +const db = require('../../db'); +const { authenticate } = require('../../middleware/auth'); + +const uid = req => req.user.id; + +// ── Ausgaben CRUD ────────────────────────────────────────────────────────── +router.get('/expenses', authenticate, (req, res) => { + const { from, to } = req.query; + let q = 'SELECT * FROM expenses WHERE user_id=?'; + 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)); +}); + +router.post('/expenses', authenticate, (req, res) => { + const { date, category, description, amount } = req.body; + if (!date || !description || !amount) return res.status(400).json({ error: 'Fehlende Felder' }); + const r = db.prepare('INSERT INTO expenses (user_id,date,category,description,amount) VALUES (?,?,?,?,?)') + .run(uid(req), date, category||'Sonstiges', description, parseFloat(amount)); + res.json(db.prepare('SELECT * FROM expenses WHERE id=?').get(r.lastInsertRowid)); +}); + +router.delete('/expenses/:id', authenticate, (req, res) => { + const e = db.prepare('SELECT id FROM expenses WHERE id=? AND user_id=?').get(req.params.id, uid(req)); + if (!e) return res.status(404).json({ error: 'Nicht gefunden' }); + db.prepare('DELETE FROM expenses WHERE id=?').run(e.id); + res.json({ ok: true }); +}); + +// ── Statistik-Daten ──────────────────────────────────────────────────────── +router.get('/overview', authenticate, (req, res) => { + const { from, to } = req.query; + const u = uid(req); + + 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) { oq += ' AND DATE(o.created_at)>=?'; op.push(from); } + if (to) { oq += ' AND DATE(o.created_at)<=?'; op.push(to); } + oq += ' ORDER BY o.created_at DESC'; + const orders = db.prepare(oq).all(...op); + + 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 totalProfit = totalRevenue - totalBaseCost; + + let eq = 'SELECT * FROM expenses WHERE user_id=?'; + const ep = [u]; + if (from) { eq += ' AND date>=?'; ep.push(from); } + if (to) { eq += ' AND date<=?'; ep.push(to); } + const expenses = db.prepare(eq + ' ORDER BY date DESC').all(...ep); + const totalExpenses = expenses.reduce((s,e) => s + e.amount, 0); + const netProfit = totalProfit - totalExpenses; + + 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++; + else if (!!o.bezahlt) byStatus.bezahlt++; + else if (o.status in byStatus) byStatus[o.status]++; + } + + 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', created_at) 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 }; + } + const monthlyData = Object.values(monthlyMap).sort((a,b)=>a.month.localeCompare(b.month)).slice(-12); + + let cum = 0; + const cumulativeData = monthlyData.map(m => { + cum += (m.revenue||0) - (m.expenses||0); + return { month: m.month, value: Math.round(cum*100)/100 }; + }); + + res.json({ + totalRevenue, openRevenue, totalBaseCost, totalProfit, + totalExpenses, netProfit, totalOrders: orders.length, + byStatus, byCategory, monthlyData, cumulativeData, + expenses: expenses.slice(0,100), + recentOrders: orders.slice(0,30), + }); +}); + +module.exports = router; diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index c2542ef..7de8e22 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1558,6 +1558,7 @@ const SEARCH_TOOL_INDEX = [ // ── Haupt-Tools ────────────────────────────────────────────────────────── {type:'tool',label:'Dashboard',icon:'🏠',sub:'',id:'dashboard'}, {type:'tool',label:'Bestellungen',icon:'📦',sub:'3D-Druck',id:'bestellungen',keywords:['order','auftrag','3d druck']}, + {type:'tool',label:'Statistik',icon:'📊',sub:'3D-Druck',id:'statistik3d',keywords:['statistik','ausgaben','gewinn','haushalt','diagramm']}, {type:'tool',label:'Kalkulator3D',icon:'🖨',sub:'3D-Druck',id:'kalkulator3d',keywords:['kalkulator','rechner','3d archiv','archiv','berechnen','filament','druckkosten']}, {type:'tool',label:'3D-Archiv',icon:'🖨',sub:'3D-Druck',id:'kalkulator3d',keywords:['archiv','druck']}, {type:'tool',label:'Dateien',icon:'📁',sub:'Werkzeuge',id:'dateien',keywords:['datei','file','upload','dokument','ordner','speicher']}, diff --git a/frontend/src/toolRegistry.js b/frontend/src/toolRegistry.js index 974672c..b47b7d2 100644 --- a/frontend/src/toolRegistry.js +++ b/frontend/src/toolRegistry.js @@ -1,5 +1,6 @@ import Kalkulator3D, { SavedList } from './tools/kalkulator3d.jsx'; import Bestellungen from './tools/bestellungen.jsx'; +import Statistik from './tools/statistik.jsx'; import Dateien from './tools/dateien.jsx'; import Nachrichten from './tools/nachrichten.jsx'; import Linkliste from './tools/linkliste.jsx'; diff --git a/frontend/src/tools/statistik.jsx b/frontend/src/tools/statistik.jsx new file mode 100644 index 0000000..11bc914 --- /dev/null +++ b/frontend/src/tools/statistik.jsx @@ -0,0 +1,331 @@ +import { useState, useEffect, useCallback } from 'react'; +import { BarChart, Bar, LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell, PieChart, Pie, Legend } from 'recharts'; + +const api = (path, opts={}) => { + const token = localStorage.getItem('sk_token'); + return fetch('/api' + path, { + headers: { 'Content-Type':'application/json', ...(token?{'Authorization':'Bearer '+token}:{}) }, + ...(opts.body ? { method: opts.method||'POST', body: JSON.stringify(opts.body) } : { method: opts.method||'GET' }), + }).then(r => r.json()); +}; + +const S = { + card: { background:'rgba(255,255,255,0.03)', border:'1px solid rgba(255,255,255,0.07)', borderRadius:12, padding:'16px 18px', marginBottom:12 }, + head: { color:'rgba(255,255,255,0.4)', fontFamily:'monospace', fontSize:10, letterSpacing:1, marginBottom:10, fontWeight:700 }, + val: (c='#fff') => ({ color:c, fontFamily:"'Space Mono',monospace", fontSize:22, fontWeight:700 }), + sub: { color:'rgba(255,255,255,0.3)', fontFamily:'monospace', fontSize:10 }, + inp: { background:'rgba(255,255,255,0.06)', border:'1px solid rgba(255,255,255,0.12)', borderRadius:8, padding:'8px 12px', color:'#fff', fontFamily:'monospace', fontSize:12, outline:'none', width:'100%', boxSizing:'border-box' }, + btn: (c='#4ecdc4') => ({ background:`${c}18`, border:`1px solid ${c}44`, borderRadius:8, padding:'8px 14px', color:c, fontFamily:'monospace', fontSize:11, cursor:'pointer' }), +}; + +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 PERIODS = [ + { id:'week', label:'Woche' }, + { id:'month', label:'Monat' }, + { id:'year', label:'Jahr' }, + { id:'all', label:'Gesamt'}, +]; + +function periodRange(id) { + 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) }; } + return {}; +} + +function KPI({ label, value, color, sub }) { + return ( +
+
{label}
+
{value}
+ {sub &&
{sub}
} +
+ ); +} + +export default function Statistik({ mobile }) { + const [period, setPeriod] = useState('month'); + const [data, setData] = useState(null); + const [loading,setLoading] = useState(false); + const [tab, setTab] = useState('overview'); // overview | expenses | orders + 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 q = from ? `?from=${from}&to=${to}` : ''; + api('/tools/statistik/overview'+q) + .then(d => setData(d)) + .catch(()=>{}) + .finally(()=>setLoading(false)); + }, [period]); + + useEffect(() => { load(); }, [load]); + + async function addExpense() { + if (!expForm.description || !expForm.amount) return; + setSaving(true); + try { + await api('/tools/statistik/expenses', { body: expForm }); + setExpForm(p => ({ ...p, description:'', amount:'' })); + load(); + } finally { setSaving(false); } + } + + async function delExpense(id) { + await api(`/tools/statistik/expenses/${id}`, { method:'DELETE' }); + load(); + } + + const netColor = (data?.netProfit||0) >= 0 ? '#4ade80' : '#f87171'; + + return ( +
+ + {/* ── Header ── */} +
+

+ 📊 3D-Druck Statistik +

+ {/* Zeitraum */} +
+ {PERIODS.map(p => ( + + ))} +
+
+ + {/* ── Tabs ── */} +
+ {[['overview','📈 Übersicht'],['expenses','💸 Ausgaben'],['orders','📦 Bestellungen']].map(([id,label])=>( + + ))} +
+ + {loading &&
Lädt…
} + + {/* ══ ÜBERSICHT ══ */} + {tab==='overview' && data && ( + <> + {/* KPI Kacheln */} +
+
EINNAHMEN & GEWINN
+
+ + + + +
+
+ + + +
+
+ + {/* Status-Verteilung */} +
+
BESTELLUNGEN NACH STATUS
+
+ {[ + ['Warteliste', data.byStatus.warteliste, '#ffe66d'], + ['In Arbeit', data.byStatus.in_arbeit, '#4ecdc4'], + ['Fertig', data.byStatus.fertig, '#6bcb77'], + ['Bezahlt', data.byStatus.bezahlt, '#c084fc'], + ['Abgeschlossen', data.byStatus.abgeschlossen, '#4ade80'], + ].map(([label, val, color]) => val > 0 && ( +
+
{val}
+
{label}
+
+ ))} +
+
+ + {/* Balkendiagramm Einnahmen vs Ausgaben */} + {data.monthlyData.length > 0 && ( +
+
EINNAHMEN VS. AUSGABEN (MONATLICH)
+ + + + `${v}€`} width={45}/> + [`${v.toFixed(2)} €`, n==='revenue'?'Einnahmen':'Ausgaben']} + labelFormatter={fmtMonth}/> + + + + +
+ )} + + {/* Kumulierter Gewinn + Ausgaben nach Kategorie */} +
+ {data.cumulativeData.length > 1 && ( +
+
KUMULIERTER NETTOGEWINN
+ + + + `${v}€`} width={42}/> + [`${v.toFixed(2)} €`, 'Gewinn kumuliert']} labelFormatter={fmtMonth}/> + + + +
+ )} + {Object.keys(data.byCategory).length > 0 && ( +
+
AUSGABEN NACH KATEGORIE
+ + + ({name,value}))} + cx="50%" cy="50%" innerRadius={40} outerRadius={65} + dataKey="value" paddingAngle={3}> + {Object.entries(data.byCategory).map(([name],i)=>( + + ))} + + [`${v.toFixed(2)} €`]}/> + {v}}/> + + +
+ )} +
+ + )} + + {/* ══ AUSGABEN ══ */} + {tab==='expenses' && ( + <> + {/* Formular */} +
+
NEUE AUSGABE
+
+ setExpForm(p=>({...p,date:e.target.value}))} + style={{...S.inp,width:140}}/> + + setExpForm(p=>({...p,description:e.target.value}))} + style={{...S.inp,flex:1,minWidth:140}}/> + setExpForm(p=>({...p,amount:e.target.value}))} + onKeyDown={e=>e.key==='Enter'&&addExpense()} + style={{...S.inp,width:90,textAlign:'right'}}/> + +
+
+ + {/* Liste */} +
+
AUSGABEN {period!=='all'&&`(${PERIODS.find(p=>p.id===period)?.label?.toUpperCase()})`}
+ {!data?.expenses?.length + ?
Keine Ausgaben im gewählten Zeitraum.
+ : data.expenses.map(e => ( +
+ + {e.date} + + + {e.category} + + + {e.description} + + + {fmt(e.amount)} + + +
+ )) + } + {data?.expenses?.length > 0 && ( +
+ + Σ {fmt(data.totalExpenses)} + +
+ )} +
+ + )} + + {/* ══ BESTELLUNGEN ══ */} + {tab==='orders' && data && ( +
+
BESTELLUNGEN IM ZEITRAUM ({data.recentOrders.length})
+ {!data.recentOrders.length + ?
Keine Bestellungen im gewählten Zeitraum.
+ : data.recentOrders.map(o => { + const statusColor = { warteliste:'#ffe66d', in_arbeit:'#4ecdc4', fertig:'#6bcb77', bezahlt:'#c084fc' }; + const st = !!o.bezahlt && !!o.abgeholt ? 'abgeschlossen' : !!o.bezahlt ? 'bezahlt' : o.status; + const col = st==='abgeschlossen' ? '#4ade80' : statusColor[st] || '#888'; + return ( +
+ + {new Date(o.created_at).toLocaleDateString('de-DE')} + + + {o.name} + + + {st} + + 0?'#4ade80':'rgba(255,255,255,0.3)', + fontFamily:"'Space Mono',monospace",fontSize:12,fontWeight:700,flexShrink:0}}> + {fmt(o.revenue||0)} + +
+ ); + }) + } +
+ )} +
+ ); +}