e.stopPropagation()}
style={{ position: 'fixed', inset: 0, zIndex: 200, background: 'rgba(0,0,0,0.25)', backdropFilter: 'blur(2px)', WebkitBackdropFilter: 'blur(2px)' }}/>
);
}
// ─── 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 }) => (
);
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 */}
);
}
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}>
))}
{!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 */}
{/* 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}>
{mm.icon} {m.title}
{mm.label} · {fmtDateDE(m.date)}{m.note ? ' · ' + m.note : ''}
remove(m.id)} scale={0.8}>
);
})}
{adding ? (
) : (
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 }) => (
);
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
{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 });