510 lines
25 KiB
JavaScript
510 lines
25 KiB
JavaScript
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>
|
||
);
|
||
}
|
||
|
||
export default function Statistik({ mobile }) {
|
||
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}}>
|
||
{[['overview','📈 Übersicht'],['revenue','💰 Einnahmen'],['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 {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}/>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|