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 (
);
}
// ββ Bild-Modal ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
function ImageModal({ src, onClose }) {
return (
);
}
// ββ 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 */}
{/* Bild */}
{image ? (
) : (
)}
{/* 3D-Modelldateien β beim Neuanlegen */}
{!editData?.id && (
setModelFiles(Array.from(e.target.files||[]))}
style={{ display:'none' }} />
{modelFiles.length > 0 ? (
{modelFiles.map((f,i) => (
π¨ {f.name}
{(f.size/1024/1024).toFixed(1)} MB
))}
) : (
)}
)}
{/* 3D-Modelldateien β beim Bearbeiten */}
{editData?.id && (
setModelFiles(Array.from(e.target.files||[]))}
style={{ display:'none' }} />
{/* Existierende Dateien */}
{existingFiles.length > 0 && (
{existingFiles.map((f,i) => (
π¨
{f.originalname}
{(f.size/1024/1024).toFixed(1)} MB
))}
)}
{/* Neue Dateien zum Hochladen */}
{modelFiles.length > 0 && (
NEU HINZUFΓGEN
{modelFiles.map((f,i) => (
{f.name}
{(f.size/1024/1024).toFixed(1)} MB
))}
)}
{modelFiles.length > 0 && (
)}
)}
);
}
// ββ 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 (
{
setEditData(null);
api('/tools/kalkulator3d').then(setItems).catch(() => {});
}} />
);
return (
{lightbox &&
setLightbox(null)} />}
Archiv
{/* Suche */}
β
setSearch(e.target.value)}
placeholder="Name oder Bemerkung suchenβ¦"
style={{ ...S.inp, paddingLeft:34, fontSize:15 }} />
{search && (
)}
{filtered.length} von {items.length} EintrΓ€gen{search ? ` fΓΌr β${search}"` : ''}
{loading && LΓ€dtβ¦
}
{!loading && items.length === 0 && (
β
Noch keine Berechnungen.
)}
{!loading && search && filtered.length === 0 && (
)}
{filtered.map(item => (
{/* Bild Thumbnail */}
{item.image && (

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' }} />
)}
{/* Name + Datum */}
{item.name}
{item.has_files && (
π¨ Dateien
)}
{new Date(item.created_at).toLocaleDateString('de-DE')}
{/* Infos */}
{item.gramm}g Β· {item.stunden}h Β· {item.farben} Farbe{item.farben > 1 ? 'n' : ''}
{/* Bemerkung */}
{item.bemerkung && (
{item.bemerkung}
)}
{/* Preise */}
{[['π',item.preis_freundschaft,'#ff6b9d'],['π€',item.preis_normal,'#4ecdc4'],['πΌ',item.preis_auftrag,'#ffe66d']].map(([e,v,c]) => (
))}
{/* Aktionen */}
))}
);
}