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 ( +