import { useConfirm } from '../confirm.jsx'; import { useState, useMemo, useEffect, useRef } from 'react'; import { api, S } from '../lib.js'; const MODEL_EXTENSIONS = ['.stl','.3mf','.f3d','.step','.stp','.obj','.iges','.igs','.fcstd','.amf','.scad']; const DEFAULTS = { materialpreis_pro_rolle: 10, // €/Rolle (1 kg = 1000 g β†’ 0.01 €/g) stromverbrauch_kw: 0.15, strompreis_pro_kwh: 0.38, druckerpreis: 550, gesamtdruckstunden: 5000, verschleiss_pro_stunde: 0.25, }; const LS_CUSTOM = 'dd_kalk3d_custom_defaults'; const getEffectiveDefaults = () => { try { const s = localStorage.getItem(LS_CUSTOM); if (s) return { ...DEFAULTS, ...JSON.parse(s) }; } catch {} return { ...DEFAULTS }; }; 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 [raw, setRaw] = useState(String(value)); // Sync wenn value sich von außen Γ€ndert (z.B. beim Bearbeiten eines Archiv-Eintrags) useEffect(() => { setRaw(String(value)); }, [value]); const dec = () => { const v = Math.max(min, Math.round((value - step) * 10000) / 10000); onChange(v); setRaw(String(v)); }; const inc = () => { const v = Math.round((value + step) * 10000) / 10000; onChange(v); setRaw(String(v)); }; return (
{label.toUpperCase()}{unit ? ` (${unit})` : ''}
{ setRaw(e.target.value); const parsed = parseFloat(e.target.value); if (!isNaN(parsed)) onChange(Math.max(min, parsed)); }} onBlur={() => { const parsed = parseFloat(raw); const safe = isNaN(parsed) ? min : Math.max(min, parsed); onChange(safe); setRaw(String(safe)); }} style={{ flex:1, background:'transparent', border:'none', color:'#fff', fontSize:15, fontFamily:"'Space Mono',monospace", textAlign:'center', outline:'none', minWidth:0, padding:'10px 4px' }} />
{hint &&
{hint}
}
); } function SmallInput({ label, value, onChange, step = 0.01 }) { const [raw, setRaw] = useState(String(value)); useEffect(() => { setRaw(String(value)); }, [value]); return (
{label}
{ setRaw(e.target.value); const parsed = parseFloat(e.target.value); if (!isNaN(parsed)) onChange(parsed); }} onBlur={() => { const parsed = parseFloat(raw); if (!isNaN(parsed)) { onChange(parsed); setRaw(String(parsed)); } else setRaw(String(value)); }} style={{ ...S.inp, padding:'7px 8px', fontSize:13 }} />
); } // ── Bild-Modal ──────────────────────────────────────────────────────────────── function ImageModal({ src, onClose }) { return (
Vorschau
); } // ── Kostenrechner ───────────────────────────────────────────────────────────── export default function Kalkulator3D({ toast, editData, onEditDone, mobile, setActive }) { 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_rolle: editData.materialpreis_pro_gramm != null ? Math.round(editData.materialpreis_pro_gramm * 1000 * 100) / 100 : DEFAULTS.materialpreis_pro_rolle, 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, } : { ...getEffectiveDefaults() }); 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 [modelFiles, setModelFiles] = useState([]); // neue Dateien zum Hochladen const [existingFiles, setExistingFiles] = useState([]); // bereits gespeicherte Dateien const [modelFolderId, setModelFolderId] = useState(null); const fileRef = useRef(null); const modelFileRef = useRef(null); const sv = (k, v) => setVars(p => ({ ...p, [k]: v })); // Im Bearbeitungsmodus: existierende Dateien laden (ohne Ordner zu erstellen) useEffect(() => { if (!editData?.id || !editData?.name) return; (async () => { try { const base = await api(`/tools/dateien/find-folder?name=3D-Modelle`); if (!base) return; // Basisordner existiert noch nicht β†’ keine Dateien const sub = await api(`/tools/dateien/find-folder?name=${encodeURIComponent(editData.name.trim())}&parent_id=${base.id}`); if (!sub) return; // Unterordner existiert nicht β†’ keine Dateien setModelFolderId(sub.id); const data = await api(`/tools/dateien?folder_id=${sub.id}`); setExistingFiles(data.own || []); } catch {} })(); }, [editData?.id]); const calc = useMemo(() => { const f = getFarbFaktor(farben); const matProGramm = vars.materialpreis_pro_rolle / 1000; const mat = gramm * matProGramm * 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; if (!file.type.startsWith('image/')) { toast('Nur Bilddateien erlaubt (JPG, PNG, …)', 'error'); e.target.value = ''; return; } if (file.size > 20 * 1024 * 1024) { toast(`Bild zu groß (${(file.size/1024/1024).toFixed(1)} MB) – max. 20 MB`, 'error'); e.target.value = ''; return; } try { const compressed = await compressImage(file); setImage(compressed); } catch { toast('Bild konnte nicht verarbeitet werden – ist die Datei beschΓ€digt?', '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, materialpreis_pro_gramm: vars.materialpreis_pro_rolle / 1000, stromverbrauch_kw: vars.stromverbrauch_kw, strompreis_pro_kwh: vars.strompreis_pro_kwh, druckerpreis: vars.druckerpreis, gesamtdruckstunden: vars.gesamtdruckstunden, verschleiss_pro_stunde: vars.verschleiss_pro_stunde, 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 }); // Neue Dateien hochladen falls vorhanden if (modelFiles.length > 0) { let folderId = modelFolderId; if (!folderId) { // Ordner erst jetzt anlegen wenn wirklich Dateien da sind const base = await api('/tools/dateien/ensure-folder', { body: { name: '3D-Modelle' } }); const sub = await api('/tools/dateien/ensure-folder', { body: { name: editData.name.trim(), parent_id: base.id } }); folderId = sub.id; setModelFolderId(folderId); } const form = new FormData(); form.append('folder_id', folderId); for (const f of modelFiles) form.append('files', f); await fetch('/api/tools/dateien/upload-3d', { method:'POST', headers:{ Authorization:`Bearer ${localStorage.getItem('sk_token')}` }, body: form, }); setModelFiles([]); const data = await api(`/tools/dateien?folder_id=${folderId}`); setExistingFiles(data.own || []); } toast('Aktualisiert βœ“'); onEditDone?.(); } else { await api('/tools/kalkulator3d', { body:payload }); // 3D-Dateien hochladen wenn vorhanden if (modelFiles.length > 0) { try { // 1. "3D-Modelle" Basisordner sicherstellen const base = await api('/tools/dateien/ensure-folder', { body: { name: '3D-Modelle' } }); // 2. Unterordner mit Modellnamen anlegen const sub = await api('/tools/dateien/ensure-folder', { body: { name: name.trim(), parent_id: base.id } }); // 3. Dateien hochladen const form = new FormData(); form.append('folder_id', sub.id); for (const f of modelFiles) form.append('files', f); await fetch('/api/tools/dateien/upload-3d', { method: 'POST', headers: { Authorization: `Bearer ${localStorage.getItem('sk_token')}` }, body: form, }); toast(`Gespeichert βœ“ Β· ${modelFiles.length} Datei(en) in 3D-Modelle/${name.trim()} abgelegt`); } catch { toast('Gespeichert βœ“ (Datei-Upload fehlgeschlagen)', 'error'); } } else { toast('Gespeichert βœ“'); } setName(''); setBemerkung(''); setImage(null); setModelFiles([]); setActive?.('kalkulator3d-saved'); } } catch(e) { toast(e.message, 'error'); } finally { setSaving(false); } }; const pad = mobile ? '14px 14px 90px' : '36px 44px'; return (
{editData &&
✎ {editData.name}
}

