Files
dickendock/frontend/src/tools/statistik.jsx

791 lines
39 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect } 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)}`;
// Lokales Datum als YYYY-MM-DD (kein UTC-Bug wie bei toISOString)
function todayLocal() {
const d = new Date();
const y = d.getFullYear(), m = String(d.getMonth()+1).padStart(2,'0'), day = String(d.getDate()).padStart(2,'0');
return `${y}-${m}-${day}`;
}
const fmtMonth = m => {
if (!m) return '';
if (/^\d{4}-\d{2}$/.test(m)) {
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);
}
if (/^\d{4}-\d{2}-\d{2}$/.test(m)) {
const [,mo,d] = m.split('-');
return `${d}.${mo}.`;
}
return m;
};
// Bei Wochen-Granularität: zeigt "15.06. 21.06." statt nur den Montag
const fmtMonthRange = (m, granularity) => {
if (!m || granularity !== 'week' || !/^\d{4}-\d{2}-\d{2}$/.test(m)) return fmtMonth(m);
const [y,mo,d] = m.split('-').map(Number);
const start = new Date(y, mo-1, d);
const end = new Date(y, mo-1, d+6);
const f = dt => `${String(dt.getDate()).padStart(2,'0')}.${String(dt.getMonth()+1).padStart(2,'0')}.`;
return `${f(start)} ${f(end)}`;
};
const PERIODS = [
{ id:'week', label:'Diese Woche' },
{ id:'month', label:'Dieser Monat' },
{ id:'year', label:'Dieses Jahr' },
{ id:'custom', label:'Eigener Zeitraum' },
{ id:'all', label:'Gesamt'},
];
// 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);
const day = d.getDay() || 7; // 1=Mo … 7=So
d.setDate(d.getDate() - (day - 1)); // zurück auf Montag
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 {};
}
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>
);
}
// Bestellungen im gewählten Zeitraum
function OrdersInPeriod({ fromDate, toDate }) {
const [orders, setOrders] = useState(null);
useEffect(() => {
const q = (fromDate || toDate) ? `?from=${fromDate}&to=${toDate}` : '';
api(`/tools/statistik/month-orders${q}`).then(setOrders).catch(()=>setOrders([]));
}, [fromDate, toDate]);
const STATUS_ORDER = ['warteliste','in_arbeit','fertig','bezahlt','abgeschlossen'];
const STATUS_COLOR = { warteliste:'#ffe66d', in_arbeit:'#4ecdc4', fertig:'#6bcb77', bezahlt:'#c084fc', abgeschlossen:'#4ade80' };
const STATUS_LABEL = { warteliste:'Warteliste', in_arbeit:'In Arbeit', fertig:'Fertig', bezahlt:'Bezahlt', abgeschlossen:'Abgeschlossen' };
const periodLabel = (fromDate || toDate) ? 'IM ZEITRAUM' : 'GESAMT';
// Gruppieren nach Status
const grouped = {};
(orders||[]).forEach(o => {
const st = !!o.bezahlt && !!o.abgeholt ? 'abgeschlossen' : !!o.bezahlt ? 'bezahlt' : o.status;
if (!grouped[st]) grouped[st] = [];
grouped[st].push({ ...o, _st: st });
});
// Innerhalb jeder Gruppe nach Datum sortieren (neueste zuerst)
Object.values(grouped).forEach(g => g.sort((a,b) => new Date(b.created_at) - new Date(a.created_at)));
return (
<div style={S.card}>
<div style={S.head}>BESTELLUNGEN {periodLabel} {orders ? `(${orders.length})` : ''}</div>
{!orders
? <div style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:12}}>Lädt</div>
: !orders.length
? <div style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:12}}>Keine Bestellungen im Zeitraum.</div>
: STATUS_ORDER.filter(st => grouped[st]?.length).map(st => {
const col = STATUS_COLOR[st];
const group = grouped[st];
const total = group.reduce((s,o)=>s+(o.revenue||0),0);
return (
<div key={st} style={{marginBottom:16}}>
{/* Status-Header mit Summe */}
<div style={{display:'flex',alignItems:'center',justifyContent:'space-between',
padding:'6px 10px',background:`${col}10`,borderRadius:8,marginBottom:6}}>
<div style={{display:'flex',alignItems:'center',gap:8}}>
<span style={{background:`${col}20`,border:`1px solid ${col}44`,borderRadius:10,
padding:'2px 10px',color:col,fontFamily:'monospace',fontSize:10}}>
{STATUS_LABEL[st]}
</span>
<span style={{color:'rgba(255,255,255,0.35)',fontFamily:'monospace',fontSize:10}}>
{group.length} Bestellung{group.length!==1?'en':''}
</span>
</div>
{total > 0 && (
<span style={{color:col,fontFamily:"'Space Mono',monospace",fontSize:12,fontWeight:700}}>
Σ {fmt(total)}
</span>
)}
</div>
{/* Einträge */}
{group.map(o => (
<div key={o.id} style={{display:'flex',alignItems:'center',gap:10,padding:'7px 10px',
borderBottom:'1px solid rgba(255,255,255,0.04)'}}>
<span style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:10,flexShrink:0,width:60}}>
{new Date(o.created_at).toLocaleDateString('de-DE',{day:'2-digit',month:'2-digit'})}
</span>
<span style={{color:'rgba(255,255,255,0.8)',fontFamily:'monospace',fontSize:12,flex:1}}>
{o.name}
</span>
<span style={{color:o.revenue>0?'#4ade80':'rgba(255,255,255,0.25)',
fontFamily:"'Space Mono',monospace",fontSize:12,fontWeight:700,flexShrink:0}}>
{fmt(o.revenue||0)}
</span>
</div>
))}
</div>
);
})
}
</div>
);
}
// MakerWorld Creator-Center Statistik
function MakerworldTab({ toast }) {
const [state, setState] = useState(null); // { configured, data, error, fetchedAt, needsCode }
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [showModels, setShowModels] = useState(false);
const [loginStatus, setLoginStatus] = useState(null);
const [credForm, setCredForm] = useState({ username:'', password:'' });
const [savingCreds, setSavingCreds] = useState(false);
const [codeInput, setCodeInput] = useState('');
const [submittingCode, setSubmittingCode] = useState(false);
const [cookieInput, setCookieInput] = useState('');
const [savingCookie, setSavingCookie] = useState(false);
const [showCookieFallback, setShowCookieFallback] = useState(false);
const [debugImgUrl, setDebugImgUrl] = useState(null);
const [loadingScreenshot, setLoadingScreenshot] = useState(false);
const load = () => {
setLoading(true);
api('/tools/makerworld/stats').then(setState).catch(()=>{}).finally(()=>setLoading(false));
};
const loadLoginStatus = () => {
api('/tools/makerworld/login-status').then(setLoginStatus).catch(()=>{});
};
useEffect(() => { load(); loadLoginStatus(); }, []);
// Solange auf einen Code gewartet wird, alle paar Sekunden pollen
useEffect(() => {
if (!state?.needsCode) return;
const iv = setInterval(() => { load(); loadLoginStatus(); }, 5000);
return () => clearInterval(iv);
}, [state?.needsCode]);
const refresh = async () => {
setRefreshing(true);
try {
const r = await api('/tools/makerworld/refresh', { method:'POST' });
setState(p => ({ ...p, ...r }));
loadLoginStatus();
if (r.needsCode) toast?.('MakerWorld verlangt einen E-Mail-Verifizierungscode');
else if (r.error) toast?.(r.error,'error');
else toast?.('Aktualisiert');
} catch(e) { toast?.(e.message,'error'); }
finally { setRefreshing(false); }
};
const saveCredentials = async () => {
if (!credForm.username.trim() || !credForm.password) return;
setSavingCreds(true);
try {
const r = await api('/tools/makerworld/credentials', { body:credForm });
setCredForm({ username:'', password:'' });
setState(p => ({ ...p, ...r, configured:true }));
loadLoginStatus();
if (r.needsCode) toast?.('Zugangsdaten gespeichert — E-Mail-Code erforderlich');
else if (r.error) toast?.('Gespeichert, aber Testabruf fehlgeschlagen: '+r.error,'error');
else toast?.('Zugangsdaten gespeichert, Abruf erfolgreich');
} catch(e) { toast?.(e.message,'error'); }
finally { setSavingCreds(false); }
};
const removeCredentials = async () => {
try {
await api('/tools/makerworld/credentials', { method:'DELETE' });
toast?.('Zugangsdaten entfernt');
setState({ configured:false, data:null, error:null, fetchedAt:null, needsCode:false });
loadLoginStatus();
} catch(e) { toast?.(e.message,'error'); }
};
const viewDebugScreenshot = async () => {
setLoadingScreenshot(true);
try {
const r = await api('/tools/makerworld/debug-screenshot');
if (!r.image) { toast?.('Kein Debug-Screenshot vorhanden','error'); return; }
setDebugImgUrl(r.image); // bereits eine data:-URI, CSP-konform (kein blob:)
} catch(e) { toast?.(e.message,'error'); }
finally { setLoadingScreenshot(false); }
};
const submitCode = async () => {
if (!codeInput.trim()) return;
setSubmittingCode(true);
try {
const r = await api('/tools/makerworld/verify-code', { body:{ code:codeInput.trim() } });
setState(p => ({ ...p, ...r }));
loadLoginStatus();
if (r.needsCode) toast?.(r.error || 'Code nicht akzeptiert, nochmal versuchen','error');
else if (r.error) toast?.(r.error,'error');
else { toast?.('Login erfolgreich'); setCodeInput(''); }
} catch(e) { toast?.(e.message,'error'); }
finally { setSubmittingCode(false); }
};
const saveCookie = async () => {
if (!cookieInput.trim()) return;
setSavingCookie(true);
try {
const r = await api('/tools/makerworld/cookie', { body:{ cookie: cookieInput } });
setCookieInput('');
setState(p => ({ ...p, ...r, configured:true }));
if (r.error) toast?.('Gespeichert, aber Testabruf fehlgeschlagen: '+r.error,'error');
else toast?.('Cookie übernommen, Abruf erfolgreich');
loadLoginStatus();
} catch(e) { toast?.(e.message,'error'); }
finally { setSavingCookie(false); }
};
if (loading) return <div style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:12}}>Lädt</div>;
const d = state?.data;
const fmtInt = v => (v ?? 0).toLocaleString('de-DE');
const fmtAgo = ts => {
if (!ts) return '—';
const min = Math.round((Date.now()-ts)/60000);
if (min < 1) return 'gerade eben';
if (min < 60) return `vor ${min} Min.`;
return `vor ${Math.round(min/60)} Std.`;
};
return (
<div>
{/* Admin: Code-Eingabe hat höchste Priorität, wenn gerade angefordert */}
{state?.needsCode && (
<div style={{...S.card,borderColor:'rgba(255,230,109,0.4)',background:'rgba(255,230,109,0.08)'}}>
<div style={{...S.head,color:'#ffe66d'}}>📧 VERIFIZIERUNGSCODE ERFORDERLICH</div>
<div style={{...S.sub,marginBottom:8}}>
MakerWorld hat einen Code an deine E-Mail-Adresse geschickt. Bitte hier eintragen
(5 Minuten Zeit, danach muss der Login-Versuch neu gestartet werden).
</div>
<div style={{display:'flex',gap:8}}>
<input value={codeInput} onChange={e=>setCodeInput(e.target.value)}
onKeyDown={e=>e.key==='Enter'&&submitCode()}
placeholder="Code aus E-Mail" style={{...S.inp,flex:1}}/>
<button onClick={submitCode} disabled={submittingCode||!codeInput.trim()}
style={{...S.btn('#ffe66d'),opacity:(submittingCode||!codeInput.trim())?0.5:1,whiteSpace:'nowrap'}}>
{submittingCode?'…':'✓ Bestätigen'}
</button>
</div>
</div>
)}
{/* Admin: Zugangsdaten */}
{!state?.needsCode && (
<div style={S.card}>
<div style={S.head}>MEINE MAKERWORLD-ZUGANGSDATEN</div>
{loginStatus && (
<div style={{...S.sub,marginBottom:10}}>
{loginStatus.configured
? `Eingerichtet als "${loginStatus.username}" · Letzter Abruf: ${fmtAgo(loginStatus.lastFetchedAt)}`
: 'Noch keine Zugangsdaten hinterlegt.'}
{loginStatus.lastError && <div style={{color:'#f87171',marginTop:4}}> {loginStatus.lastError}</div>}
</div>
)}
<div style={{...S.sub,marginBottom:8}}>
Wird verschlüsselt gespeichert und nur für deinen eigenen Abruf genutzt andere Nutzer sehen
weder deine Zugangsdaten noch deine Zahlen. Ein echter Browser mit dauerhaftem Profil hält die
Session am Laufen bei aktiver Nutzung wird ein erneuter Login kaum je nötig. Falls doch,
erscheint hier automatisch die Code-Eingabe.
</div>
<div style={{display:'flex',gap:8,flexWrap:'wrap',marginBottom:8}}>
<input value={credForm.username} onChange={e=>setCredForm(p=>({...p,username:e.target.value}))}
placeholder="E-Mail / Benutzername" style={{...S.inp,flex:1,minWidth:160}}/>
<input type="password" value={credForm.password} onChange={e=>setCredForm(p=>({...p,password:e.target.value}))}
onKeyDown={e=>e.key==='Enter'&&saveCredentials()}
placeholder="Passwort" style={{...S.inp,flex:1,minWidth:160}}/>
</div>
<div style={{display:'flex',gap:8,flexWrap:'wrap'}}>
<button onClick={saveCredentials} disabled={savingCreds||!credForm.username.trim()||!credForm.password}
style={{...S.btn('#4ade80'),opacity:(savingCreds||!credForm.username.trim()||!credForm.password)?0.5:1}}>
{savingCreds?'…':'💾 Speichern & testen'}
</button>
<button onClick={refresh} disabled={refreshing}
style={{...S.btn('#4ecdc4'),opacity:refreshing?0.5:1}}>
{refreshing?'…':'🔄 Neu abrufen'}
</button>
<button onClick={()=>setShowCookieFallback(p=>!p)} style={{...S.btn('#888888')}}>
{showCookieFallback ? '▲ Cookie-Fallback ausblenden' : '⚙ Cookie-Fallback (manuell)'}
</button>
{loginStatus?.configured && (
<button onClick={removeCredentials} style={{...S.btn('#f87171')}}>
🗑 Zugangsdaten entfernen
</button>
)}
</div>
{showCookieFallback && (
<div style={{marginTop:14,paddingTop:14,borderTop:'1px solid rgba(255,255,255,0.07)'}}>
<div style={{...S.sub,marginBottom:8}}>
Nur falls der automatische Login nicht funktioniert: kompletten Cookie-Header aus einem
eingeloggten Browser-Tab kopieren (Entwicklertools Netzwerk Request an
/de/my/data-overview/model "Cookie"-Header) und hier einfügen.
</div>
<textarea value={cookieInput} onChange={e=>setCookieInput(e.target.value)}
placeholder="lang=de; cf_clearance=...; token=...; ..."
rows={3} style={{...S.inp,resize:'vertical',fontSize:10,marginBottom:8}}/>
<button onClick={saveCookie} disabled={savingCookie||!cookieInput.trim()}
style={{...S.btn('#4ecdc4'),opacity:(savingCookie||!cookieInput.trim())?0.5:1}}>
{savingCookie?'…':'💾 Cookie übernehmen & testen'}
</button>
</div>
)}
</div>
)}
{state?.error && (
<div style={{...S.card,borderColor:'rgba(248,113,113,0.3)',background:'rgba(248,113,113,0.06)'}}>
<div style={{color:'#f87171',fontFamily:'monospace',fontSize:11,marginBottom:8}}>
{state.error}{d ? ' — zeige zuletzt bekannten Stand.' : ''}
</div>
<button onClick={viewDebugScreenshot} disabled={loadingScreenshot}
style={{...S.btn('#888888'),opacity:loadingScreenshot?0.5:1}}>
{loadingScreenshot?'…':'🖼 Debug-Screenshot ansehen'}
</button>
{debugImgUrl && (
<div style={{marginTop:10}}>
<img src={debugImgUrl} alt="Debug-Screenshot" style={{width:'100%',borderRadius:8,border:'1px solid rgba(255,255,255,0.1)'}}/>
</div>
)}
</div>
)}
{d && (
<>
<div style={S.card}>
<div style={S.head}>CREATOR CENTER {state.fetchedAt ? `· ${fmtAgo(state.fetchedAt)}` : ''}</div>
<div style={{display:'flex',flexWrap:'wrap',gap:20,marginBottom:16}}>
<KPI label="Downloads" value={fmtInt(d.downloads)} color="#4ecdc4"/>
<KPI label="Prints" value={fmtInt(d.prints)} color="#4ecdc4"/>
<KPI label="Likes" value={fmtInt(d.likes)} color="#ff6b9d"/>
<KPI label="Collections" value={fmtInt(d.collections)} color="#ff6b9d"/>
<KPI label="Boosts" value={fmtInt(d.boosts)} color="#ffe66d"/>
<KPI label="Follower" value={fmtInt(d.followers)} color="#ffe66d"/>
</div>
<div style={{borderTop:'1px solid rgba(255,255,255,0.06)',paddingTop:16,display:'flex',flexWrap:'wrap',gap:20}}>
<KPI label="Views" value={fmtInt(d.views)} color="#60a5fa"/>
<KPI label="Impressions" value={fmtInt(d.impressions)} color="#60a5fa"/>
<KPI label="Creator Points" value={fmtInt(d.creatorPoints)} color="#c084fc"/>
<KPI label="Model Points" value={fmtInt(d.modelPoints)} color="#c084fc" sub="aus Modellen"/>
<KPI label="MakerLab Points" value={fmtInt(d.instructionPoints)} color="#c084fc" sub="aus Anleitungen"/>
</div>
</div>
{d.models?.length > 0 && (
<div style={S.card}>
<div style={{display:'flex',alignItems:'center',justifyContent:'space-between',marginBottom:showModels?10:0}}>
<div style={{...S.head,marginBottom:0}}>MODELLE ({d.models.length})</div>
<button onClick={()=>setShowModels(p=>!p)} style={{...S.btn('#888888'),fontSize:10}}>
{showModels ? '▲ einklappen' : '▼ anzeigen'}
</button>
</div>
{showModels && [...d.models].sort((a,b)=>b.downloads-a.downloads).map(m => (
<div key={m.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.8)',fontFamily:'monospace',fontSize:12,flex:1,minWidth:160}}>
{m.title}
</span>
<span style={{color:'#4ecdc4',fontFamily:"'Space Mono',monospace",fontSize:11}}>{fmtInt(m.downloads)}</span>
<span style={{color:'#ff6b9d',fontFamily:"'Space Mono',monospace",fontSize:11}}>{fmtInt(m.likes)}</span>
<span style={{color:'#60a5fa',fontFamily:"'Space Mono',monospace",fontSize:11}}>👁{fmtInt(m.views)}</span>
<span style={{color:'#c084fc',fontFamily:"'Space Mono',monospace",fontSize:11}}>{m.points?.toFixed?.(1) ?? m.points}pt</span>
</div>
))}
</div>
)}
</>
)}
</div>
);
}
export default function Statistik({ mobile, user, toast }) {
const [fromDate, setFromDate] = useState('');
const [toDate, setToDate] = useState(todayLocal());
const [data, setData] = useState(null);
const [loading,setLoading] = useState(false);
const [tab, setTab] = useState('overview');
const [expForm,setExpForm] = useState({ date: todayLocal(), category:'Filament', description:'', amount:'' });
const [saving, setSaving] = useState(false);
function load() {
setLoading(true);
const q = (fromDate || toDate) ? `?from=${fromDate}&to=${toDate}` : '';
api('/tools/statistik/overview'+q)
.then(d => setData(d))
.catch(()=>{})
.finally(()=>setLoading(false));
}
useEffect(() => { load(); }, [fromDate, toDate]);
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:8,alignItems:'center',flexWrap:'wrap'}}>
<div style={{display:'flex',alignItems:'center',gap:6}}>
<span style={{color:'rgba(255,255,255,0.35)',fontFamily:'monospace',fontSize:11}}>Von</span>
<input type="date" value={fromDate} onChange={e=>setFromDate(e.target.value)}
style={{background:'rgba(255,255,255,0.06)',border:'1px solid rgba(255,255,255,0.12)',
borderRadius:8,padding:'5px 10px',color:'#fff',fontFamily:'monospace',fontSize:11,cursor:'pointer'}}/>
</div>
<div style={{display:'flex',alignItems:'center',gap:6}}>
<span style={{color:'rgba(255,255,255,0.35)',fontFamily:'monospace',fontSize:11}}>Bis</span>
<input type="date" value={toDate} onChange={e=>setToDate(e.target.value)}
style={{background:'rgba(255,255,255,0.06)',border:'1px solid rgba(255,255,255,0.12)',
borderRadius:8,padding:'5px 10px',color:'#fff',fontFamily:'monospace',fontSize:11,cursor:'pointer'}}/>
</div>
{fromDate && (
<button onClick={()=>{setFromDate('');setToDate(todayLocal());}}
style={{background:'rgba(255,255,255,0.04)',border:'1px solid rgba(255,255,255,0.1)',
borderRadius:8,padding:'5px 10px',color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:11,cursor:'pointer'}}>
Zurücksetzen
</button>
)}
</div>
</div>
{/* ── Tabs ── */}
<div style={{display:'flex',gap:4,marginBottom:16,overflowX:'auto',overflowY:'hidden',
scrollbarWidth:'none',WebkitOverflowScrolling:'touch',paddingBottom:2}}>
{[['overview','📈 Übersicht'],['revenue','💰 Einnahmen'],['expenses','💸 Ausgaben'],['orders','📦 Bestellungen'],['makerworld','🖨️ MakerWorld']].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', flexShrink:0, whiteSpace:'nowrap',
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 {data.granularity==='day'?'(TÄGLICH)':data.granularity==='week'?'(WÖCHENTLICH)':'(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,color:'#fff'}}
labelStyle={{color:'rgba(255,255,255,0.6)'}} itemStyle={{color:'#fff'}}
formatter={(v,n)=>[`${v.toFixed(2)}`, n==='revenue'?'Einnahmen':'Ausgaben']}
labelFormatter={m => fmtMonthRange(m, data.granularity)}/>
<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}>NETTOGEWINN IM ZEITVERLAUF</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,color:'#fff'}}
labelStyle={{color:'rgba(255,255,255,0.6)'}} itemStyle={{color:'#4ecdc4'}}
formatter={v=>[`${v.toFixed(2)}`, 'Gewinn kumuliert']} labelFormatter={m => fmtMonthRange(m, data.granularity)}/>
<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,color:'#fff'}}
labelStyle={{color:'rgba(255,255,255,0.6)'}} itemStyle={{color:'#fff'}}
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 {(fromDate||toDate!==todayLocal())&&'(IM ZEITRAUM)'}</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>
</>
)}
{/* ══ EINNAHMEN ══ */}
{tab==='revenue' && data && (
<div style={S.card}>
<div style={S.head}>
IM ZEITRAUM BEZAHLTE AUFTRÄGE ({data.paidOrders?.length||0}) · {fmt(data.totalRevenue)} EINGENOMMEN
</div>
{!data.paidOrders?.length
? <div style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:12}}>
Keine bezahlten Aufträge im gewählten Zeitraum.
</div>
: data.paidOrders.map(o => (
<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}}>
{o.bezahlt_am ? new Date(o.bezahlt_am).toLocaleDateString('de-DE') : '—'}
</span>
<span style={{color:'rgba(255,255,255,0.8)',fontFamily:'monospace',fontSize:12,flex:1}}>
{o.name}
</span>
<span style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:10,flexShrink:0}}>
Erstellt: {new Date(o.created_at).toLocaleDateString('de-DE')}
</span>
<div style={{display:'flex',gap:6,flexShrink:0}}>
<span style={{color:'#f87171',fontFamily:"'Space Mono',monospace",fontSize:11}}>
-{fmt(o.base_cost)}
</span>
<span style={{color:'#4ade80',fontFamily:"'Space Mono',monospace",fontSize:12,fontWeight:700}}>
{fmt(o.revenue)}
</span>
</div>
</div>
))
}
{data.paidOrders?.length > 0 && (
<div style={{display:'flex',justifyContent:'space-between',paddingTop:10,
borderTop:'1px solid rgba(255,255,255,0.07)'}}>
<span style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:11}}>
Materialkosten: {fmt(data.totalBaseCost)}
</span>
<span style={{color:'#60a5fa',fontFamily:"'Space Mono',monospace",fontSize:13,fontWeight:700}}>
Rohgewinn: {fmt(data.totalProfit)}
</span>
</div>
)}
</div>
)}
{/* ══ BESTELLUNGEN (dieser Monat) ══ */}
{tab==='orders' && (
<OrdersInPeriod fromDate={fromDate} toDate={toDate}/>
)}
{/* ══ MAKERWORLD ══ */}
{tab==='makerworld' && (
<MakerworldTab toast={toast}/>
)}
</div>
);
}