// ─── Zubehör (accessories) — inventory management + overview ───
// Data model per accessory:
// { id, name, cat, qty, price (number|null → "zugehörig"), forEquipmentId (string|null),
// note, defectQty }
// Rental storage: rental.accessoryItems = [{ accessoryId, name, quantity, given, returned }]
const { useState: useStateZ, useEffect: useEffectZ } = React;
// Category list is now user-managed (starts empty). This map only supplies a nice
// default emoji when a category name happens to match — new names fall back to 📦.
const ACC_CAT_EMOJI = { 'Kabel': '🔌', 'Ständer': '🎚️', 'Adapter': '🔗', 'Taschen': '🎒', 'Batterien': '🔋', 'Sonstiges': '📦' };
// How many of an accessory are currently out (active/reserved rentals), and how many free.
function accStock(acc, rentals = [], excludeRentalId = null) {
const total = Math.max(0, Number(acc.qty) || 0);
const defect = Math.max(0, Number(acc.defectQty) || 0);
let out = 0;
for (const r of rentals) {
if (excludeRentalId && r.id === excludeRentalId) continue;
if (r.status !== 'aktiv' && r.status !== 'reserviert') continue;
for (const a of (r.accessoryItems || [])) {
if (a.accessoryId === acc.id && !a.returned) out += Math.max(0, Number(a.quantity) || 0);
}
}
const available = Math.max(0, total - defect - out);
return { total, defect, out, available };
}
if (typeof window !== 'undefined') window.accStock = accStock;
// ─── Add / edit accessory sheet ───────────────────────────────
function AccessorySheet({ open, onClose, initial, onSave, equipment = [], accCats = [], setAccCats }) {
const t = useTheme();
const blank = { id: '', name: '', cat: '', qty: 1, price: '', forEquipmentId: '', note: '', defectQty: 0, emoji: '', photo: '' };
const [form, setForm] = useStateZ(blank);
const [newCat, setNewCat] = useStateZ('');
const [uploading, setUploading] = useStateZ(false);
const [addingCat, setAddingCat] = useStateZ(false);
useEffectZ(() => {
if (open) { setForm(initial ? { ...blank, ...initial, price: initial.price == null ? '' : String(initial.price) } : blank); setAddingCat(false); setNewCat(''); }
}, [open, initial]);
const set = (k, v) => setForm(f => ({ ...f, [k]: v }));
const valid = form.name.trim();
const commitNewCat = () => {
const c = newCat.trim();
if (!c) { setAddingCat(false); return; }
if (setAccCats && !accCats.includes(c)) setAccCats(prev => [...prev, c]);
set('cat', c);
setNewCat(''); setAddingCat(false);
};
const save = () => {
const priceNum = String(form.price).trim() === '' ? null : Math.max(0, Number(form.price) || 0);
onSave({
...form,
id: form.id || 'acc-' + Date.now().toString(36),
name: form.name.trim(),
qty: Math.max(0, Number(form.qty) || 0),
defectQty: Math.max(0, Number(form.defectQty) || 0),
price: priceNum,
forEquipmentId: form.forEquipmentId || null,
});
onClose();
};
const inp = { width: '100%', boxSizing: 'border-box', padding: '11px 13px', borderRadius: 11, border: `0.5px solid ${t.inputBorder}`, background: t.inputBg, fontSize: 15, color: t.text, outline: 'none', fontFamily: 'inherit' };
const Stepper = ({ value, onChange, min = 0 }) => (
onChange(Math.max(min, (Number(value) || 0) - 1))} scale={0.85}>
{Number(value) || 0}
onChange((Number(value) || 0) + 1)} scale={0.85}>
);
return (
{initial ? 'Zubehör bearbeiten' : 'Neues Zubehör'}
{/* Icon — photo or emoji, editable like equipment */}
{form.photo && (
set('photo', '')} scale={0.85} style={{ position: 'absolute', top: -6, right: -6 }}>
)}
set('name', e.target.value)} placeholder="z.B. XLR-Kabel 5m" style={inp} autoFocus/>
{accCats.map(c => {
const on = form.cat === c;
return (
set('cat', c)} scale={0.95}>
{c}
);
})}
{addingCat ? (
) : (
{ setAddingCat(true); setNewCat(''); }} scale={0.9}>
Kategorie
)}
{accCats.length === 0 && !addingCat &&
Noch keine Kategorien — mit „✎ Kategorie“ eine anlegen.
}
Anzahl Bestand
set('qty', v)} min={0}/>
Davon defekt
set('defectQty', v)} min={0}/>
set('price', e.target.value)} inputMode="decimal"
placeholder="leer lassen → nicht einzeln verleihbar" style={inp}/>
{String(form.price).trim() === ''
? 'Ohne Preis: gehört als Zubehör zu Geräten, wird nicht einzeln berechnet.'
: 'Mit Preis: kann auch eigenständig verliehen werden.'}
set('forEquipmentId', '')} scale={0.95}>
— keins —
{equipment.map(eq => {
const on = form.forEquipmentId === eq.id;
return (
set('forEquipmentId', eq.id)} scale={0.95}>
{eq.name}
);
})}
{initial ? 'Speichern' : 'Zubehör anlegen'}
);
}
// ─── One accessory row in the overview ────────────────────────
function AccRow({ acc, t, rentals, equipment, onEdit, onDelete, view = 'card', showIcons = true }) {
const { total, out, available, defect } = accStock(acc, rentals);
const low = available === 0 || (total > 0 && available <= Math.max(1, Math.floor(total * 0.2)));
const eq = acc.forEquipmentId ? equipment.find(e => e.id === acc.forEquipmentId) : null;
const standalone = acc.price != null && Number(acc.price) > 0;
const compact = view === 'compact' || view === 'table';
const [menu, setMenu] = useStateZ(false);
if (compact) {
return (
{showIcons &&
}
{acc.name}
{acc.cat}{standalone ? ` · ${acc.price} €/Tag` : ' · zugehörig'}
);
}
return (
{showIcons &&
}
{acc.name}
{acc.cat}
{standalone
? · {acc.price} €/Tag
: · zugehörig }
{eq && zu {eq.name} }
{available}/{total}
verfügbar
{ e.stopPropagation && e.stopPropagation(); setMenu(m => !m); }} scale={0.85}>
{(out > 0 || defect > 0) && (
{out > 0 && {out}× verliehen }
{defect > 0 && {defect}× defekt }
)}
{menu && (
<>
setMenu(false)} style={{ position: 'fixed', inset: 0, zIndex: 40 }}/>
{ setMenu(false); onEdit(); }} scale={0.98}>
Bearbeiten
{ setMenu(false); onDelete(); }} scale={0.98}>
Löschen
>
)}
);
}
function accCatEmoji(cat) {
return ACC_CAT_EMOJI[cat] || '📦';
}
// Accessory avatar — photo > custom emoji > category emoji, on a soft tile.
// Mirrors EqAvatar so accessories get the same editable icon as equipment.
function AccAvatar({ acc = {}, size = 42, radius = 11 }) {
const t = useTheme();
const tileFill = 'transparent';
const tile = {
width: size, height: size, borderRadius: radius, flexShrink: 0, background: tileFill,
display: 'flex', alignItems: 'center', justifyContent: 'center', overflow: 'hidden'
};
if (acc.photo) {
return
;
}
return
{acc.emoji || accCatEmoji(acc.cat)}
;
}
// ─── Main Zubehör overview (embedded in Equipment screen) ─────
function ScreenZubehoer({ accessories, setAccessories, accCats = [], setAccCats, equipment = [], rentals = [], toast, newNonce, view = 'card', showIcons = true }) {
const t = useTheme();
const [sheetOpen, setSheetOpen] = useStateZ(false);
const [editing, setEditing] = useStateZ(null);
const [confirmId, setConfirmId] = useStateZ(null);
const [filter, setFilter] = useStateZ('Alle');
// Parent's "+" button bumps newNonce → open the add sheet.
useEffectZ(() => { if (newNonce) { setEditing(null); setSheetOpen(true); } }, [newNonce]);
const openNew = () => { setEditing(null); setSheetOpen(true); };
const onSave = (acc) => {
setAccessories(prev => {
const exists = prev.find(p => p.id === acc.id);
if (exists) return prev.map(p => p.id === acc.id ? acc : p);
return [acc, ...prev];
});
toast && toast(editing ? 'Gespeichert' : `„${acc.name}“ hinzugefügt`);
};
const doDelete = () => {
const a = accessories.find(x => x.id === confirmId);
setAccessories(prev => prev.filter(p => p.id !== confirmId));
toast && toast(`„${a ? a.name : 'Zubehör'}“ gelöscht`);
setConfirmId(null);
};
// Summary numbers
const totalUnits = accessories.reduce((s, a) => s + (Number(a.qty) || 0), 0);
const outUnits = accessories.reduce((s, a) => s + accStock(a, rentals).out, 0);
const lowCount = accessories.filter(a => { const s = accStock(a, rentals); return s.total > 0 && s.available <= Math.max(1, Math.floor(s.total * 0.2)); }).length;
// Category order: managed list first, then any leftover categories found on items.
const usedCats = [...new Set(accessories.map(a => a.cat).filter(Boolean))];
const orderedCats = [...accCats, ...usedCats.filter(c => !accCats.includes(c))];
const cats = ['Alle', ...orderedCats.filter(c => accessories.some(a => a.cat === c))];
const shown = filter === 'Alle' ? accessories : accessories.filter(a => a.cat === filter);
// Group by category for the "Alle" view
const groups = {};
for (const a of shown) { (groups[a.cat || 'Ohne Kategorie'] = groups[a.cat || 'Ohne Kategorie'] || []).push(a); }
const groupOrder = [...orderedCats.filter(c => groups[c]), ...(groups['Ohne Kategorie'] ? ['Ohne Kategorie'] : [])];
return (
{accessories.length > 0 && (
<>
{/* Summary card */}
{/* Category filter */}
{cats.map(c => (
setFilter(c)} scale={0.94}>
{c}
))}
>
)}
{/* List (grouped by category when showing all) */}
{accessories.length === 0 ? (
Noch kein Zubehör angelegt.
+ Erstes Zubehör anlegen
) : filter === 'Alle' ? (
groupOrder.map(cat => (
{cat}
{groups[cat].map(a =>
{ setEditing(a); setSheetOpen(true); }} onDelete={() => setConfirmId(a.id)}/>)}
))
) : (
{shown.map(a =>
{ setEditing(a); setSheetOpen(true); }} onDelete={() => setConfirmId(a.id)}/>)}
)}
setSheetOpen(false)} initial={editing} onSave={onSave} equipment={equipment} accCats={accCats} setAccCats={setAccCats}/>
setConfirmId(null)} onConfirm={doDelete}/>
);
}
Object.assign(window, { ScreenZubehoer, accStock, accCatEmoji });
// ─── Accessory editor used inside a rental (add/remove/qty + pricing + collisions) ───
// value = accessoryItems: [{ accessoryId, name, quantity, dailyRate }]
function AccessoryRentalEditor({ value = [], onChange, accessories = [], equipmentItems = [], rentals = [], excludeRentalId, rentalStart, rentalEnd, days = 1, showIcons = true }) {
const t = useTheme();
const [picking, setPicking] = useStateZ(false);
const items = value || [];
if (!accessories.length) return null; // nothing to manage yet
const byId = (id) => accessories.find(a => a.id === id);
const setQty = (id, q) => onChange(items.map(it => it.accessoryId === id ? { ...it, quantity: Math.max(1, q) } : it));
const remove = (id) => onChange(items.filter(it => it.accessoryId !== id));
// Auto-suggest: accessories whose forEquipmentId matches one of the rental's equipment items
const eqIds = new Set((equipmentItems || []).map(e => e.equipmentId));
const suggested = accessories.filter(a => a.forEquipmentId && eqIds.has(a.forEquipmentId) && !items.some(it => it.accessoryId === a.id));
const mkItem = (a, quantity) => ({ accessoryId: a.id, name: a.name, quantity, dailyRate: (a.price != null && Number(a.price) > 0) ? Number(a.price) : 0 });
const takeSuggested = () => onChange([...items, ...suggested.map(a => mkItem(a, 1))]);
const addBulk = (picks) => {
const existing = new Set(items.map(it => it.accessoryId));
const additions = picks.filter(p => !existing.has(p.acc.id)).map(p => mkItem(p.acc, p.quantity));
let next = items.map(it => { const hit = picks.find(p => p.acc.id === it.accessoryId); return hit ? { ...it, quantity: it.quantity + hit.quantity } : it; });
onChange([...next, ...additions]);
setPicking(false);
};
const billDays = Math.max(1, Number(days) || 1);
const accTotal = items.reduce((s, it) => s + billDays * (Number(it.dailyRate) || 0) * Math.max(1, Number(it.quantity) || 1), 0);
return (
{suggested.length > 0 && (
💡
Zubehör der Geräte übernehmen ({suggested.map(s => s.name).join(', ')})
)}
{items.length > 0 && (
{items.map(it => {
const acc = byId(it.accessoryId);
const clash = (acc && rentalStart && rentalEnd && window.accessoryConflicts)
? window.accessoryConflicts(it.accessoryId, it.quantity, rentalStart, rentalEnd, accessories, rentals, excludeRentalId) : null;
const paid = (Number(it.dailyRate) || 0) > 0;
const lineCost = billDays * (Number(it.dailyRate) || 0) * Math.max(1, Number(it.quantity) || 1);
return (
{showIcons &&
{accCatEmoji(acc ? acc.cat : '')} }
{it.name}
{clash ? `⚠ nur ${clash.total} verfügbar`
: paid ? `${it.dailyRate} €/Tag · ${lineCost} €` : 'zugehörig · kostenlos'}
setQty(it.accessoryId, it.quantity - 1)} scale={0.85}>
{it.quantity}
setQty(it.accessoryId, it.quantity + 1)} scale={0.85}>
remove(it.accessoryId)} scale={0.85}>
);
})}
{accTotal > 0 && (
Zubehör-Anteil ({billDays} {billDays > 1 ? 'Tage' : 'Tag'})
{accTotal} €
)}
)}
{picking ? (
setPicking(false)} onCommit={addBulk}/>
) : (
setPicking(true)} scale={0.98}>
Zubehör hinzufügen
)}
);
}
// Multi-select accessory picker (bulk add with quantities + availability)
function AccessoryPicker({ accessories, rentals = [], excludeRentalId, rentalStart, rentalEnd, onCommit, onCancel, showIcons = true }) {
const t = useTheme();
const [qty, setQty] = useStateZ({});
const [query, setQuery] = useStateZ('');
const bump = (id, d) => setQty(p => ({ ...p, [id]: Math.max(0, (p[id] || 0) + d) }));
const q = query.trim().toLowerCase();
const filtered = q ? accessories.filter(a => (a.name + ' ' + a.cat).toLowerCase().includes(q)) : accessories;
const distinct = accessories.filter(a => (qty[a.id] || 0) > 0);
const totalUnits = distinct.reduce((s, a) => s + qty[a.id], 0);
const commit = () => { if (distinct.length) onCommit(distinct.map(a => ({ acc: a, quantity: qty[a.id] }))); };
return (
Zubehör auswählen
Abbrechen
{accessories.length > 6 && (
setQuery(e.target.value)} placeholder="Suchen …"
style={{ width: '100%', boxSizing: 'border-box', padding: '10px 14px', marginBottom: 10, borderRadius: 999, border: `0.5px solid ${t.inputBorder}`, background: t.inputBg, color: t.text, fontSize: 13, outline: 'none', fontFamily: 'inherit' }}/>
)}
{filtered.length === 0 &&
Nichts gefunden.
}
{filtered.map(a => {
const n = qty[a.id] || 0;
const active = n > 0;
const stock = accStock(a, rentals, excludeRentalId);
const over = n > stock.available;
return (
{showIcons &&
}
{a.name}
{over ? `nur ${stock.available} frei` : `${stock.available} frei${a.price != null && a.price > 0 ? ` · ${a.price} €/Tag` : ''}`}
{active ? (
bump(a.id, -1)} scale={0.85}>
{n}
bump(a.id, 1)} scale={0.85}>
) : (
bump(a.id, 1)} scale={0.85}>
)}
);
})}
{totalUnits ? `${totalUnits} hinzufügen` : 'Menge wählen …'}
);
}
Object.assign(window, { AccessoryRentalEditor });