Kostenrechner

{editData && } {!editData && }
{/* Eingaben */}
setFarben(Math.max(1, Math.round(v)))} step={1} min={1} hint={`Farbfaktor: Γ—${getFarbFaktor(farben).toFixed(2)}`} />
{showAdv && (
sv('materialpreis_pro_rolle',v)} step={0.5}/> sv('stromverbrauch_kw',v)} step={0.01}/> sv('strompreis_pro_kwh',v)} step={0.01}/> sv('druckerpreis',v)} step={10}/> sv('gesamtdruckstunden',v)} step={100}/> sv('verschleiss_pro_stunde',v)} step={0.01}/>
)}
{/* Ergebnis */}
{[['Material',calc.mat,'#a78bfa'],['Strom',calc.str,'#60a5fa'],['Verschleiß',calc.ver,'#f59e0b'],['Grundkosten',calc.grund,'#4ecdc4']].map(([l,v,c],i) => (
{l.toUpperCase()}
{v.toFixed(2)} €
))}
{/* Preise */}
{STUFEN.map((s, i) => (
{s.emoji}
{s.label}
{(calc.grund * s.mult).toFixed(2)}€
Γ—{s.mult}
))}
{/* Speichern */}
BERECHNUNG SPEICHERN
{/* Name */}
setName(e.target.value)} onKeyDown={e => e.key === 'Enter' && save()} placeholder='z.B. "Halterung MΓΌller"' style={{ ...S.inp, fontSize:15 }} />
{/* Bemerkung */}