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

772 lines
40 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 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 (
<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={raw} step={step}
onChange={e => {
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' }}
/>
<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.5)', fontSize:10, fontFamily:'monospace', marginTop:3 }}>{hint}</div>}
</div>
);
}
function SmallInput({ label, value, onChange, step = 0.01 }) {
const [raw, setRaw] = useState(String(value));
useEffect(() => { setRaw(String(value)); }, [value]);
return (
<div>
<div style={{ color:'rgba(255,255,255,0.6)', fontSize:9, fontFamily:'monospace', marginBottom:3 }}>{label}</div>
<input
type="number" value={raw} step={step}
onChange={e => {
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 }}
/>
</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, 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 (
<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>
<div style={{ display:'flex', gap:8 }}>
{editData && <button onClick={onEditDone} style={S.btn('#ff6b9d', true)}> Abbrechen</button>}
{!editData && <button onClick={() => window.location.reload()} title="Neu laden"
style={{ background:'transparent', border:'1px solid rgba(255,255,255,0.1)', borderRadius:8,
color:'rgba(255,255,255,0.4)', cursor:'pointer', padding:'6px 10px', fontSize:14, fontFamily:'monospace' }}></button>}
</div>
</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.6)', 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 !== getEffectiveDefaults()[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 €/Rolle" value={vars.materialpreis_pro_rolle} onChange={v=>sv('materialpreis_pro_rolle',v)} step={0.5}/>
<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>
<div style={{ display:'flex', gap:8, marginTop:10 }}>
<button onClick={() => {
localStorage.setItem(LS_CUSTOM, JSON.stringify(vars));
toast('Als persönlicher Standard gespeichert ✓');
}} style={{ ...S.btn('#4ecdc4', true), flex:1, textAlign:'center' }}>
Als Standard speichern
</button>
<button onClick={() => {
localStorage.removeItem(LS_CUSTOM);
setVars({ ...DEFAULTS });
toast('Auf Werkseinstellungen zurückgesetzt');
}} style={{ ...S.btn('#ff6b9d', true) }} title="Werkseinstellungen wiederherstellen">
</button>
</div>
</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.6)', 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.6)', 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.5)', 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.6)', cursor:'pointer', fontFamily:'monospace', fontSize:12,
}}>📷 Bild hinzufügen</button>
)}
</div>
{/* 3D-Modelldateien beim Neuanlegen */}
{!editData?.id && (
<div style={{ marginBottom:14 }}>
<label style={{ ...S.head, display:'block', marginBottom:6 }}>
3D-DATEIEN (optional) werden in 3D-Modelle/{name.trim()||'…'} abgelegt
</label>
<input ref={modelFileRef} type="file" multiple
accept={MODEL_EXTENSIONS.join(',')}
onChange={e => setModelFiles(Array.from(e.target.files||[]))}
style={{ display:'none' }} />
{modelFiles.length > 0 ? (
<div style={{ background:'rgba(255,255,255,0.03)', border:'1px solid rgba(255,255,255,0.1)',
borderRadius:8, padding:10 }}>
{modelFiles.map((f,i) => (
<div key={i} style={{ display:'flex', alignItems:'center', justifyContent:'space-between',
padding:'4px 0', borderBottom: i<modelFiles.length-1 ? '1px solid rgba(255,255,255,0.05)' : 'none' }}>
<span style={{ color:'rgba(255,255,255,0.7)', fontFamily:'monospace', fontSize:11,
overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap', maxWidth:'80%' }}>
🖨 {f.name}
</span>
<span style={{ color:'rgba(255,255,255,0.3)', fontFamily:'monospace', fontSize:9, flexShrink:0 }}>
{(f.size/1024/1024).toFixed(1)} MB
</span>
</div>
))}
<div style={{ display:'flex', gap:6, marginTop:8 }}>
<button onClick={() => modelFileRef.current?.click()}
style={{ ...S.btn('#4ecdc4', true), flex:1, textAlign:'center', fontSize:11 }}>
+ Weitere hinzufügen
</button>
<button onClick={() => setModelFiles([])}
style={{ ...S.btn('#ff6b9d', true), fontSize:11 }}></button>
</div>
</div>
) : (
<button onClick={() => modelFileRef.current?.click()} style={{
width:'100%', padding:'16px', background:'rgba(255,255,255,0.03)',
border:'1px dashed rgba(255,255,255,0.15)', borderRadius:8,
color:'rgba(255,255,255,0.6)', cursor:'pointer', fontFamily:'monospace', fontSize:12,
}}>🖨 3D-Dateien anhängen (STL, 3MF, F3D, STEP )</button>
)}
</div>
)}
{/* 3D-Modelldateien beim Bearbeiten */}
{editData?.id && (
<div style={{ marginBottom:14 }}>
<label style={{ ...S.head, display:'block', marginBottom:6 }}>
3D-DATEIEN 3D-Modelle/{editData.name}
</label>
<input ref={modelFileRef} type="file" multiple
accept={MODEL_EXTENSIONS.join(',')}
onChange={e => setModelFiles(Array.from(e.target.files||[]))}
style={{ display:'none' }} />
{/* Existierende Dateien */}
{existingFiles.length > 0 && (
<div style={{ background:'rgba(255,255,255,0.03)', border:'1px solid rgba(255,255,255,0.08)',
borderRadius:8, padding:10, marginBottom:8 }}>
{existingFiles.map((f,i) => (
<div key={f.id} style={{ display:'flex', alignItems:'center', gap:8,
padding:'5px 0', borderBottom: i<existingFiles.length-1 ? '1px solid rgba(255,255,255,0.05)' : 'none' }}>
<span style={{ fontSize:16, flexShrink:0 }}>🖨</span>
<span style={{ color:'rgba(255,255,255,0.7)', fontFamily:'monospace', fontSize:11,
flex:1, overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>
{f.originalname}
</span>
<span style={{ color:'rgba(255,255,255,0.25)', fontFamily:'monospace', fontSize:9, flexShrink:0 }}>
{(f.size/1024/1024).toFixed(1)} MB
</span>
<button onClick={async () => {
try {
await api(`/tools/dateien/${f.id}`, { method:'DELETE' });
const remaining = existingFiles.filter(x => x.id !== f.id);
setExistingFiles(remaining);
// Ordner löschen wenn er jetzt leer ist
if (remaining.length === 0 && modelFolderId) {
await api(`/tools/dateien/folders/${modelFolderId}`, { method:'DELETE' });
setModelFolderId(null);
}
} catch(e) { toast(e.message, 'error'); }
}} style={{ background:'transparent', border:'none', cursor:'pointer',
color:'rgba(255,107,157,0.5)', fontSize:14, padding:'0 2px', flexShrink:0, lineHeight:1 }}></button>
</div>
))}
</div>
)}
{/* Neue Dateien zum Hochladen */}
{modelFiles.length > 0 && (
<div style={{ background:'rgba(78,205,196,0.04)', border:'1px solid rgba(78,205,196,0.15)',
borderRadius:8, padding:10, marginBottom:8 }}>
<div style={{ ...S.head, marginBottom:6 }}>NEU HINZUFÜGEN</div>
{modelFiles.map((f,i) => (
<div key={i} style={{ display:'flex', justifyContent:'space-between',
padding:'3px 0', borderBottom: i<modelFiles.length-1 ? '1px solid rgba(255,255,255,0.05)' : 'none' }}>
<span style={{ color:'rgba(255,255,255,0.6)', fontFamily:'monospace', fontSize:11,
overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap', maxWidth:'80%' }}>
{f.name}
</span>
<span style={{ color:'rgba(255,255,255,0.3)', fontFamily:'monospace', fontSize:9, flexShrink:0 }}>
{(f.size/1024/1024).toFixed(1)} MB
</span>
</div>
))}
</div>
)}
<div style={{ display:'flex', gap:6 }}>
<button onClick={() => modelFileRef.current?.click()}
style={{ ...S.btn('#4ecdc4', true), flex:1, textAlign:'center', fontSize:11, padding:'9px 0' }}>
+ Dateien hinzufügen
</button>
{modelFiles.length > 0 && (
<button onClick={() => setModelFiles([])}
style={{ ...S.btn('#ff6b9d', true), fontSize:11 }}> Abbrechen</button>
)}
</div>
</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 ──────────────────────────────────────────────────────────────
// ── Share Modal ───────────────────────────────────────────────────────────────
function CalcShareModal({ item, onClose, toast }) {
const [username, setUsername] = useState('');
const [shares, setShares] = useState([]);
const load = () => api(`/tools/kalkulator3d/${item.id}/shares`).then(setShares).catch(()=>{});
useEffect(()=>{ load(); },[]);
const share = async () => {
if (!username.trim()) return;
try { await api(`/tools/kalkulator3d/${item.id}/share`,{body:{username:username.trim()}}); toast('Geteilt ✓'); setUsername(''); load(); }
catch(e) { toast(e.message,'error'); }
};
const unshare = async uid => {
try { await api(`/tools/kalkulator3d/${item.id}/share/${uid}`,{method:'DELETE'}); setShares(p=>p.filter(s=>s.id!==uid)); toast('Freigabe entfernt'); }
catch(e) { toast(e.message,'error'); }
};
const isMobile = window.innerWidth<768;
return (
<div style={{position:'fixed',inset:0,background:'rgba(0,0,0,0.8)',zIndex:6000,
display:'flex',alignItems:isMobile?'flex-end':'center',justifyContent:'center',padding:isMobile?0:24}}
onClick={e=>e.target===e.currentTarget&&onClose()}>
<div style={{background:'#1a1a1e',borderRadius:isMobile?'16px 16px 0 0':16,
width:'100%',maxWidth:420,padding:'20px 20px 28px',border:'1px solid rgba(255,255,255,0.12)'}}>
{isMobile&&<div style={{width:36,height:4,background:'rgba(255,255,255,0.15)',borderRadius:2,margin:'0 auto 14px'}}/>}
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',marginBottom:14}}>
<div style={{color:'#fff',fontFamily:"'Space Mono',monospace",fontSize:13,fontWeight:700}}>
🖨 {item.name}
</div>
<button onClick={onClose} style={{background:'transparent',border:'none',color:'rgba(255,255,255,0.4)',cursor:'pointer',fontSize:18,padding:'0 4px'}}></button>
</div>
<p style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:10,marginBottom:12,lineHeight:1.6}}>
Empfänger können das Modell nur ansehen, nicht bearbeiten oder löschen.
</p>
<div style={{display:'flex',gap:8,marginBottom:14}}>
<input value={username} onChange={e=>setUsername(e.target.value)} onKeyDown={e=>e.key==='Enter'&&share()}
placeholder="Benutzername" autoCapitalize="none" style={{...S.inp,flex:1,fontSize:15}}/>
<button onClick={share} style={S.btn('#4ecdc4')}>Teilen</button>
</div>
{shares.length>0 ? (
<div>
<div style={{...S.head,marginBottom:6}}>GETEILT MIT</div>
{shares.map(s=>(
<div key={s.id} style={{display:'flex',justifyContent:'space-between',alignItems:'center',
padding:'8px 0',borderBottom:'1px solid rgba(255,255,255,0.05)'}}>
<div>
<div style={{color:'rgba(255,255,255,0.8)',fontFamily:'monospace',fontSize:13}}>{s.username}</div>
{s.shared_at && <div style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:10}}>seit {new Date(s.shared_at.replace(' ','T')).toLocaleDateString('de-DE')}</div>}
</div>
<button onClick={()=>unshare(s.id)} style={S.btn('#ff6b9d',true)}></button>
</div>
))}
</div>
) : (
<div style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:11}}>Noch mit niemandem geteilt.</div>
)}
</div>
</div>
);
}
export function SavedList({ toast, mobile }) {
const { confirm, ConfirmDialog } = useConfirm();
const [own, setOwn] = useState([]);
const [shared, setShared] = useState([]);
const [loading, setLoading] = useState(true);
const [tab, setTab] = useState('own');
const [editData, setEditData] = useState(null);
const [search, setSearch] = useState('');
const [lightbox, setLightbox] = useState(null);
const [shareItem,setShareItem]= useState(null);
const load = () => {
setLoading(true);
api('/tools/kalkulator3d')
.then(d => { setOwn(d.own||[]); setShared(d.shared||[]); })
.catch(e => toast(e.message,'error'))
.finally(()=>setLoading(false));
};
useEffect(()=>{ load(); },[]);
const del = async id => {
try { await api(`/tools/kalkulator3d/${id}`,{method:'DELETE'}); setOwn(p=>p.filter(i=>i.id!==id)); toast('Gelöscht'); }
catch(e) { toast(e.message,'error'); }
};
const items = tab==='own' ? own : shared;
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); load(); }} />
);
return (
<div style={{ padding: mobile ? '14px 14px 90px' : '36px 44px', maxWidth:900 }}>
{lightbox && <ImageModal src={lightbox} onClose={() => setLightbox(null)} />}
{shareItem && <CalcShareModal item={shareItem} onClose={()=>{setShareItem(null);load();}} toast={toast}/>}
<ConfirmDialog/>
<div style={{ display:'flex', alignItems:'center', justifyContent:'space-between', marginBottom:14 }}>
<h1 style={{ color:'#fff', fontFamily:"'Space Mono',monospace", fontSize:mobile?18:22, margin:0 }}>Archiv</h1>
<button onClick={load} title="Neu laden"
style={{ background:'transparent', border:'1px solid rgba(255,255,255,0.1)', borderRadius:8,
color:'rgba(255,255,255,0.4)', cursor:'pointer', padding:'6px 10px', fontSize:14, fontFamily:'monospace' }}></button>
</div>
{/* Tabs */}
<div style={{ display:'flex', gap:6, marginBottom:14 }}>
{[['own','Meine',own.length],['shared','Geteilt mit mir',shared.length]].map(([k,l,cnt])=>(
<button key={k} onClick={()=>{setTab(k);setSearch('');}} style={{
padding:'6px 14px', borderRadius:20, fontFamily:'monospace', fontSize:11, cursor:'pointer',
background: tab===k ? '#4ecdc4' : 'rgba(255,255,255,0.05)',
color: tab===k ? '#0d0d0f' : 'rgba(255,255,255,0.55)',
border: tab===k ? 'none' : '1px solid rgba(255,255,255,0.1)',
fontWeight: tab===k ? 700 : 400,
}}>
{l}{cnt>0&&<span style={{marginLeft:5,opacity:0.7}}>({cnt})</span>}
</button>
))}
</div>
{/* Suche */}
<div style={{ position:'relative', marginBottom:12 }}>
<span style={{ position:'absolute', left:12, top:'50%', transform:'translateY(-50%)',
color:'rgba(255,255,255,0.6)', 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.6)',
cursor:'pointer', fontSize:16,
}}></button>
)}
</div>
<p style={{ color:'rgba(255,255,255,0.55)', 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.6)', 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.55)', 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.55)', 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={{ display:'flex', alignItems:'center', gap:6 }}>
<div style={{ color:'#fff', fontFamily:'monospace', fontSize:13, fontWeight:700 }}>{item.name}</div>
{item.has_files && (
<span title="3D-Dateien vorhanden"
style={{ fontSize:11, background:'rgba(78,205,196,0.12)', border:'1px solid rgba(78,205,196,0.25)',
borderRadius:5, padding:'1px 5px', color:'#4ecdc4', fontFamily:'monospace', fontSize:9,
letterSpacing:0.5, flexShrink:0 }}>🖨 Dateien</span>
)}
</div>
<div style={{ color:'rgba(255,255,255,0.5)', 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.6)', 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 }}>
{!item.is_shared ? (
<>
<button onClick={() => setEditData(item)} style={{ ...S.btn('#4ecdc4', true), flex:1, textAlign:'center' }}> Bearbeiten</button>
<button onClick={() => setShareItem(item)} style={{ ...S.btn('#ffe66d', true), padding:'0 12px' }} title="Teilen">🤝</button>
<button onClick={() => del(item.id)} style={{ ...S.btn('#ff6b9d', true), padding:'0 12px' }} title="Löschen"></button>
</>
) : (
<div style={{ color:'rgba(255,255,255,0.25)', fontFamily:'monospace', fontSize:10, padding:'6px 0' }}>
von {item.owner_name} · nur Ansicht
</div>
)}
</div>
</div>
))}
</div>
);
}