772 lines
40 KiB
JavaScript
772 lines
40 KiB
JavaScript
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>
|
||
);
|
||
}
|