Dateien nach "frontend/src/tools" hochladen
This commit is contained in:
426
frontend/src/tools/kalkulator3d.jsx
Normal file
426
frontend/src/tools/kalkulator3d.jsx
Normal file
@@ -0,0 +1,426 @@
|
|||||||
|
import { useState, useMemo, useEffect, useRef } from 'react';
|
||||||
|
import { api, S } from '../lib.js';
|
||||||
|
|
||||||
|
const DEFAULTS = {
|
||||||
|
materialpreis_pro_gramm: 0.01, stromverbrauch_kw: 0.15,
|
||||||
|
strompreis_pro_kwh: 0.38, druckerpreis: 550,
|
||||||
|
gesamtdruckstunden: 5000, verschleiss_pro_stunde: 0.06,
|
||||||
|
};
|
||||||
|
const getFarbFaktor = n => Math.round((1.0 + Math.max(0, n - 1) * 0.1) * 100) / 100;
|
||||||
|
const STUFEN = [
|
||||||
|
{ key:'f', emoji:'💖', label:'Freundschaft', mult:1.0, c:'#ff6b9d' },
|
||||||
|
{ key:'n', emoji:'🤝', label:'Normal', mult:1.5, c:'#4ecdc4' },
|
||||||
|
{ key:'a', emoji:'💼', label:'Auftrag', mult:2.5, c:'#ffe66d' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── Bild komprimieren (client-seitig, max 800px, JPEG 0.75) ──────────────────
|
||||||
|
function compressImage(file) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = e => {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
const MAX = 800;
|
||||||
|
let w = img.width, h = img.height;
|
||||||
|
if (w > MAX || h > MAX) {
|
||||||
|
if (w > h) { h = Math.round(h * MAX / w); w = MAX; }
|
||||||
|
else { w = Math.round(w * MAX / h); h = MAX; }
|
||||||
|
}
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = w; canvas.height = h;
|
||||||
|
canvas.getContext('2d').drawImage(img, 0, 0, w, h);
|
||||||
|
resolve(canvas.toDataURL('image/jpeg', 0.75));
|
||||||
|
};
|
||||||
|
img.onerror = reject;
|
||||||
|
img.src = e.target.result;
|
||||||
|
};
|
||||||
|
reader.onerror = reject;
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Stepper ───────────────────────────────────────────────────────────────────
|
||||||
|
function Stepper({ label, value, onChange, step, unit, min = 0, hint }) {
|
||||||
|
const dec = () => onChange(Math.max(min, Math.round((value - step) * 10000) / 10000));
|
||||||
|
const inc = () => onChange(Math.round((value + step) * 10000) / 10000);
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{ color:'rgba(255,255,255,0.4)', fontSize:10, fontFamily:'monospace', marginBottom:5, letterSpacing:1 }}>
|
||||||
|
{label.toUpperCase()}{unit ? ` (${unit})` : ''}
|
||||||
|
</div>
|
||||||
|
<div style={{ display:'flex', alignItems:'center', background:'rgba(255,255,255,0.05)', borderRadius:8, border:'1px solid rgba(255,255,255,0.1)', overflow:'hidden' }}>
|
||||||
|
<button onClick={dec} style={{ padding:'10px 12px', background:'transparent', border:'none', color:'rgba(255,255,255,0.6)', fontSize:20, cursor:'pointer', flexShrink:0, lineHeight:1 }}>−</button>
|
||||||
|
<input type="number" value={value} step={step}
|
||||||
|
onChange={e => onChange(parseFloat(e.target.value) || 0)}
|
||||||
|
style={{ flex:1, background:'transparent', border:'none', color:'#fff', fontSize:15, fontFamily:"'Space Mono',monospace", textAlign:'center', outline:'none', minWidth:0, padding:'10px 4px' }}
|
||||||
|
/>
|
||||||
|
<button onClick={inc} style={{ padding:'10px 12px', background:'transparent', border:'none', color:'rgba(255,255,255,0.6)', fontSize:20, cursor:'pointer', flexShrink:0, lineHeight:1 }}>+</button>
|
||||||
|
</div>
|
||||||
|
{hint && <div style={{ color:'rgba(255,255,255,0.2)', fontSize:10, fontFamily:'monospace', marginTop:3 }}>{hint}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SmallInput({ label, value, onChange, step = 0.01 }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{ color:'rgba(255,255,255,0.35)', fontSize:9, fontFamily:'monospace', marginBottom:3 }}>{label}</div>
|
||||||
|
<input type="number" value={value} step={step}
|
||||||
|
onChange={e => onChange(parseFloat(e.target.value) || 0)}
|
||||||
|
style={{ ...S.inp, padding:'7px 8px', fontSize:13 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Bild-Modal ────────────────────────────────────────────────────────────────
|
||||||
|
function ImageModal({ src, onClose }) {
|
||||||
|
return (
|
||||||
|
<div style={{ position:'fixed', inset:0, background:'rgba(0,0,0,0.9)', zIndex:9000,
|
||||||
|
display:'flex', alignItems:'center', justifyContent:'center', padding:20 }}
|
||||||
|
onClick={onClose}>
|
||||||
|
<img src={src} alt="Vorschau"
|
||||||
|
style={{ maxWidth:'100%', maxHeight:'90vh', borderRadius:10, objectFit:'contain' }} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Kostenrechner ─────────────────────────────────────────────────────────────
|
||||||
|
export default function Kalkulator3D({ toast, editData, onEditDone, mobile }) {
|
||||||
|
const [gramm, setGramm] = useState(editData?.gramm ?? 50);
|
||||||
|
const [stunden, setStunden] = useState(editData?.stunden ?? 3);
|
||||||
|
const [farben, setFarben] = useState(editData?.farben ?? 1);
|
||||||
|
const [vars, setVars] = useState(editData ? {
|
||||||
|
materialpreis_pro_gramm: editData.materialpreis_pro_gramm,
|
||||||
|
stromverbrauch_kw: editData.stromverbrauch_kw,
|
||||||
|
strompreis_pro_kwh: editData.strompreis_pro_kwh,
|
||||||
|
druckerpreis: editData.druckerpreis,
|
||||||
|
gesamtdruckstunden: editData.gesamtdruckstunden,
|
||||||
|
verschleiss_pro_stunde: editData.verschleiss_pro_stunde,
|
||||||
|
} : { ...DEFAULTS });
|
||||||
|
const [showAdv, setShowAdv] = useState(false);
|
||||||
|
const [name, setName] = useState(editData?.name ?? '');
|
||||||
|
const [bemerkung, setBemerkung] = useState(editData?.bemerkung ?? '');
|
||||||
|
const [image, setImage] = useState(editData?.image ?? null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const fileRef = useRef(null);
|
||||||
|
const sv = (k, v) => setVars(p => ({ ...p, [k]: v }));
|
||||||
|
|
||||||
|
const calc = useMemo(() => {
|
||||||
|
const f = getFarbFaktor(farben);
|
||||||
|
const mat = gramm * vars.materialpreis_pro_gramm * f;
|
||||||
|
const str = stunden * vars.stromverbrauch_kw * vars.strompreis_pro_kwh;
|
||||||
|
const ver = stunden * vars.verschleiss_pro_stunde;
|
||||||
|
return { mat, str, ver, grund: mat + str + ver };
|
||||||
|
}, [gramm, stunden, farben, vars]);
|
||||||
|
|
||||||
|
const handleImagePick = async e => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
try {
|
||||||
|
const compressed = await compressImage(file);
|
||||||
|
setImage(compressed);
|
||||||
|
} catch { toast('Bild konnte nicht geladen werden', 'error'); }
|
||||||
|
e.target.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
if (!name.trim()) { toast('Bitte einen Namen eingeben', 'error'); return; }
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const payload = { name, gramm, stunden, farben, ...vars,
|
||||||
|
preis_freundschaft: calc.grund * 1.0,
|
||||||
|
preis_normal: calc.grund * 1.5,
|
||||||
|
preis_auftrag: calc.grund * 2.5,
|
||||||
|
image, bemerkung };
|
||||||
|
if (editData?.id) {
|
||||||
|
await api(`/tools/kalkulator3d/${editData.id}`, { method:'PUT', body:payload });
|
||||||
|
toast('Aktualisiert ✓'); onEditDone?.();
|
||||||
|
} else {
|
||||||
|
await api('/tools/kalkulator3d', { body:payload });
|
||||||
|
toast('Gespeichert ✓'); setName(''); setBemerkung(''); setImage(null);
|
||||||
|
}
|
||||||
|
} catch(e) { toast(e.message, 'error'); } finally { setSaving(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const pad = mobile ? '14px 14px 90px' : '36px 44px';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding:pad, maxWidth:860 }}>
|
||||||
|
<div style={{ display:'flex', justifyContent:'space-between', alignItems:'flex-start', marginBottom:20 }}>
|
||||||
|
<div>
|
||||||
|
{editData && <div style={{ color:'#ffe66d', fontSize:10, fontFamily:'monospace', marginBottom:4 }}>✎ {editData.name}</div>}
|
||||||
|
<h1 style={{ color:'#fff', fontFamily:"'Space Mono',monospace", fontSize:mobile?18:22, margin:0 }}>Kostenrechner</h1>
|
||||||
|
</div>
|
||||||
|
{editData && <button onClick={onEditDone} style={S.btn('#ff6b9d', true)}>✕ Abbrechen</button>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Eingaben */}
|
||||||
|
<div style={{ ...S.card, marginBottom:10 }}>
|
||||||
|
<div style={{ display:'flex', flexDirection:'column', gap:10, marginBottom:10 }}>
|
||||||
|
<Stepper label="Gewicht" value={gramm} onChange={setGramm} step={1} unit="g" min={0} />
|
||||||
|
<Stepper label="Druckdauer" value={stunden} onChange={setStunden} step={0.5} unit="h" min={0} />
|
||||||
|
<Stepper label="Anzahl Farben" value={farben}
|
||||||
|
onChange={v => setFarben(Math.max(1, Math.round(v)))} step={1} min={1}
|
||||||
|
hint={`Farbfaktor: ×${getFarbFaktor(farben).toFixed(2)}`} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button onClick={() => setShowAdv(v => !v)} style={{
|
||||||
|
width:'100%', marginTop:4, background:'rgba(255,255,255,0.02)',
|
||||||
|
border:'1px solid rgba(255,255,255,0.06)', borderRadius:7,
|
||||||
|
padding:'9px 12px', color:'rgba(255,255,255,0.35)', cursor:'pointer',
|
||||||
|
fontFamily:'monospace', fontSize:11, textAlign:'left',
|
||||||
|
display:'flex', alignItems:'center', gap:7,
|
||||||
|
}}>
|
||||||
|
<span style={{ display:'inline-block', transform:showAdv?'rotate(90deg)':'rotate(0)', transition:'transform 0.18s' }}>▶</span>
|
||||||
|
Erweiterte Einstellungen
|
||||||
|
<span style={{ marginLeft:'auto', fontSize:9, background:'rgba(78,205,196,0.1)', borderRadius:3, padding:'2px 6px', color:'#4ecdc4' }}>
|
||||||
|
{Object.entries(vars).some(([k,v]) => v !== DEFAULTS[k]) ? 'GEÄNDERT' : 'STANDARD'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{showAdv && (
|
||||||
|
<div style={{ marginTop:10, background:'rgba(0,0,0,0.2)', border:'1px solid rgba(255,255,255,0.05)', borderRadius:9, padding:12 }}>
|
||||||
|
<div style={{ display:'grid', gridTemplateColumns:'1fr 1fr', gap:10 }}>
|
||||||
|
<SmallInput label="Material €/g" value={vars.materialpreis_pro_gramm} onChange={v=>sv('materialpreis_pro_gramm',v)} step={0.001}/>
|
||||||
|
<SmallInput label="Strom kW" value={vars.stromverbrauch_kw} onChange={v=>sv('stromverbrauch_kw',v)} step={0.01}/>
|
||||||
|
<SmallInput label="Strom €/kWh" value={vars.strompreis_pro_kwh} onChange={v=>sv('strompreis_pro_kwh',v)} step={0.01}/>
|
||||||
|
<SmallInput label="Drucker €" value={vars.druckerpreis} onChange={v=>sv('druckerpreis',v)} step={10}/>
|
||||||
|
<SmallInput label="Lebensdauer h" value={vars.gesamtdruckstunden} onChange={v=>sv('gesamtdruckstunden',v)} step={100}/>
|
||||||
|
<SmallInput label="Verschleiß €/h" value={vars.verschleiss_pro_stunde} onChange={v=>sv('verschleiss_pro_stunde',v)} step={0.01}/>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setVars({...DEFAULTS})} style={{ ...S.btn('#ff6b9d', true), marginTop:10 }}>↺ Standard</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Ergebnis */}
|
||||||
|
<div style={{ ...S.card, marginBottom:10 }}>
|
||||||
|
<div style={{ display:'grid', gridTemplateColumns:'1fr 1fr', gap:0 }}>
|
||||||
|
{[['Material',calc.mat,'#a78bfa'],['Strom',calc.str,'#60a5fa'],['Verschleiß',calc.ver,'#f59e0b'],['Grundkosten',calc.grund,'#4ecdc4']].map(([l,v,c],i) => (
|
||||||
|
<div key={l} style={{
|
||||||
|
padding:'10px 14px',
|
||||||
|
borderBottom: i < 2 ? '1px solid rgba(255,255,255,0.05)' : 'none',
|
||||||
|
borderRight: i % 2 === 0 ? '1px solid rgba(255,255,255,0.05)' : 'none',
|
||||||
|
}}>
|
||||||
|
<div style={{ color:'rgba(255,255,255,0.3)', fontSize:9, fontFamily:'monospace', marginBottom:2 }}>{l.toUpperCase()}</div>
|
||||||
|
<div style={{ color:c, fontSize:i===3?18:14, fontFamily:"'Space Mono',monospace", fontWeight:i===3?700:400 }}>
|
||||||
|
{v.toFixed(2)} €
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Preise */}
|
||||||
|
<div style={{ display:'grid', gridTemplateColumns:'1fr 1fr 1fr', gap:8, marginBottom:12 }}>
|
||||||
|
{STUFEN.map((s, i) => (
|
||||||
|
<div key={s.key} style={{
|
||||||
|
background:`${s.c}10`, border:`1px solid ${s.c}30`, borderRadius:10,
|
||||||
|
padding:'12px 10px', textAlign:'center', transform:i===1?'scale(1.04)':'scale(1)',
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize:20, marginBottom:3 }}>{s.emoji}</div>
|
||||||
|
<div style={{ color:'rgba(255,255,255,0.35)', fontSize:9, fontFamily:'monospace', marginBottom:4 }}>{s.label}</div>
|
||||||
|
<div style={{ color:s.c, fontSize:mobile?16:20, fontFamily:"'Space Mono',monospace", fontWeight:700 }}>
|
||||||
|
{(calc.grund * s.mult).toFixed(2)}€
|
||||||
|
</div>
|
||||||
|
<div style={{ color:'rgba(255,255,255,0.2)', fontSize:8, fontFamily:'monospace', marginTop:2 }}>×{s.mult}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Speichern */}
|
||||||
|
<div style={S.card}>
|
||||||
|
<div style={{ ...S.head, marginBottom:10 }}>BERECHNUNG SPEICHERN</div>
|
||||||
|
|
||||||
|
{/* Name */}
|
||||||
|
<div style={{ marginBottom:10 }}>
|
||||||
|
<label style={{ ...S.head, display:'block', marginBottom:4 }}>NAME</label>
|
||||||
|
<input value={name} onChange={e => setName(e.target.value)}
|
||||||
|
onKeyDown={e => e.key === 'Enter' && save()}
|
||||||
|
placeholder='z.B. "Halterung Müller"'
|
||||||
|
style={{ ...S.inp, fontSize:15 }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bemerkung */}
|
||||||
|
<div style={{ marginBottom:10 }}>
|
||||||
|
<label style={{ ...S.head, display:'block', marginBottom:4 }}>BEMERKUNG</label>
|
||||||
|
<textarea value={bemerkung} onChange={e => setBemerkung(e.target.value)}
|
||||||
|
placeholder="Material, Infill, Notizen…"
|
||||||
|
rows={3}
|
||||||
|
style={{ ...S.inp, resize:'vertical', lineHeight:1.6, fontSize:14, padding:'9px 12px' }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bild */}
|
||||||
|
<div style={{ marginBottom:14 }}>
|
||||||
|
<label style={{ ...S.head, display:'block', marginBottom:6 }}>BILD (optional)</label>
|
||||||
|
<input ref={fileRef} type="file" accept="image/*" onChange={handleImagePick}
|
||||||
|
style={{ display:'none' }} />
|
||||||
|
{image ? (
|
||||||
|
<div style={{ position:'relative', display:'inline-block' }}>
|
||||||
|
<img src={image} alt="Vorschau"
|
||||||
|
style={{ width:'100%', maxWidth:240, height:140, objectFit:'cover', borderRadius:8,
|
||||||
|
border:'1px solid rgba(255,255,255,0.1)', display:'block' }} />
|
||||||
|
<button onClick={() => setImage(null)} style={{
|
||||||
|
position:'absolute', top:6, right:6,
|
||||||
|
width:24, height:24, borderRadius:'50%',
|
||||||
|
background:'rgba(0,0,0,0.7)', border:'none',
|
||||||
|
color:'#fff', fontSize:13, cursor:'pointer', lineHeight:1,
|
||||||
|
}}>✕</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button onClick={() => fileRef.current?.click()} style={{
|
||||||
|
width:'100%', padding:'20px', background:'rgba(255,255,255,0.03)',
|
||||||
|
border:'1px dashed rgba(255,255,255,0.15)', borderRadius:8,
|
||||||
|
color:'rgba(255,255,255,0.3)', cursor:'pointer', fontFamily:'monospace', fontSize:12,
|
||||||
|
}}>📷 Bild hinzufügen</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button onClick={save} disabled={saving} style={{
|
||||||
|
width:'100%', background:'linear-gradient(135deg,#4ecdc4,#45b7d1)', border:'none',
|
||||||
|
borderRadius:8, padding:'12px 0', color:'#0d0d0f',
|
||||||
|
fontFamily:"'Space Mono',monospace", fontWeight:700,
|
||||||
|
fontSize:13, cursor:saving?'default':'pointer', opacity:saving?0.7:1,
|
||||||
|
}}>{saving ? '…' : editData?.id ? '✓ Aktualisieren' : '↓ Speichern'}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Kostenarchiv ──────────────────────────────────────────────────────────────
|
||||||
|
export function SavedList({ toast, mobile }) {
|
||||||
|
const [items, setItems] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [delId, setDelId] = useState(null);
|
||||||
|
const [editData, setEditData] = useState(null);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [lightbox, setLightbox] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api('/tools/kalkulator3d').then(setItems).catch(e => toast(e.message, 'error')).finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const del = async id => {
|
||||||
|
try { await api(`/tools/kalkulator3d/${id}`, { method:'DELETE' }); setItems(p => p.filter(i => i.id !== id)); toast('Gelöscht'); }
|
||||||
|
catch(e) { toast(e.message, 'error'); } finally { setDelId(null); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const filtered = search.trim()
|
||||||
|
? items.filter(i =>
|
||||||
|
i.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
(i.bemerkung && i.bemerkung.toLowerCase().includes(search.toLowerCase()))
|
||||||
|
)
|
||||||
|
: items;
|
||||||
|
|
||||||
|
if (editData) return (
|
||||||
|
<Kalkulator3D toast={toast} mobile={mobile} editData={editData} onEditDone={() => {
|
||||||
|
setEditData(null);
|
||||||
|
api('/tools/kalkulator3d').then(setItems).catch(() => {});
|
||||||
|
}} />
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: mobile ? '14px 14px 90px' : '36px 44px', maxWidth:900 }}>
|
||||||
|
{lightbox && <ImageModal src={lightbox} onClose={() => setLightbox(null)} />}
|
||||||
|
|
||||||
|
<h1 style={{ color:'#fff', fontFamily:"'Space Mono',monospace", fontSize:mobile?18:22, marginBottom:14 }}>Kostenarchiv</h1>
|
||||||
|
|
||||||
|
{/* Suche */}
|
||||||
|
<div style={{ position:'relative', marginBottom:12 }}>
|
||||||
|
<span style={{ position:'absolute', left:12, top:'50%', transform:'translateY(-50%)',
|
||||||
|
color:'rgba(255,255,255,0.3)', fontSize:14, pointerEvents:'none' }}>⌕</span>
|
||||||
|
<input value={search} onChange={e => setSearch(e.target.value)}
|
||||||
|
placeholder="Name oder Bemerkung suchen…"
|
||||||
|
style={{ ...S.inp, paddingLeft:34, fontSize:15 }} />
|
||||||
|
{search && (
|
||||||
|
<button onClick={() => setSearch('')} style={{
|
||||||
|
position:'absolute', right:10, top:'50%', transform:'translateY(-50%)',
|
||||||
|
background:'transparent', border:'none', color:'rgba(255,255,255,0.3)',
|
||||||
|
cursor:'pointer', fontSize:16,
|
||||||
|
}}>✕</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style={{ color:'rgba(255,255,255,0.25)', fontFamily:'monospace', fontSize:10, marginBottom:14 }}>
|
||||||
|
{filtered.length} von {items.length} Einträgen{search ? ` für „${search}"` : ''}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{loading && <div style={{ color:'rgba(255,255,255,0.3)', fontFamily:'monospace' }}>Lädt…</div>}
|
||||||
|
|
||||||
|
{!loading && items.length === 0 && (
|
||||||
|
<div style={{ ...S.card, textAlign:'center', padding:40 }}>
|
||||||
|
<div style={{ fontSize:28, marginBottom:10 }}>◉</div>
|
||||||
|
<div style={{ color:'rgba(255,255,255,0.25)', fontFamily:'monospace', fontSize:12 }}>Noch keine Berechnungen.</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && search && filtered.length === 0 && (
|
||||||
|
<div style={{ ...S.card, textAlign:'center', padding:24 }}>
|
||||||
|
<div style={{ color:'rgba(255,255,255,0.25)', fontFamily:'monospace', fontSize:12 }}>Keine Treffer.</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{filtered.map(item => (
|
||||||
|
<div key={item.id} style={{ ...S.card, marginBottom:10, padding:'14px' }}>
|
||||||
|
<div style={{ display:'flex', gap:12 }}>
|
||||||
|
|
||||||
|
{/* Bild Thumbnail */}
|
||||||
|
{item.image && (
|
||||||
|
<img src={item.image} alt={item.name}
|
||||||
|
onClick={() => setLightbox(item.image)}
|
||||||
|
style={{ width:72, height:72, objectFit:'cover', borderRadius:8, flexShrink:0,
|
||||||
|
border:'1px solid rgba(255,255,255,0.1)', cursor:'pointer' }} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ flex:1, minWidth:0 }}>
|
||||||
|
{/* Name + Datum */}
|
||||||
|
<div style={{ display:'flex', justifyContent:'space-between', alignItems:'flex-start', marginBottom:4 }}>
|
||||||
|
<div style={{ color:'#fff', fontFamily:'monospace', fontSize:13, fontWeight:700 }}>{item.name}</div>
|
||||||
|
<div style={{ color:'rgba(255,255,255,0.2)', fontFamily:'monospace', fontSize:10, flexShrink:0, marginLeft:8 }}>
|
||||||
|
{new Date(item.created_at).toLocaleDateString('de-DE')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Infos */}
|
||||||
|
<div style={{ color:'rgba(255,255,255,0.3)', fontFamily:'monospace', fontSize:10, marginBottom:6 }}>
|
||||||
|
{item.gramm}g · {item.stunden}h · {item.farben} Farbe{item.farben > 1 ? 'n' : ''}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bemerkung */}
|
||||||
|
{item.bemerkung && (
|
||||||
|
<div style={{ color:'rgba(255,255,255,0.45)', fontFamily:'monospace', fontSize:11,
|
||||||
|
lineHeight:1.5, marginBottom:8, wordBreak:'break-word' }}>
|
||||||
|
{item.bemerkung}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Preise */}
|
||||||
|
<div style={{ display:'grid', gridTemplateColumns:'1fr 1fr 1fr', gap:6, marginBottom:10 }}>
|
||||||
|
{[['💖',item.preis_freundschaft,'#ff6b9d'],['🤝',item.preis_normal,'#4ecdc4'],['💼',item.preis_auftrag,'#ffe66d']].map(([e,v,c]) => (
|
||||||
|
<div key={e} style={{ background:`${c}10`, border:`1px solid ${c}25`, borderRadius:7, padding:'6px 4px', textAlign:'center' }}>
|
||||||
|
<div style={{ fontSize:12 }}>{e}</div>
|
||||||
|
<div style={{ color:c, fontFamily:"'Space Mono',monospace", fontSize:12, fontWeight:700 }}>{v.toFixed(2)}€</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Aktionen */}
|
||||||
|
{delId === item.id ? (
|
||||||
|
<div style={{ display:'flex', gap:6, alignItems:'center', marginTop:8 }}>
|
||||||
|
<span style={{ color:'rgba(255,255,255,0.35)', fontSize:11, fontFamily:'monospace', flex:1 }}>Wirklich löschen?</span>
|
||||||
|
<button onClick={() => del(item.id)} style={S.btn('#ff6b9d', true)}>✕ Ja</button>
|
||||||
|
<button onClick={() => setDelId(null)} style={S.btn('rgba(255,255,255,0.4)', true)}>Nein</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display:'flex', gap:6, marginTop:10 }}>
|
||||||
|
<button onClick={() => setEditData(item)} style={{ ...S.btn('#4ecdc4', true), flex:1, textAlign:'center' }}>✎ Bearbeiten</button>
|
||||||
|
<button onClick={() => setDelId(item.id)} style={{ ...S.btn('#ff6b9d', true), flex:1, textAlign:'center' }}>✕ Löschen</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user