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 (
{label}
{value}
{sub &&
{sub}
}
);
}
// 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 (
BESTELLUNGEN {periodLabel} {orders ? `(${orders.length})` : ''}
{!orders
?
Lädt…
: !orders.length
?
Keine Bestellungen im Zeitraum.
: 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 (
{/* Status-Header mit Summe */}
{STATUS_LABEL[st]}
{group.length} Bestellung{group.length!==1?'en':''}
{total > 0 && (
Σ {fmt(total)}
)}
{/* Einträge */}
{group.map(o => (
{new Date(o.created_at).toLocaleDateString('de-DE',{day:'2-digit',month:'2-digit'})}
{o.name}
0?'#4ade80':'rgba(255,255,255,0.25)',
fontFamily:"'Space Mono',monospace",fontSize:12,fontWeight:700,flexShrink:0}}>
{fmt(o.revenue||0)}
))}
);
})
}
);
}
// 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 Lädt…
;
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 (
{/* Admin: Code-Eingabe hat höchste Priorität, wenn gerade angefordert */}
{state?.needsCode && (
📧 VERIFIZIERUNGSCODE ERFORDERLICH
MakerWorld hat einen Code an deine E-Mail-Adresse geschickt. Bitte hier eintragen
(5 Minuten Zeit, danach muss der Login-Versuch neu gestartet werden).
setCodeInput(e.target.value)}
onKeyDown={e=>e.key==='Enter'&&submitCode()}
placeholder="Code aus E-Mail" style={{...S.inp,flex:1}}/>
{submittingCode?'…':'✓ Bestätigen'}
)}
{/* Admin: Zugangsdaten */}
{!state?.needsCode && (
MEINE MAKERWORLD-ZUGANGSDATEN
{loginStatus && (
{loginStatus.configured
? `Eingerichtet als "${loginStatus.username}" · Letzter Abruf: ${fmtAgo(loginStatus.lastFetchedAt)}`
: 'Noch keine Zugangsdaten hinterlegt.'}
{loginStatus.lastError &&
⚠ {loginStatus.lastError}
}
)}
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.
setCredForm(p=>({...p,username:e.target.value}))}
placeholder="E-Mail / Benutzername" style={{...S.inp,flex:1,minWidth:160}}/>
setCredForm(p=>({...p,password:e.target.value}))}
onKeyDown={e=>e.key==='Enter'&&saveCredentials()}
placeholder="Passwort" style={{...S.inp,flex:1,minWidth:160}}/>
{savingCreds?'…':'💾 Speichern & testen'}
{refreshing?'…':'🔄 Neu abrufen'}
setShowCookieFallback(p=>!p)} style={{...S.btn('#888888')}}>
{showCookieFallback ? '▲ Cookie-Fallback ausblenden' : '⚙ Cookie-Fallback (manuell)'}
{loginStatus?.configured && (
🗑 Zugangsdaten entfernen
)}
{showCookieFallback && (
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.
)}
)}
{state?.error && (
⚠ {state.error}{d ? ' — zeige zuletzt bekannten Stand.' : ''}
{loadingScreenshot?'…':'🖼 Debug-Screenshot ansehen'}
{debugImgUrl && (
)}
)}
{d && (
<>
CREATOR CENTER {state.fetchedAt ? `· ${fmtAgo(state.fetchedAt)}` : ''}
{d.models?.length > 0 && (
MODELLE ({d.models.length})
setShowModels(p=>!p)} style={{...S.btn('#888888'),fontSize:10}}>
{showModels ? '▲ einklappen' : '▼ anzeigen'}
{showModels && [...d.models].sort((a,b)=>b.downloads-a.downloads).map(m => (
{m.title}
↓{fmtInt(m.downloads)}
♥{fmtInt(m.likes)}
👁{fmtInt(m.views)}
{m.points?.toFixed?.(1) ?? m.points}pt
))}
)}
>
)}
);
}
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 (
{/* ── Header ── */}
📊 3D-Druck Statistik
{/* Zeitraum */}
Von
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'}}/>
Bis
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'}}/>
{fromDate && (
{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
)}
{/* ── Tabs ── */}
{[['overview','📈 Übersicht'],['revenue','💰 Einnahmen'],['expenses','💸 Ausgaben'],['orders','📦 Bestellungen'],['makerworld','🖨️ MakerWorld']].map(([id,label])=>(
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}
))}
{loading &&
Lädt…
}
{/* ══ ÜBERSICHT ══ */}
{tab==='overview' && data && (
<>
{/* KPI Kacheln */}
{/* 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 && (
))}
{/* Balkendiagramm Einnahmen vs Ausgaben */}
{data.monthlyData.length > 0 && (
EINNAHMEN VS. AUSGABEN {data.granularity==='day'?'(TÄGLICH)':data.granularity==='week'?'(WÖCHENTLICH)':'(MONATLICH)'}
`${v}€`} width={45}/>
[`${v.toFixed(2)} €`, n==='revenue'?'Einnahmen':'Ausgaben']}
labelFormatter={m => fmtMonthRange(m, data.granularity)}/>
)}
{/* Kumulierter Gewinn + Ausgaben nach Kategorie */}
{data.cumulativeData.length > 1 && (
NETTOGEWINN IM ZEITVERLAUF
`${v}€`} width={42}/>
[`${v.toFixed(2)} €`, 'Gewinn kumuliert']} labelFormatter={m => fmtMonthRange(m, data.granularity)}/>
)}
{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,category:e.target.value}))}
style={{...S.inp,width:130}}>
{CATS.map(c=>{c} )}
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'}}/>
{saving?'…':'+ Hinzufügen'}
{/* Liste */}
AUSGABEN {(fromDate||toDate!==todayLocal())&&'(IM ZEITRAUM)'}
{!data?.expenses?.length
?
Keine Ausgaben im gewählten Zeitraum.
: data.expenses.map(e => (
{e.date}
{e.category}
{e.description}
{fmt(e.amount)}
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}}>
✕
))
}
{data?.expenses?.length > 0 && (
Σ {fmt(data.totalExpenses)}
)}
>
)}
{/* ══ EINNAHMEN ══ */}
{tab==='revenue' && data && (
IM ZEITRAUM BEZAHLTE AUFTRÄGE ({data.paidOrders?.length||0}) · {fmt(data.totalRevenue)} EINGENOMMEN
{!data.paidOrders?.length
?
Keine bezahlten Aufträge im gewählten Zeitraum.
: data.paidOrders.map(o => (
{o.bezahlt_am ? new Date(o.bezahlt_am).toLocaleDateString('de-DE') : '—'}
{o.name}
Erstellt: {new Date(o.created_at).toLocaleDateString('de-DE')}
-{fmt(o.base_cost)}
{fmt(o.revenue)}
))
}
{data.paidOrders?.length > 0 && (
Materialkosten: {fmt(data.totalBaseCost)}
Rohgewinn: {fmt(data.totalProfit)}
)}
)}
{/* ══ BESTELLUNGEN (dieser Monat) ══ */}
{tab==='orders' && (
)}
{/* ══ MAKERWORLD ══ */}
{tab==='makerworld' && (
)}
);
}