fix: revenue aus order_items korrekt berechnet, custom Zeitraum (Monat/Jahr)
This commit is contained in:
@@ -12,8 +12,7 @@ router.get('/expenses', authenticate, (req, res) => {
|
|||||||
const p = [uid(req)];
|
const p = [uid(req)];
|
||||||
if (from) { q += ' AND date>=?'; p.push(from); }
|
if (from) { q += ' AND date>=?'; p.push(from); }
|
||||||
if (to) { q += ' AND date<=?'; p.push(to); }
|
if (to) { q += ' AND date<=?'; p.push(to); }
|
||||||
q += ' ORDER BY date DESC, id DESC';
|
res.json(db.prepare(q + ' ORDER BY date DESC, id DESC').all(...p));
|
||||||
res.json(db.prepare(q).all(...p));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/expenses', authenticate, (req, res) => {
|
router.post('/expenses', authenticate, (req, res) => {
|
||||||
@@ -31,41 +30,60 @@ router.delete('/expenses/:id', authenticate, (req, res) => {
|
|||||||
res.json({ ok: true });
|
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) => {
|
router.get('/overview', authenticate, (req, res) => {
|
||||||
const { from, to } = req.query;
|
const { from, to } = req.query;
|
||||||
const u = uid(req);
|
const u = uid(req);
|
||||||
|
|
||||||
// Bezahlte Aufträge: nach bezahlt_am filtern; offene: nach created_at
|
// Alle Aufträge des Users
|
||||||
// Zeitraum-Filter: zeige Auftrag wenn er im Zeitraum bezahlt wurde (bezahlt)
|
const allOrders = db.prepare('SELECT * FROM orders WHERE user_id=? ORDER BY created_at DESC').all(u);
|
||||||
// ODER noch offen ist und im Zeitraum erstellt wurde
|
|
||||||
let oq = `SELECT o.*,
|
// Zeitraum-Filter anwenden:
|
||||||
COALESCE(o.custom_price,
|
// bezahlte Aufträge → bezahlt_am im Zeitraum
|
||||||
(SELECT SUM(oi.custom_price * oi.stueckzahl) FROM order_items oi WHERE oi.order_id=o.id AND oi.custom_price IS NOT NULL)
|
// offene Aufträge → created_at im Zeitraum
|
||||||
) as revenue,
|
const inRange = (dateStr) => {
|
||||||
(SELECT COALESCE(SUM(COALESCE(c.preis_freundschaft, oi.preis_freundschaft, 0) * oi.stueckzahl),0)
|
if (!dateStr) return false;
|
||||||
FROM order_items oi LEFT JOIN calculations c ON c.id=oi.calculation_id WHERE oi.order_id=o.id) as base_cost
|
if (!from && !to) return true;
|
||||||
FROM orders o WHERE o.user_id=?`;
|
const d = dateStr.slice(0,10);
|
||||||
const op = [u];
|
if (from && d < from) return false;
|
||||||
if (from && to) {
|
if (to && d > to) return false;
|
||||||
oq += ` AND (
|
return true;
|
||||||
(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)<=?)
|
const orders = allOrders.filter(o => {
|
||||||
)`;
|
if (o.bezahlt) return inRange(o.bezahlt_am || o.created_at);
|
||||||
op.push(from, to, from, to);
|
return inRange(o.created_at);
|
||||||
}
|
});
|
||||||
oq += ' ORDER BY COALESCE(o.bezahlt_am, o.created_at) DESC';
|
|
||||||
const orders = db.prepare(oq).all(...op);
|
|
||||||
|
|
||||||
const paidOrders = orders.filter(o => !!o.bezahlt);
|
const paidOrders = orders.filter(o => !!o.bezahlt);
|
||||||
const openOrders = orders.filter(o => !o.bezahlt && o.status !== 'warteliste');
|
const openOrders = orders.filter(o => !o.bezahlt && o.status !== 'warteliste');
|
||||||
|
|
||||||
const totalRevenue = paidOrders.reduce((s,o) => s + (o.revenue || 0), 0);
|
const totalRevenue = paidOrders.reduce((s,o) => s + getOrderRevenue(o), 0);
|
||||||
const openRevenue = openOrders.reduce((s,o) => s + (o.revenue || 0), 0);
|
const openRevenue = openOrders.reduce((s,o) => s + getOrderRevenue(o), 0);
|
||||||
const totalBaseCost = paidOrders.reduce((s,o) => s + (o.base_cost || 0), 0);
|
const totalBaseCost = paidOrders.reduce((s,o) => s + getOrderBaseCost(o.id), 0);
|
||||||
const totalProfit = totalRevenue - totalBaseCost;
|
const totalProfit = totalRevenue - totalBaseCost;
|
||||||
|
|
||||||
|
// Ausgaben im Zeitraum
|
||||||
let eq = 'SELECT * FROM expenses WHERE user_id=?';
|
let eq = 'SELECT * FROM expenses WHERE user_id=?';
|
||||||
const ep = [u];
|
const ep = [u];
|
||||||
if (from) { eq += ' AND date>=?'; ep.push(from); }
|
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 totalExpenses = expenses.reduce((s,e) => s + e.amount, 0);
|
||||||
const netProfit = totalProfit - totalExpenses;
|
const netProfit = totalProfit - totalExpenses;
|
||||||
|
|
||||||
|
// Status-Verteilung (gefilterte Aufträge)
|
||||||
const byStatus = { warteliste:0, in_arbeit:0, fertig:0, bezahlt:0, abgeschlossen:0 };
|
const byStatus = { warteliste:0, in_arbeit:0, fertig:0, bezahlt:0, abgeschlossen:0 };
|
||||||
for (const o of orders) {
|
for (const o of orders) {
|
||||||
if (!!o.bezahlt && !!o.abgeholt) byStatus.abgeschlossen++;
|
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]++;
|
else if (o.status in byStatus) byStatus[o.status]++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ausgaben nach Kategorie (gefiltert)
|
||||||
const byCategory = {};
|
const byCategory = {};
|
||||||
for (const e of expenses) {
|
for (const e of expenses) {
|
||||||
byCategory[e.category] = (byCategory[e.category] || 0) + e.amount;
|
byCategory[e.category] = (byCategory[e.category] || 0) + e.amount;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Monatliche Daten
|
// Monatliche Daten: IMMER alle Daten (letzte 12 Monate) für das Balkendiagramm
|
||||||
const monthlyRev = db.prepare(`
|
// Revenue korrekt: für jeden bezahlten Auftrag getOrderRevenue aufrufen
|
||||||
SELECT strftime('%Y-%m', CASE WHEN bezahlt=1 THEN bezahlt_am ELSE created_at END) as month,
|
const monthBuckets = {};
|
||||||
SUM(CASE WHEN bezahlt=1 THEN COALESCE(custom_price,0) ELSE 0 END) as revenue,
|
for (const o of allOrders) {
|
||||||
COUNT(*) as orders
|
// Monat: für bezahlte = bezahlt_am, für offene = created_at
|
||||||
FROM orders WHERE user_id=? GROUP BY month ORDER BY month
|
const dateStr = o.bezahlt ? (o.bezahlt_am || o.created_at) : o.created_at;
|
||||||
`).all(u);
|
const month = dateStr?.slice(0,7);
|
||||||
|
if (!month) continue;
|
||||||
const monthlyExp = db.prepare(`
|
if (!monthBuckets[month]) monthBuckets[month] = { month, revenue:0, orders:0, expenses:0 };
|
||||||
SELECT strftime('%Y-%m', date) as month, SUM(amount) as expenses
|
if (o.bezahlt) {
|
||||||
FROM expenses WHERE user_id=? GROUP BY month ORDER BY month
|
monthBuckets[month].revenue += getOrderRevenue(o);
|
||||||
`).all(u);
|
}
|
||||||
|
monthBuckets[month].orders++;
|
||||||
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);
|
|
||||||
|
|
||||||
|
// 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;
|
let cum = 0;
|
||||||
const cumulativeData = monthlyData.map(m => {
|
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 };
|
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({
|
res.json({
|
||||||
totalRevenue, openRevenue, totalBaseCost, totalProfit,
|
totalRevenue: Math.round(totalRevenue*100)/100,
|
||||||
totalExpenses, netProfit, totalOrders: orders.length,
|
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,
|
byStatus, byCategory, monthlyData, cumulativeData,
|
||||||
expenses: expenses.slice(0,100),
|
expenses: expenses.slice(0,100),
|
||||||
recentOrders: orders.slice(0,30),
|
recentOrders,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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 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 = [
|
const PERIODS = [
|
||||||
{ id:'week', label:'Woche' },
|
{ id:'week', label:'Diese Woche' },
|
||||||
{ id:'month', label:'Monat' },
|
{ id:'month', label:'Dieser Monat' },
|
||||||
{ id:'year', label:'Jahr' },
|
{ id:'year', label:'Dieses Jahr' },
|
||||||
|
{ id:'custom', label:'Eigener Zeitraum' },
|
||||||
{ id:'all', label:'Gesamt'},
|
{ 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 now = new Date();
|
||||||
const pad = n => String(n).padStart(2,'0');
|
const pad = n => String(n).padStart(2,'0');
|
||||||
const iso = d => `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}`;
|
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==='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==='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==='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 {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,21 +85,22 @@ function KPI({ label, value, color, sub }) {
|
|||||||
|
|
||||||
export default function Statistik({ mobile }) {
|
export default function Statistik({ mobile }) {
|
||||||
const [period, setPeriod] = useState('month');
|
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 [data, setData] = useState(null);
|
||||||
const [loading,setLoading] = useState(false);
|
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 [expForm,setExpForm] = useState({ date: new Date().toISOString().slice(0,10), category:'Filament', description:'', amount:'' });
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
const load = useCallback(() => {
|
const load = useCallback(() => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const { from, to } = periodRange(period);
|
const { from, to } = periodRange(period, custom);
|
||||||
const q = from ? `?from=${from}&to=${to}` : '';
|
const q = from ? `?from=${from}&to=${to}` : '';
|
||||||
api('/tools/statistik/overview'+q)
|
api('/tools/statistik/overview'+q)
|
||||||
.then(d => setData(d))
|
.then(d => setData(d))
|
||||||
.catch(()=>{})
|
.catch(()=>{})
|
||||||
.finally(()=>setLoading(false));
|
.finally(()=>setLoading(false));
|
||||||
}, [period]);
|
}, [period, custom]);
|
||||||
|
|
||||||
useEffect(() => { load(); }, [load]);
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
@@ -97,8 +130,8 @@ export default function Statistik({ mobile }) {
|
|||||||
📊 3D-Druck Statistik
|
📊 3D-Druck Statistik
|
||||||
</h2>
|
</h2>
|
||||||
{/* Zeitraum */}
|
{/* Zeitraum */}
|
||||||
<div style={{display:'flex',gap:4}}>
|
<div style={{display:'flex',gap:4,flexWrap:'wrap',alignItems:'center'}}>
|
||||||
{PERIODS.map(p => (
|
{PERIODS.filter(p=>p.id!=='custom').map(p => (
|
||||||
<button key={p.id} onClick={()=>setPeriod(p.id)} style={{
|
<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)',
|
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)'}`,
|
border: `1px solid ${period===p.id ? 'rgba(78,205,196,0.5)' : 'rgba(255,255,255,0.1)'}`,
|
||||||
@@ -107,6 +140,36 @@ export default function Statistik({ mobile }) {
|
|||||||
fontFamily:'monospace', fontSize:11, cursor:'pointer',
|
fontFamily:'monospace', fontSize:11, cursor:'pointer',
|
||||||
}}>{p.label}</button>
|
}}>{p.label}</button>
|
||||||
))}
|
))}
|
||||||
|
{/* Eigener Zeitraum */}
|
||||||
|
<div style={{display:'flex',gap:4,alignItems:'center'}}>
|
||||||
|
<select value={custom.type} onChange={e=>setCustom(p=>({...p,type:e.target.value}))}
|
||||||
|
onClick={()=>setPeriod('custom')}
|
||||||
|
style={{background:'rgba(255,255,255,0.06)',border:'1px solid rgba(255,255,255,0.12)',
|
||||||
|
borderRadius:8,padding:'5px 8px',color:'rgba(255,255,255,0.6)',fontFamily:'monospace',fontSize:11,cursor:'pointer'}}>
|
||||||
|
<option value="month">Monat</option>
|
||||||
|
<option value="year">Jahr</option>
|
||||||
|
</select>
|
||||||
|
{custom.type==='month' && (
|
||||||
|
<select value={custom.month}
|
||||||
|
onChange={e=>{setCustom(p=>({...p,month:e.target.value}));setPeriod('custom');}}
|
||||||
|
style={{background: period==='custom'?'rgba(78,205,196,0.1)':'rgba(255,255,255,0.06)',
|
||||||
|
border:`1px solid ${period==='custom'?'rgba(78,205,196,0.4)':'rgba(255,255,255,0.12)'}`,
|
||||||
|
borderRadius:8,padding:'5px 8px',color:period==='custom'?'#4ecdc4':'rgba(255,255,255,0.6)',
|
||||||
|
fontFamily:'monospace',fontSize:11,cursor:'pointer'}}>
|
||||||
|
{getMonthOptions().map(o=><option key={o.val} value={o.val}>{o.label}</option>)}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
{custom.type==='year' && (
|
||||||
|
<select value={custom.year}
|
||||||
|
onChange={e=>{setCustom(p=>({...p,year:Number(e.target.value)}));setPeriod('custom');}}
|
||||||
|
style={{background: period==='custom'?'rgba(78,205,196,0.1)':'rgba(255,255,255,0.06)',
|
||||||
|
border:`1px solid ${period==='custom'?'rgba(78,205,196,0.4)':'rgba(255,255,255,0.12)'}`,
|
||||||
|
borderRadius:8,padding:'5px 8px',color:period==='custom'?'#4ecdc4':'rgba(255,255,255,0.6)',
|
||||||
|
fontFamily:'monospace',fontSize:11,cursor:'pointer'}}>
|
||||||
|
{getYearOptions().map(y=><option key={y} value={y}>{y}</option>)}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user