// Equipment management screen — list / add / edit / delete const { useState, useEffect, useRef } = React; // ─── Row: tap → preview, long-press → menu ─── function EqRow({ item, t, rentals, onOpen, onEdit, onDelete }) { const startX = React.useRef(0); const startY = React.useRef(0); const moved = React.useRef(false); const longTimer = React.useRef(null); const longFired = React.useRef(false); const rowRef = React.useRef(null); const [menu, setMenu] = React.useState(null); // { x, y } | null const { total, verfuegbar: verf } = eqStock(item, rentals); const cancelLong = () => { if (longTimer.current) { clearTimeout(longTimer.current); longTimer.current = null; } }; const onPointerDown = (ev) => { if (ev.pointerType === 'mouse' && ev.button !== 0) return; startX.current = ev.clientX; startY.current = ev.clientY; moved.current = false; longFired.current = false; cancelLong(); longTimer.current = setTimeout(() => { longFired.current = true; const rect = rowRef.current && rowRef.current.getBoundingClientRect(); if (rect) setMenu({ x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }); }, 480); try { ev.currentTarget.setPointerCapture(ev.pointerId); } catch {} }; const onPointerMove = (ev) => { const adx = ev.clientX - startX.current; const ady = ev.clientY - startY.current; if (!moved.current && (Math.abs(adx) > 5 || Math.abs(ady) > 5)) { moved.current = true; cancelLong(); } }; const settle = () => { cancelLong(); if (longFired.current) return; // menu shown, no tap if (moved.current) return; // user scrolled, no tap onOpen(); }; return (
{item.name}
{item.cat}
{item.price} €/Tag
{verf}/{total} verfügbar
{(item.accessories || []).length > 0 && (
+ {item.accessories.length} Zubehör
)} {(item.maint || []).filter(m => !m.done).map(m => { const mm = maintMeta(m.type); const label = m.type === 'tuev' ? `TÜV ${fmtDateDE(m.date).slice(0, 6)}` : mm.label; return (
{mm.icon} {label}
); })}
{menu && setMenu(null)} onEdit={() => { setMenu(null); onEdit(); }} onDelete={() => { setMenu(null); onDelete(); }}/>}
); } // Floating two-action menu shown after a long-press on a row. function EqContextMenu({ pos, t, onClose, onEdit, onDelete }) { // Clamp menu within viewport. const W = 220, H = 110; const vw = typeof window !== 'undefined' ? window.innerWidth : 360; const vh = typeof window !== 'undefined' ? window.innerHeight : 720; const left = Math.max(12, Math.min(vw - W - 12, pos.x - W / 2)); const top = Math.max(12, Math.min(vh - H - 12, pos.y - H / 2)); return (
e.stopPropagation()} style={{ position: 'fixed', inset: 0, zIndex: 200, background: 'rgba(0,0,0,0.25)', backdropFilter: 'blur(2px)', WebkitBackdropFilter: 'blur(2px)' }}/>
Bearbeiten
Löschen
); } // ─── Read-only preview sheet — opens when tapping a row ─── function EqPreviewSheet({ open, onClose, item, rentals, setEquipment, onEdit, onDelete }) { const t = useTheme(); if (!item) return
; const { total, verfuegbar: verf, vermietet, reparatur } = eqStock(item, rentals); const patch = (changes) => setEquipment(prev => prev.map(p => p.id === item.id ? { ...p, ...changes } : p)); const setQty = (q) => { const next = Math.max(1, Number(q) || 1); // Don't let qty drop below what's currently rented out const min = vermietet + reparatur; patch({ qty: Math.max(min, next) }); }; const Cell = ({ label, value, strong, accent }) => (
{value}
{label}
); const StepBtn = ({ sign, onClick, disabled }) => ( !disabled && onClick()} scale={0.88} disabled={disabled}>
0 ? 'M12 5v14M5 12h14' : 'M5 12h14'} color={t.text} size={17} sw={2.4}/>
); return (
{/* Hero */}
{item.name}
{item.cat}{item.sub ? ' · ' + item.sub : ''}
{item.kind && (
{item.kind}
)}
{/* Stats */}
{/* Anzahl stepper */}
Bestand
Anzahl im Inventar
{vermietet > 0 ? `${vermietet} vermietet` : 'Nichts vermietet'} {reparatur > 0 ? ` · ${reparatur} in Reparatur` : ''}
setQty((item.qty || 1) - 1)}/>
{item.qty || 1}
setQty((item.qty || 1) + 1)}/>
{/* Wartung — direkt editierbar */}
Wartung & Reparatur
patch({ maint: v })}/>
{/* Zubehör — read only */} {(item.accessories || []).length > 0 && (
Zubehör · gehört dazu
{item.accessories.map(s => (
{s}
))}
)} {/* Actions */}
Bearbeiten
Löschen
); } const EQ_KIND_OPTIONS = ['Tontechnik', 'Anhänger', 'Kabel']; // Suggest accessories based on equipment type / name keywords. Returns deduped list of strings. function suggestAccessories(eq) { if (!eq) return []; const key = ((eq.name || '') + ' ' + (eq.cat || '') + ' ' + (eq.kind || '')).toLowerCase(); const out = []; if (/mikr|mic|sm5|ew\d|sennheis|shure|funkmik|ges/.test(key)) { out.push('XLR-Kabel 5m', 'Mikrofonständer'); } if (/sm5|gesang/.test(key)) out.push('Popschutz'); if (/funk/.test(key)) out.push('Ersatz-Batterien (AA ×4)'); if (/lautsprech|aktivlaut|jbl|prx|eon|pa|box/.test(key)) { out.push('Boxenständer', 'Stromkabel 5m', 'Speakon-Kabel'); } if (/misch|mixer|mg10|yamaha|pult/.test(key)) { out.push('USB-Kabel', 'Klinke-Klinke 3m'); } if (/anhäng|trailer/.test(key)) { out.push('Spanngurte (4×)', 'Sicherungsnetz', 'Adapter 7→13-polig'); } if (/kabel|verläng|trommel/.test(key)) out.push('Kabeltrommel 25m'); return [...new Set(out)]; } // ─── Utilization stats card (Auslastung) ────────────────────── // ─── Top Performance card — % of total rentals per item ────── // Replaces the old generic utilization chart. Shows which products // drive the most rentals as a clear percentage of total bookings. function UtilizationCard({ equipment, onSelect }) { const t = useTheme(); const [open, setOpen] = useState(false); if (equipment.length === 0) return null; const totalUses = equipment.reduce((s, e) => s + (Number(e.uses) || 0), 0) || 1; const ranked = [...equipment] .map(e => ({ ...e, pct: Math.round(((Number(e.uses) || 0) / totalUses) * 100) })) .sort((a, b) => b.pct - a.pct); const topPct = ranked[0] ? ranked[0].pct : 0; const visible = open ? ranked : ranked.slice(0, 3); const barColor = (p) => p >= 25 ? t.green : p >= 12 ? t.accent : p >= 5 ? t.orange : t.textTer; const Bar = ({ pct, color }) => (
); return (
setOpen(o => !o)} scale={0.99}>
Beliebteste Produkte
Spitzenreiter:
{ranked[0] ? ranked[0].name : '—'}
{topPct}%
{visible.map((e, i) => ( onSelect && onSelect(e)} scale={0.99}>
#{i + 1}
{e.name}
{e.pct}%
{e.uses || 0} ×
))}
{!open && ranked.length > 3 && ( setOpen(true)} scale={0.99} style={{ marginTop: 10 }}>
Alle {ranked.length} Geräte anzeigen ›
)}
); } // ─── Accessory editor (used inside the equipment sheet) ─── function AccessoryEditor({ value = [], onChange, suggestions = [] }) { const t = useTheme(); const [text, setText] = useState(''); const items = value || []; const open = suggestions.filter(s => !items.includes(s)); const add = (v) => { const s = (v || text).trim(); if (!s) return; if (items.includes(s)) { setText(''); return; } onChange([...items, s]); setText(''); }; const remove = (s) => onChange(items.filter(x => x !== s)); const addAll = () => onChange([...items, ...open]); const inpStyle = { flex: 1, minWidth: 0, width: 0, padding: '10px 12px', borderRadius: 10, border: `0.5px solid ${t.inputBorder}`, background: t.inputBg, fontSize: 14, color: t.text, outline: 'none', fontFamily: 'inherit', boxSizing: 'border-box', }; return (
{/* Selected accessories */} {items.length > 0 && (
{items.map(s => (
{s} remove(s)} scale={0.85}>
))}
)} {/* Add custom */}
setText(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); add(); } }} placeholder="z.B. DI-Box, Adapter, Trolley…" style={inpStyle}/> add()} scale={0.94}>
+ Zubehör
{/* Suggestions */} {open.length > 0 && (
💡 Auto-Vorschläge
Alle übernehmen
{open.map(s => ( add(s)} scale={0.95}>
+ {s}
))}
)}
); } // ─── Maintenance / repair editor (used inside the equipment sheet) ─── function MaintEditor({ value = [], onChange }) { const t = useTheme(); const [adding, setAdding] = useState(false); const blank = { type: 'check', title: '', date: todayISO(), note: '' }; const [draft, setDraft] = useState(blank); const dInp = { width: '100%', padding: '10px 12px', borderRadius: 10, border: `0.5px solid ${t.inputBorder}`, background: t.inputBg, fontSize: 14, color: t.text, outline: 'none', fontFamily: 'inherit', boxSizing: 'border-box', }; const add = () => { if (!draft.title.trim()) return; onChange([...value, { ...draft, id: 'm-' + Date.now(), done: false }]); setDraft(blank); setAdding(false); }; const toggle = (id) => onChange(value.map(m => m.id === id ? { ...m, done: !m.done } : m)); const remove = (id) => onChange(value.filter(m => m.id !== id)); return (
{value.map(m => { const mm = maintMeta(m.type); return (
toggle(m.id)} scale={0.85}>
{m.done && }
{mm.icon} {m.title}
{mm.label} · {fmtDateDE(m.date)}{m.note ? ' · ' + m.note : ''}
remove(m.id)} scale={0.8}>
); })}
{adding ? (
{MAINT_TYPES.map(ty => { const mm = maintMeta(ty); const sel = draft.type === ty; return ( setDraft(d => ({ ...d, type: ty }))} scale={0.94}>
{mm.icon} {mm.label}
); })}
setDraft(d => ({ ...d, title: e.target.value }))} placeholder="z.B. Nächster TÜV / HU" style={dInp}/>
setDraft(d => ({ ...d, date: e.target.value }))} style={dInp}/> setDraft(d => ({ ...d, note: e.target.value }))} placeholder="Notiz" style={dInp}/>
Hinzufügen
{ setDraft(blank); setAdding(false); }} scale={0.96}>
Abbrechen
) : ( setAdding(true)} scale={0.98} style={{ marginTop: 8 }}>
Wartung / Defekt / TÜV erfassen
)}
); } // ─── Edit / Add equipment sheet ───────────────────────────── function EquipmentSheet({ open, onClose, initial, onSave, categories, rentals = [] }) { const t = useTheme(); const isEdit = !!initial; const blank = { id: '', name: '', cat: '', sub: '', kind: categories[0] || 'Tontechnik', repairQty: 0, price: 0, qty: 1, uses: 0, ic: 'speaker', emoji: '', photo: '', until: '', maint: [], accessories: [] }; const [form, setForm] = useState(blank); const [uploading, setUploading] = useState(false); const fileRef = useRef(null); useEffect(() => { if (open) setForm(initial ? { ...blank, ...initial } : blank); }, [open, initial]); const set = (k, v) => setForm(f => ({ ...f, [k]: v })); const valid = form.name.trim() && form.cat.trim(); const save = () => { if (!valid) return; const out = { ...form, id: form.id || ('eq-' + Date.now()), price: Number(form.price) || 0, qty: Math.max(1, Number(form.qty) || 1), repairQty: Math.max(0, Math.min(Math.max(1, Number(form.qty) || 1), Number(form.repairQty) || 0)), uses: Number(form.uses) || 0, }; onSave(out); onClose(); }; return (
{isEdit ? 'Equipment bearbeiten' : 'Neues Equipment'}
{isEdit ? 'Felder anpassen' : 'Lege ein neues Mietobjekt an.'}
Abbrechen
set('name', e.target.value)} placeholder="z.B. JBL PRX-815" style={inp(t)}/> set('cat', e.target.value)} placeholder="z.B. Aktivlautsprecher" style={inp(t)}/> set('sub', e.target.value)} placeholder="z.B. 1.300 W · 12″ · Bluetooth" style={inp(t)}/>
{categories.map(k => ( set('kind', k)} scale={0.95}>
{k}
))}
{/* Live preview */}
{form.photo && ( set('photo', '')} scale={0.85} style={{ position: 'absolute', top: -6, right: -6 }}>
)}
{/* Upload button */}
{ const file = e.target.files && e.target.files[0]; if (!file) return; setUploading(true); try { const url = await compressImage(file); setForm(f => ({ ...f, photo: url })); } catch { /* ignore */ } setUploading(false); e.target.value = ''; }}/> fileRef.current && fileRef.current.click()} scale={0.97}>
{uploading ? 'Lädt…' : (form.photo ? 'Foto ändern' : 'Eigenes Foto hochladen')}
Eigenes Bild aufnehmen oder wählen – oder unten ein Symbol/Emoji.
{/* Glyph row — only the first 3 symbols */}
Symbole
{EQ_GLYPHS.slice(0, 3).map(g => { const sel = !form.photo && !form.emoji && form.ic === g; return ( setForm(f => ({ ...f, ic: g, emoji: '', photo: '' }))} scale={0.9}>
); })}
{/* Custom emoji input — type your own */}
Eigenes Emoji
{form.emoji || '?'}
{ // grab the first visible glyph from the input const v = e.target.value; const seg = v ? [...v] : []; const ch = seg[seg.length - 1] || ''; setForm(f => ({ ...f, emoji: ch, photo: '' })); }} placeholder="Emoji eingeben …" maxLength={4} style={{ flex: 1, padding: '12px 14px', borderRadius: 12, border: `0.5px solid ${t.inputBorder}`, background: t.inputBg, fontSize: 18, color: t.text, outline: 'none', fontFamily: 'inherit', boxSizing: 'border-box' }}/> {form.emoji && ( setForm(f => ({ ...f, emoji: '' }))} scale={0.85}>
Löschen
)}
{/* Verfügbarkeit — abgeleitet aus Anzahl, aktiven Vermietungen und Reparatur */} {(() => { const stockTotal = Math.max(1, Number(form.qty) || 1); const vermietet = rentals .filter(r => r.equipmentId === form.id && r.status === 'aktiv') .reduce((s, r) => s + (Number(r.quantity) || 1), 0); const maxRepair = Math.max(0, stockTotal - vermietet); const reparatur = Math.max(0, Math.min(maxRepair, Number(form.repairQty) || 0)); const verfuegbar = Math.max(0, stockTotal - vermietet - reparatur); const StepBtn = ({ sign, disabled }) => ( !disabled && set('repairQty', Math.max(0, Math.min(maxRepair, reparatur + sign)))} scale={0.88} disabled={disabled}>
0 ? 'M12 5v14M5 12h14' : 'M5 12h14'} color={t.text} size={16} sw={2.4}/>
); const Cell = ({ label, value, strong }) => (
{value}
{label}
); return (
Für Reparatur / Wartung rausnehmen
{reparatur}
= maxRepair}/>
Reduziert automatisch die verfügbare Menge.
); })()}
set('price', e.target.value)} style={inp(t)}/> set('qty', e.target.value)} style={inp(t)}/> set('uses', e.target.value)} style={inp(t)}/>
set('maint', v)}/> set('accessories', v)} suggestions={suggestAccessories(form)}/>
{isEdit ? 'Änderungen speichern' : 'Equipment hinzufügen'}
); } const inp = (t) => ({ width: '100%', padding: '12px 14px', borderRadius: 12, border: `0.5px solid ${t.inputBorder}`, background: t.inputBg, fontSize: 15, color: t.text, outline: 'none', fontFamily: 'inherit', boxSizing: 'border-box', }); function FormGroup({ label, children, required, tight }) { const t = useTheme(); return (
{label}{required && *}
{children}
); } // ─── Categories sheet (re-implemented for this tab) ───────── function CategoriesSheet({ open, onClose, categories, setCategories, toast, equipment }) { const t = useTheme(); const [newName, setNewName] = useState(''); const [editingIdx, setEditingIdx] = useState(-1); const [editName, setEditName] = useState(''); const add = () => { const v = newName.trim(); if (!v) return; if (categories.includes(v)) { toast('Kategorie existiert bereits'); return; } setCategories([...categories, v]); setNewName(''); toast(`„${v}“ hinzugefügt`); }; const remove = (idx) => { const v = categories[idx]; setCategories(categories.filter((_, i) => i !== idx)); toast(`„${v}“ entfernt`); }; const startEdit = (idx) => { setEditingIdx(idx); setEditName(categories[idx]); }; const commitEdit = () => { const v = editName.trim(); if (!v || editingIdx < 0) { setEditingIdx(-1); return; } if (v === categories[editingIdx]) { setEditingIdx(-1); return; } const next = categories.slice(); next[editingIdx] = v; setCategories(next); setEditingIdx(-1); toast('Umbenannt'); }; return (
Kategorien
Hinzufügen, umbenennen, löschen.
Fertig
setNewName(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') add(); }} placeholder="Neue Kategorie…" style={inp(t)}/>
+ Neu
{categories.map((c, i) => (
{editingIdx === i ? ( setEditName(e.target.value)} onBlur={commitEdit} onKeyDown={e => { if (e.key === 'Enter') commitEdit(); if (e.key === 'Escape') setEditingIdx(-1); }} style={{ flex: 1, padding: '8px 10px', borderRadius: 8, border: `1px solid ${t.accent}`, background: t.card, fontSize: 15, color: t.text, outline: 'none', fontFamily: 'inherit' }}/> ) : ( startEdit(i)} scale={0.98} style={{ flex: 1 }}>
{c}
)}
{equipment.filter(e => e.kind === c).length}
remove(i)} scale={0.85}>
))}
Tipp: Auf einen Namen tippen, um umzubenennen.
); } // ─── Main Equipment screen ───────────────────────────────── function ScreenEquipment({ equipment, setEquipment, categories, setCategories, rentals = [], setRentals, logistik, setLogistik, initialArea = 'equipment', toast, go }) { const t = useTheme(); const [area, setArea] = useState(initialArea); useEffect(() => { setArea(initialArea); }, [initialArea]); const [filter, setFilter] = useState('Alle'); const [editing, setEditing] = useState(null); // equipment object or null const [sheetOpen, setSheetOpen] = useState(false); const [previewItem, setPreviewItem] = useState(null); const [previewOpen, setPreviewOpen] = useState(false); const [catsOpen, setCatsOpen] = useState(false); const [confirmId, setConfirmId] = useState(null); const [logistikSettingsOpen, setLogistikSettingsOpen] = useState(false); useEffect(() => { if (filter !== 'Alle' && !categories.includes(filter)) setFilter('Alle'); }, [categories, filter]); const filters = ['Alle', ...categories]; const filtered = filter === 'Alle' ? equipment : equipment.filter(e => e.kind === filter); const openNew = () => { setEditing(null); setSheetOpen(true); }; const openEdit = (eq) => { setEditing(eq); setSheetOpen(true); }; const openPreview = (eq) => { setPreviewItem(eq); setPreviewOpen(true); }; const editFromPreview = () => { setPreviewOpen(false); // Tiny delay so the preview sheet visually closes before the edit sheet appears. setTimeout(() => { setEditing(previewItem); setSheetOpen(true); }, 180); }; // Live-sync preview with latest equipment state (Anzahl/Wartung edits in preview). const livePreviewItem = previewItem ? (equipment.find(p => p.id === previewItem.id) || previewItem) : null; const onSave = (eq) => { setEquipment(prev => { const exists = prev.find(p => p.id === eq.id); if (exists) return prev.map(p => p.id === eq.id ? eq : p); return [eq, ...prev]; }); toast(editing ? 'Gespeichert' : `„${eq.name}“ hinzugefügt`); }; const doDelete = () => { const e = equipment.find(x => x.id === confirmId); setEquipment(prev => prev.filter(p => p.id !== confirmId)); toast(`„${e ? e.name : 'Equipment'}“ gelöscht`); setConfirmId(null); }; return (
{area === 'equipment' ? `${equipment.length} Objekte` : 'Lieferpreis · Routen'}
{area === 'equipment' ? 'Equipment' : 'Logistik'}
{area === 'equipment' ? (
) : ( setLogistikSettingsOpen(true)} scale={0.88}>
)}
{/* Sub-area switch — Equipment / Logistik */}
{[ ['equipment', 'Equipment', IMG_ICONS.lager], ['logistik', 'Logistik', IMG_ICONS.lieferwagen], ].map(([id, label, img]) => ( setArea(id)} scale={0.96} style={{ flex: 1 }}>
{(() => { const isActive = area === id; const isLineArt = !!img.invertDark; let filter = 'none'; if (isLineArt) { const px = isActive ? 0.7 : 0.4; const shadow = [ `drop-shadow(${px}px 0 0 #000)`, `drop-shadow(-${px}px 0 0 #000)`, `drop-shadow(0 ${px}px 0 #000)`, `drop-shadow(0 -${px}px 0 #000)`, ].join(' '); filter = t.dark ? `${shadow} invert(1)` : shadow; } return ( ); })()} {label}
))}
{area === 'logistik' && ( setLogistikSettingsOpen(false)} /> )} {area === 'equipment' && <> {/* Utilization stats */} {/* Filter chips + manage categories */}
{filters.map(label => ( setFilter(label)} scale={0.94}>
{label}
))} setCatsOpen(true)} scale={0.9}>
{/* List */}
{filtered.map(e => ( openPreview(e)} onEdit={() => openEdit(e)} onDelete={() => setConfirmId(e.id)}/> ))} {filtered.length === 0 && (
Keine Objekte.
+ Erstes Objekt anlegen
)}
} setPreviewOpen(false)} item={livePreviewItem} rentals={rentals} setEquipment={setEquipment} onEdit={editFromPreview} onDelete={() => { setPreviewOpen(false); if (livePreviewItem) setConfirmId(livePreviewItem.id); }}/> setSheetOpen(false)} initial={editing} onSave={onSave} categories={categories} rentals={rentals}/> setCatsOpen(false)} categories={categories} setCategories={setCategories} toast={toast} equipment={equipment}/> setConfirmId(null)} onConfirm={doDelete}/>
); } Object.assign(window, { ScreenEquipment });