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

421 lines
21 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 { useConfirm } from '../confirm.jsx';
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>
);
}
// ── Archiv ──────────────────────────────────────────────────────────────
export function SavedList({ toast, mobile }) {
const { confirm, ConfirmDialog } = useConfirm();
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 }}>Archiv</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 */}
<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={() => del(item.id)} style={{ ...S.btn('#ff6b9d', true), flex:1, textAlign:'center' }}> Löschen</button>
</div>
</div>
))}
</div>
);
}