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 setPrice = (v) => patch({ price: v === '' ? '' : Math.max(0, Number(v) || 0) });
const maxRepair = Math.max(0, total - vermietet);
const setRepair = (n) => patch({ repairQty: Math.max(0, Math.min(maxRepair, Number(n) || 0)) });
const Cell = ({ label, value, strong, accent }) => (
);
const StepBtn = ({ sign, onClick, disabled }) => (
!disabled && onClick()} scale={0.85} disabled={disabled}>
0 ? t.solidBg : t.chip, display: 'flex', alignItems: 'center', justifyContent: 'center', opacity: disabled ? 0.4 : 1 }}>
0 ? 'M12 5v14M5 12h14' : 'M5 12h14'} color={sign > 0 ? t.solidFg : t.text} size={15} sw={2.4}/>
);
return (
{/* Hero */}
{item.name}
{item.cat}{item.sub ? ' · ' + item.sub : ''}{item.kind ? ` · ${item.kind}` : ''}
{/* Verfügbarkeit — Übersicht */}
{/* Schnell anpassen — Tagespreis · Anzahl · Reparatur ohne vollen Bearbeiten-Modus */}
{/* Schnell anpassen — als Liste (Label links, Stepper rechts) */}
Anzahl im Inventar
setQty((item.qty || 1) - 1)}/>
{item.qty || 1}
setQty((item.qty || 1) + 1)}/>
Für Reparatur
setRepair(reparatur - 1)}/>
{reparatur}
= maxRepair} onClick={() => setRepair(reparatur + 1)}/>
Tagespreis €
setPrice((Number(item.price) || 0) - 5)}/>
setPrice(e.target.value)}
onBlur={(e) => setPrice(String(Math.max(0, Number(e.target.value) || 0)))}
style={{ width: 64, textAlign: 'center', padding: '6px 4px', borderRadius: 10, border: `0.5px solid ${t.inputBorder}`, background: t.inputBg, fontSize: 17, fontWeight: 700, color: t.text, fontVariantNumeric: 'tabular-nums', outline: 'none', fontFamily: 'inherit', MozAppearance: 'textfield' }}/>
setPrice((Number(item.price) || 0) + 5)}/>
{/* 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, setCategories, rentals = [] }) {
const t = useTheme();
const isEdit = !!initial;
const blank = { id: '', name: '', cat: '', sub: '', kind: '',
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 [newCat, setNewCat] = useState('');
const [addingCat, setAddingCat] = useState(false);
const fileRef = useRef(null);
useEffect(() => {
if (open) { setForm(initial ? { ...blank, ...initial } : blank); setAddingCat(false); setNewCat(''); }
}, [open, initial]);
const set = (k, v) => setForm(f => ({ ...f, [k]: v }));
const commitNewCat = () => {
const c = newCat.trim();
if (!c) { setAddingCat(false); return; }
if (setCategories && !categories.includes(c)) setCategories(prev => [...prev, c]);
set('kind', c);
setNewCat(''); setAddingCat(false);
};
const valid = form.name.trim() && form.cat.trim();
// Reusable +/- stepper (matches the Reparatur stepper). `editable` lets the
// user tap the number and type an exact value, still nudgeable with +/-.
const Stepper = ({ value, onChange, min = 0, step = 1, editable = false, suffix = '' }) => {
const num = Number(value) || 0;
const Btn = ({ sign, disabled }) => (
!disabled && onChange(String(Math.max(min, num + sign * step)))} scale={0.85} disabled={disabled}>
0 ? t.solidBg : t.chip,
display: 'flex', alignItems: 'center', justifyContent: 'center', opacity: disabled ? 0.4 : 1,
}}>
0 ? 'M12 5v14M5 12h14' : 'M5 12h14'} color={sign > 0 ? t.solidFg : t.text} size={15} sw={2.4}/>
);
return (
{editable ? (
onChange(e.target.value)}
onBlur={(e) => onChange(String(Math.max(min, Number(e.target.value) || 0)))}
style={{
width: 64, textAlign: 'center', padding: '7px 4px', borderRadius: 10,
border: `0.5px solid ${t.inputBorder}`, background: t.inputBg,
fontSize: 17, fontWeight: 700, color: t.text, fontVariantNumeric: 'tabular-nums',
outline: 'none', fontFamily: 'inherit', MozAppearance: 'textfield',
}}/>
) : (
{num}{suffix}
)}
);
};
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
{/* Icon — Foto oder Emoji, kompakt ganz oben (wie Zubehör) */}
{form.photo && (
set('photo', '')} scale={0.85} style={{ position: 'absolute', top: -6, right: -6 }}>
)}
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)}/>
{categories.map(k => {
const on = form.kind === k;
return (
set('kind', k)} scale={0.95}>
{k}
);
})}
{addingCat ? (
) : (
{ setAddingCat(true); setNewCat(''); }} scale={0.9}>
Kategorie
)}
{categories.length === 0 && !addingCat && null}
Tagespreis €
set('price', v)} min={0} step={5} editable/>
Anzahl
set('qty', v)} min={1} step={1} editable/>
Einsätze
set('uses', v)} min={0} step={1} editable/>
In Reparatur
set('repairQty', Math.min(Number(v) || 0, Number(form.qty) || 1))} min={0} step={1} editable/>
{/* Verfügbarkeit & Reparatur werden in der Detail-/Info-Ansicht gepflegt,
nicht im Bearbeiten-Modus. */}
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, accessories = [], setAccessories, accCats = [], setAccCats, categories, setCategories, rentals = [], setRentals, logistik, setLogistik, tabSettings = {}, setTabSettings, initialArea = 'equipment', toast, go }) {
const t = useTheme();
const [area, setArea] = useState(initialArea);
const [zubNonce, setZubNonce] = useState(0);
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' : area === 'zubehoer' ? 'Zubehör' : 'Logistik'}
{area === 'equipment' ? (
) : area === 'zubehoer' ? (
setZubNonce(n => n + 1)} scale={0.9}>
) : null}
{/* Sub-area switch — underline tabs (AudioFlow-Stil) */}
{[
['equipment', 'Equipment', 'M3 7l9-4 9 4v10l-9 4-9-4V7zM3 7l9 4 9-4M12 11v10'],
['zubehoer', 'Zubehör', 'M20.5 7.3l-8-4.5a1 1 0 00-1 0l-8 4.5A1 1 0 003 8.2v7.6a1 1 0 00.5.9l8 4.5a1 1 0 001 0l8-4.5a1 1 0 00.5-.9V8.2a1 1 0 00-.5-.9zM3.3 7.9l8.7 4.9 8.7-4.9M12 22v-9.2'],
['logistik', 'Logistik', 'M1 3h13v10H1zM14 8h4l3 3v2h-7zM5.5 18.5a1.5 1.5 0 11-3 0 1.5 1.5 0 013 0zM19.5 18.5a1.5 1.5 0 11-3 0 1.5 1.5 0 013 0z'],
].map(([id, label, d]) => {
const on = area === id;
return (
setArea(id)} scale={0.97}>
{label}
);
})}
{area === 'zubehoer' && (
)}
{area === 'logistik' && (
setLogistikSettingsOpen(false)}
/>
)}
{area === 'equipment' && <>
{/* Utilization stats */}
{/* Filter chips */}
{filters.map(label => (
setFilter(label)} scale={0.94}>
{label}
))}
{/* List */}
{(() => {
const eqCfg = tabSettings.equipment || {};
const eqView = eqCfg.view || 'card';
const eqIcons = eqCfg.showIcons !== false;
const setEqCfg = (patch) => setTabSettings && setTabSettings((prev) => ({ ...prev, equipment: { ...(prev.equipment || {}), ...patch } }));
const VIEWS = [['card', 'Karten'], ['compact', 'Kompakt'], ['table', 'Tabelle']];
return (
<>
{filtered.length === 0 ? (
Keine Objekte.
+ Erstes Objekt anlegen
) : filter === 'Alle' ? (
(() => {
const groups = {};
for (const e of filtered) { const k = e.kind || 'Ohne Kategorie'; (groups[k] = groups[k] || []).push(e); }
const groupOrder = (() => {
const usedKinds = [...new Set(filtered.map(e => e.kind).filter(Boolean))];
const ordered = [...categories.filter(c => groups[c]), ...usedKinds.filter(k => !categories.includes(k) && groups[k])];
return [...ordered, ...(groups['Ohne Kategorie'] ? ['Ohne Kategorie'] : [])];
})();
return groupOrder.map(cat => (
{cat}
{groups[cat].map(e => (
openPreview(e)} onEdit={() => openEdit(e)} onDelete={() => setConfirmId(e.id)}/>
))}
));
})()
) : (
filtered.map(e => (
openPreview(e)} onEdit={() => openEdit(e)} onDelete={() => setConfirmId(e.id)}/>
))
)}
>
);
})()}
>}
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} setCategories={setCategories} rentals={rentals}/>
setCatsOpen(false)} categories={categories} setCategories={setCategories} toast={toast} equipment={equipment}/>
setConfirmId(null)} onConfirm={doDelete}/>
);
}
Object.assign(window, { ScreenEquipment });