feat: 3D-Statistik Tool mit Haushaltsbuch, Diagrammen und Ausgaben-Tracker
This commit is contained in:
@@ -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;
|
module.exports = db;
|
||||||
|
|||||||
116
backend/src/tools/statistik/routes.js
Normal file
116
backend/src/tools/statistik/routes.js
Normal file
@@ -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;
|
||||||
@@ -1558,6 +1558,7 @@ const SEARCH_TOOL_INDEX = [
|
|||||||
// ── Haupt-Tools ──────────────────────────────────────────────────────────
|
// ── Haupt-Tools ──────────────────────────────────────────────────────────
|
||||||
{type:'tool',label:'Dashboard',icon:'🏠',sub:'',id:'dashboard'},
|
{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:'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:'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:'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']},
|
{type:'tool',label:'Dateien',icon:'📁',sub:'Werkzeuge',id:'dateien',keywords:['datei','file','upload','dokument','ordner','speicher']},
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import Kalkulator3D, { SavedList } from './tools/kalkulator3d.jsx';
|
import Kalkulator3D, { SavedList } from './tools/kalkulator3d.jsx';
|
||||||
import Bestellungen from './tools/bestellungen.jsx';
|
import Bestellungen from './tools/bestellungen.jsx';
|
||||||
|
import Statistik from './tools/statistik.jsx';
|
||||||
import Dateien from './tools/dateien.jsx';
|
import Dateien from './tools/dateien.jsx';
|
||||||
import Nachrichten from './tools/nachrichten.jsx';
|
import Nachrichten from './tools/nachrichten.jsx';
|
||||||
import Linkliste from './tools/linkliste.jsx';
|
import Linkliste from './tools/linkliste.jsx';
|
||||||
|
|||||||
331
frontend/src/tools/statistik.jsx
Normal file
331
frontend/src/tools/statistik.jsx
Normal file
@@ -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 (
|
||||||
|
<div style={{flex:1,minWidth:130}}>
|
||||||
|
<div style={S.sub}>{label}</div>
|
||||||
|
<div style={S.val(color)}>{value}</div>
|
||||||
|
{sub && <div style={{...S.sub,marginTop:2}}>{sub}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div style={{ padding: mobile?'14px 14px 90px':'28px 36px', maxWidth:900 }}>
|
||||||
|
|
||||||
|
{/* ── Header ── */}
|
||||||
|
<div style={{display:'flex',alignItems:'center',justifyContent:'space-between',marginBottom:20,flexWrap:'wrap',gap:10}}>
|
||||||
|
<h2 style={{color:'#fff',fontFamily:'monospace',fontSize:18,fontWeight:700,margin:0}}>
|
||||||
|
📊 3D-Druck Statistik
|
||||||
|
</h2>
|
||||||
|
{/* Zeitraum */}
|
||||||
|
<div style={{display:'flex',gap:4}}>
|
||||||
|
{PERIODS.map(p => (
|
||||||
|
<button key={p.id} onClick={()=>setPeriod(p.id)} style={{
|
||||||
|
background: period===p.id ? 'rgba(78,205,196,0.15)' : 'rgba(255,255,255,0.04)',
|
||||||
|
border: `1px solid ${period===p.id ? 'rgba(78,205,196,0.5)' : 'rgba(255,255,255,0.1)'}`,
|
||||||
|
borderRadius:8, padding:'5px 12px',
|
||||||
|
color: period===p.id ? '#4ecdc4' : 'rgba(255,255,255,0.4)',
|
||||||
|
fontFamily:'monospace', fontSize:11, cursor:'pointer',
|
||||||
|
}}>{p.label}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Tabs ── */}
|
||||||
|
<div style={{display:'flex',gap:4,marginBottom:16}}>
|
||||||
|
{[['overview','📈 Übersicht'],['expenses','💸 Ausgaben'],['orders','📦 Bestellungen']].map(([id,label])=>(
|
||||||
|
<button key={id} onClick={()=>setTab(id)} style={{
|
||||||
|
background: tab===id ? 'rgba(255,255,255,0.08)' : 'transparent',
|
||||||
|
border: `1px solid ${tab===id ? 'rgba(255,255,255,0.2)' : 'rgba(255,255,255,0.08)'}`,
|
||||||
|
borderRadius:8, padding:'6px 14px',
|
||||||
|
color: tab===id ? '#fff' : 'rgba(255,255,255,0.4)',
|
||||||
|
fontFamily:'monospace', fontSize:11, cursor:'pointer',
|
||||||
|
}}>{label}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && <div style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:12}}>Lädt…</div>}
|
||||||
|
|
||||||
|
{/* ══ ÜBERSICHT ══ */}
|
||||||
|
{tab==='overview' && data && (
|
||||||
|
<>
|
||||||
|
{/* KPI Kacheln */}
|
||||||
|
<div style={{...S.card}}>
|
||||||
|
<div style={S.head}>EINNAHMEN & GEWINN</div>
|
||||||
|
<div style={{display:'flex',flexWrap:'wrap',gap:20,marginBottom:16}}>
|
||||||
|
<KPI label="Eingenommen" value={fmt(data.totalRevenue)} color="#4ade80" sub="bezahlt"/>
|
||||||
|
<KPI label="Offen" value={fmt(data.openRevenue)} color="#ffe66d" sub="in Arbeit/Fertig"/>
|
||||||
|
<KPI label="Materialkosten" value={fmt(data.totalBaseCost)} color="#f87171" sub="Selbstkosten"/>
|
||||||
|
<KPI label="Rohgewinn" value={fmt(data.totalProfit)} color="#60a5fa" sub="nach Materialkosten"/>
|
||||||
|
</div>
|
||||||
|
<div style={{borderTop:'1px solid rgba(255,255,255,0.06)',paddingTop:16,display:'flex',flexWrap:'wrap',gap:20}}>
|
||||||
|
<KPI label="Ausgaben" value={fmt(data.totalExpenses)} color="#f87171" sub="Einkäufe"/>
|
||||||
|
<KPI label="NETTOGEWINN" value={fmt(data.netProfit)} color={netColor} sub="Rohgewinn − Ausgaben"/>
|
||||||
|
<KPI label="Bestellungen" value={data.totalOrders} color="rgba(255,255,255,0.6)" sub="im Zeitraum"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status-Verteilung */}
|
||||||
|
<div style={{...S.card}}>
|
||||||
|
<div style={S.head}>BESTELLUNGEN NACH STATUS</div>
|
||||||
|
<div style={{display:'flex',flexWrap:'wrap',gap:8}}>
|
||||||
|
{[
|
||||||
|
['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 && (
|
||||||
|
<div key={label} style={{background:`${color}12`,border:`1px solid ${color}33`,
|
||||||
|
borderRadius:8,padding:'8px 14px',textAlign:'center'}}>
|
||||||
|
<div style={{color,fontFamily:"'Space Mono',monospace",fontSize:18,fontWeight:700}}>{val}</div>
|
||||||
|
<div style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:9}}>{label}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Balkendiagramm Einnahmen vs Ausgaben */}
|
||||||
|
{data.monthlyData.length > 0 && (
|
||||||
|
<div style={S.card}>
|
||||||
|
<div style={S.head}>EINNAHMEN VS. AUSGABEN (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}/>
|
||||||
|
<YAxis tick={{fill:'rgba(255,255,255,0.3)',fontSize:10}} axisLine={false} tickLine={false} tickFormatter={v=>`${v}€`} width={45}/>
|
||||||
|
<Tooltip contentStyle={{background:'#1a1d2e',border:'1px solid rgba(255,255,255,0.1)',borderRadius:8,fontFamily:'monospace',fontSize:11}}
|
||||||
|
formatter={(v,n)=>[`${v.toFixed(2)} €`, n==='revenue'?'Einnahmen':'Ausgaben']}
|
||||||
|
labelFormatter={fmtMonth}/>
|
||||||
|
<Bar dataKey="revenue" fill="#4ade80" radius={[4,4,0,0]} maxBarSize={28}/>
|
||||||
|
<Bar dataKey="expenses" fill="#f87171" radius={[4,4,0,0]} maxBarSize={28}/>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Kumulierter Gewinn + Ausgaben nach Kategorie */}
|
||||||
|
<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>
|
||||||
|
<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}/>
|
||||||
|
<YAxis tick={{fill:'rgba(255,255,255,0.3)',fontSize:9}} axisLine={false} tickLine={false} tickFormatter={v=>`${v}€`} width={42}/>
|
||||||
|
<Tooltip contentStyle={{background:'#1a1d2e',border:'1px solid rgba(255,255,255,0.1)',borderRadius:8,fontFamily:'monospace',fontSize:11}}
|
||||||
|
formatter={v=>[`${v.toFixed(2)} €`, 'Gewinn kumuliert']} labelFormatter={fmtMonth}/>
|
||||||
|
<Line type="monotone" dataKey="value" stroke="#4ecdc4" strokeWidth={2} dot={false}/>
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{Object.keys(data.byCategory).length > 0 && (
|
||||||
|
<div style={{...S.card,flex:1,minWidth:200}}>
|
||||||
|
<div style={S.head}>AUSGABEN NACH KATEGORIE</div>
|
||||||
|
<ResponsiveContainer width="100%" height={160}>
|
||||||
|
<PieChart>
|
||||||
|
<Pie data={Object.entries(data.byCategory).map(([name,value])=>({name,value}))}
|
||||||
|
cx="50%" cy="50%" innerRadius={40} outerRadius={65}
|
||||||
|
dataKey="value" paddingAngle={3}>
|
||||||
|
{Object.entries(data.byCategory).map(([name],i)=>(
|
||||||
|
<Cell key={name} fill={CAT_COLORS[name]||'#888'}/>
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip contentStyle={{background:'#1a1d2e',border:'1px solid rgba(255,255,255,0.1)',borderRadius:8,fontFamily:'monospace',fontSize:11}}
|
||||||
|
formatter={v=>[`${v.toFixed(2)} €`]}/>
|
||||||
|
<Legend iconType="circle" iconSize={8}
|
||||||
|
formatter={v=><span style={{color:'rgba(255,255,255,0.5)',fontFamily:'monospace',fontSize:9}}>{v}</span>}/>
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ══ AUSGABEN ══ */}
|
||||||
|
{tab==='expenses' && (
|
||||||
|
<>
|
||||||
|
{/* Formular */}
|
||||||
|
<div style={S.card}>
|
||||||
|
<div style={S.head}>NEUE AUSGABE</div>
|
||||||
|
<div style={{display:'flex',gap:8,flexWrap:'wrap'}}>
|
||||||
|
<input type="date" value={expForm.date}
|
||||||
|
onChange={e=>setExpForm(p=>({...p,date:e.target.value}))}
|
||||||
|
style={{...S.inp,width:140}}/>
|
||||||
|
<select value={expForm.category}
|
||||||
|
onChange={e=>setExpForm(p=>({...p,category:e.target.value}))}
|
||||||
|
style={{...S.inp,width:130}}>
|
||||||
|
{CATS.map(c=><option key={c}>{c}</option>)}
|
||||||
|
</select>
|
||||||
|
<input placeholder="Beschreibung" value={expForm.description}
|
||||||
|
onChange={e=>setExpForm(p=>({...p,description:e.target.value}))}
|
||||||
|
style={{...S.inp,flex:1,minWidth:140}}/>
|
||||||
|
<input type="number" placeholder="0.00" step="0.01" min="0" value={expForm.amount}
|
||||||
|
onChange={e=>setExpForm(p=>({...p,amount:e.target.value}))}
|
||||||
|
onKeyDown={e=>e.key==='Enter'&&addExpense()}
|
||||||
|
style={{...S.inp,width:90,textAlign:'right'}}/>
|
||||||
|
<button onClick={addExpense} disabled={saving}
|
||||||
|
style={{...S.btn('#4ade80'),opacity:saving?0.5:1,whiteSpace:'nowrap'}}>
|
||||||
|
{saving?'…':'+ Hinzufügen'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Liste */}
|
||||||
|
<div style={S.card}>
|
||||||
|
<div style={S.head}>AUSGABEN {period!=='all'&&`(${PERIODS.find(p=>p.id===period)?.label?.toUpperCase()})`}</div>
|
||||||
|
{!data?.expenses?.length
|
||||||
|
? <div style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:12}}>Keine Ausgaben im gewählten Zeitraum.</div>
|
||||||
|
: data.expenses.map(e => (
|
||||||
|
<div key={e.id} style={{display:'flex',alignItems:'center',gap:10,padding:'8px 0',
|
||||||
|
borderBottom:'1px solid rgba(255,255,255,0.05)'}}>
|
||||||
|
<span style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:10,flexShrink:0,width:80}}>
|
||||||
|
{e.date}
|
||||||
|
</span>
|
||||||
|
<span style={{background:`${CAT_COLORS[e.category]||'#888'}18`,
|
||||||
|
border:`1px solid ${CAT_COLORS[e.category]||'#888'}44`,
|
||||||
|
borderRadius:6,padding:'2px 8px',color:CAT_COLORS[e.category]||'#888',
|
||||||
|
fontFamily:'monospace',fontSize:9,flexShrink:0}}>
|
||||||
|
{e.category}
|
||||||
|
</span>
|
||||||
|
<span style={{color:'rgba(255,255,255,0.7)',fontFamily:'monospace',fontSize:12,flex:1}}>
|
||||||
|
{e.description}
|
||||||
|
</span>
|
||||||
|
<span style={{color:'#f87171',fontFamily:"'Space Mono',monospace",fontSize:13,fontWeight:700,flexShrink:0}}>
|
||||||
|
{fmt(e.amount)}
|
||||||
|
</span>
|
||||||
|
<button onClick={()=>delExpense(e.id)}
|
||||||
|
style={{background:'rgba(248,113,113,0.1)',border:'1px solid rgba(248,113,113,0.2)',
|
||||||
|
borderRadius:6,padding:'3px 8px',color:'#f87171',fontFamily:'monospace',fontSize:10,cursor:'pointer',flexShrink:0}}>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
{data?.expenses?.length > 0 && (
|
||||||
|
<div style={{display:'flex',justifyContent:'flex-end',paddingTop:10}}>
|
||||||
|
<span style={{color:'#f87171',fontFamily:"'Space Mono',monospace",fontSize:14,fontWeight:700}}>
|
||||||
|
Σ {fmt(data.totalExpenses)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ══ BESTELLUNGEN ══ */}
|
||||||
|
{tab==='orders' && data && (
|
||||||
|
<div style={S.card}>
|
||||||
|
<div style={S.head}>BESTELLUNGEN IM ZEITRAUM ({data.recentOrders.length})</div>
|
||||||
|
{!data.recentOrders.length
|
||||||
|
? <div style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:12}}>Keine Bestellungen im gewählten Zeitraum.</div>
|
||||||
|
: 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 (
|
||||||
|
<div key={o.id} style={{display:'flex',alignItems:'center',gap:10,padding:'8px 0',
|
||||||
|
borderBottom:'1px solid rgba(255,255,255,0.05)',flexWrap:'wrap'}}>
|
||||||
|
<span style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:10,flexShrink:0}}>
|
||||||
|
{new Date(o.created_at).toLocaleDateString('de-DE')}
|
||||||
|
</span>
|
||||||
|
<span style={{color:'rgba(255,255,255,0.8)',fontFamily:'monospace',fontSize:12,flex:1}}>
|
||||||
|
{o.name}
|
||||||
|
</span>
|
||||||
|
<span style={{background:`${col}18`,border:`1px solid ${col}44`,borderRadius:6,
|
||||||
|
padding:'2px 8px',color:col,fontFamily:'monospace',fontSize:9,flexShrink:0}}>
|
||||||
|
{st}
|
||||||
|
</span>
|
||||||
|
<span style={{color:o.revenue>0?'#4ade80':'rgba(255,255,255,0.3)',
|
||||||
|
fontFamily:"'Space Mono',monospace",fontSize:12,fontWeight:700,flexShrink:0}}>
|
||||||
|
{fmt(o.revenue||0)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user