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}}/>
)} {/* 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}}/>
{loginStatus?.configured && ( )}
{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.