// Akten — Mietverträge (Mieter) + Protokolle (Vorlagen), alles editierbar. const { useState, useEffect, useRef } = React; // ─── Rentals list ─────────────────────────────────────────── function RentalsList({ rentals, onSelect, onNew, equipment, blockedCustomers = [] }) { const t = useTheme(); const [filter, setFilter] = useState('alle'); // alle | aktiv | reserviert | abgeschlossen | gesperrt const blockedSet = new Set((blockedCustomers || []).map(b => b.key)); const isBlocked = (r) => blockedSet.has(normalizeCustomerKey(r.tenantName)); const counts = { alle: rentals.length, aktiv: rentals.filter(r => r.status === 'aktiv').length, reserviert: rentals.filter(r => r.status === 'reserviert').length, abgeschlossen: rentals.filter(r => r.status === 'abgeschlossen').length, gesperrt: rentals.filter(isBlocked).length, }; const visible = rentals.filter(r => { if (filter === 'alle') return true; if (filter === 'gesperrt') return isBlocked(r); return r.status === filter; }); const SEGMENTS = [ { id: 'alle', label: 'Alle' }, { id: 'aktiv', label: 'Aktiv' }, { id: 'reserviert', label: 'Reserv.' }, { id: 'abgeschlossen', label: 'Fertig' }, { id: 'gesperrt', label: 'Gesperrt', danger: true }, ]; return (
Neuer Auftrag
{/* iOS-style filter bar — Alle / Aktiv / Reserv. / Fertig / Gesperrt */}
{SEGMENTS.map(seg => { const on = filter === seg.id; const count = counts[seg.id]; const showCount = on && count > 0 && seg.id !== 'alle'; const dangerOn = on && seg.danger; return ( setFilter(seg.id)} scale={0.95}>
{seg.label} {showCount && ( {count} )}
); })}
{visible.map((r) => { const m = statusMeta(r.status); const eq = equipment.find((e) => e.id === r.equipmentId); const blocked = isBlocked(r); return ( onSelect(r)} scale={0.98}>
{blocked && ( )}
{r.tenantName}
{rentalEquipmentLabel(r) || (eq ? eq.name : r.equipmentName)}
● {m.label}
{fmtRange(r.start, r.end)} {r.purpose && · {r.purpose}}
{rentalTotal(r)} €
); })} {visible.length === 0 &&
{filter === 'gesperrt' ? 'Keine gesperrten Kunden.' : filter !== 'alle' ? 'Keine Aufträge in diesem Status.' : 'Noch keine Mieter angelegt.'}
}
); } // ─── New rental sheet ───────────────────────────────────── // ─── Reusable multi-item equipment editor ────────────────────── const isPairEq = (eq) => eq && /lautsprech|aktivlautsprech|box\b|monitor/i.test((eq.name || '') + ' ' + (eq.cat || '')); // expose for cross-screen peeks if (typeof window !== 'undefined') window.isPairEq = isPairEq; function RentalItemsEditor({ items, onChange, equipment, days, rentals = [], events = [], rentalStart, rentalEnd, excludeRentalId }) { const t = useTheme(); const [picking, setPicking] = useState(false); const used = new Set(items.map((it) => it.equipmentId)); const available = equipment.filter((e) => !used.has(e.id)); const updateItem = (idx, patch) => onChange(items.map((it, i) => i === idx ? { ...it, ...patch } : it)); const removeItem = (idx) => onChange(items.filter((_, i) => i !== idx)); const addEquipment = (eq) => { const isPair = isPairEq(eq); onChange([...items, { equipmentId: eq.id, equipmentName: eq.name, dailyRate: Number(eq.price) || 0, quantity: isPair ? 2 : 1, // Mark pair items so totals know to halve per-piece (pair price ÷ 2 × pieces) isPair }]); setPicking(false); }; // For pair items the catalog price is per Paar (2 pieces) → per piece = half. const itemDayCost = (it) => { const isPair = it.isPair || isPairEq(equipment.find((e) => e.id === it.equipmentId)); const perPiece = isPair ? (Number(it.dailyRate) || 0) / 2 : Number(it.dailyRate) || 0; return perPiece * Math.max(1, Number(it.quantity) || 1); }; const dayTotal = items.reduce((s, it) => s + itemDayCost(it), 0); const grandTotal = dayTotal * Math.max(1, days || 1); return (
{/* Existing items */} {items.length === 0 ?
Noch keine Equipment ausgewählt.
:
{items.map((it, idx) => { const eq = equipment.find((e) => e.id === it.equipmentId); const isPair = isPairEq(eq); // availability check for THIS specific item const conflicts = rentalStart && rentalEnd ? equipmentConflicts(it.equipmentId, rentalStart, rentalEnd, rentals, events, excludeRentalId) : []; const free = conflicts.length === 0; // Over-quantity check — booked qty exceeds equipment stock const stockQty = Math.max(1, Number(eq && eq.qty) || 1); const overbook = Number(it.quantity) > stockQty; // For pair items the catalog price is per Paar (2 pieces); per piece = half. const perPiece = isPair ? (Number(it.dailyRate) || 0) / 2 : Number(it.dailyRate) || 0; const priceLabel = isPair ? perPiece + ' €/Stück' : (Number(it.dailyRate) || 0) + ' €/Tag'; return (
{eq && }
{it.equipmentName}
{priceLabel}
updateItem(idx, { quantity: Math.max(1, it.quantity - 1) })} scale={0.85}>
{it.quantity}
updateItem(idx, { quantity: it.quantity + 1 })} scale={0.85}>
removeItem(idx)} scale={0.85}>
{(!free || overbook) &&
{!free && ⚠ belegt } {overbook && ⚠ nur {stockQty} verfügbar }
}
); })} {days > 0 && dayTotal > 0 &&
{dayTotal} €/Tag · {days} Tag{days > 1 ? 'e' : ''}
{grandTotal} €
}
} {/* Add equipment */} {available.length > 0 &&
{picking ?
Equipment hinzufügen
setPicking(false)} scale={0.9}>
Abbrechen
{available.map((eq) => addEquipment(eq)} scale={0.98}>
{eq.name}{isPairEq(eq) && · Paar}
{eq.cat} · {eq.price} €/Tag
)}
: setPicking(true)} scale={0.97}>
Equipment hinzufügen ({available.length})
}
}
); } function NewRentalSheet({ open, onClose, onSave, equipment, rentals = [], events = [], prefill = null, logistik = {}, company = {}, blockedCustomers = [], toast }) { const t = useTheme(); const blank = { tenantName: '', address: '', email: '', phone: '', idCard: '', items: [], purpose: '', start: todayISO(), end: todayISO(), startTime: '09:00', endTime: '18:00', deposit: 100, discount: 0, note: '', color: '', delivery: { enabled: false, km: '', address: '' }, status: 'reserviert', depositStatus: 'offen', paymentStatus: 'offen', photos: [] }; const [form, setForm] = useState(blank); const [showPicker, setShowPicker] = useState(false); useEffect(() => {if (open) { const base = { ...blank, ...(prefill || {}) }; // Migrate prefilled single equipment to items[] if ((!base.items || !base.items.length) && base.equipmentId) { const eq = equipment.find((e) => e.id === base.equipmentId); if (eq) { base.items = [{ equipmentId: eq.id, equipmentName: eq.name, dailyRate: Number(base.dailyRate || eq.price) || 0, quantity: Math.max(1, Number(base.quantity) || (isPairEq(eq) ? 2 : 1)) }]; } } if (!base.items) base.items = []; setForm(base);setShowPicker(false); }}, [open]); const set = (k, v) => setForm((f) => ({ ...f, [k]: v })); const hasItems = !!(form.items && form.items.length > 0); const hasNote = !!(form.note && form.note.trim()); // Equipment is optional — but only when a note is provided to explain why. const valid = !!form.tenantName.trim() && (hasItems || hasNote); // ── Perso-Scan (simulierte Datenerkennung aus Ausweisfoto) ── const fileRef = useRef(null); const [scan, setScan] = useState({ phase: 'idle', img: null }); // idle | scanning | done useEffect(() => {if (open) setScan({ phase: 'idle', img: null });}, [open]); // Plausible mock identities the "OCR" returns const MOCK_IDS = [ { tenantName: 'Julia Sommer', address: 'Kapuzinerstr. 28\n80469 München', idCard: 'L01X9F4K7' }, { tenantName: 'Markus Reiter', address: 'Wörthstr. 11\n81667 München', idCard: 'T22M8B1Z3' }, { tenantName: 'Sabine Hofer', address: 'Schleißheimer Str. 94\n80797 München', idCard: 'C7P4D9Q21' }, { tenantName: 'Daniel Vogt', address: 'Rosenheimer Str. 145\n81671 München', idCard: 'X5K2T8M40' }]; const onPickFile = (e) => { const file = e.target.files && e.target.files[0]; const img = file ? URL.createObjectURL(file) : null; runScan(img); e.target.value = ''; }; const runScan = (img) => { setScan({ phase: 'scanning', img }); setTimeout(() => { const data = MOCK_IDS[Math.floor(Math.random() * MOCK_IDS.length)]; setForm((f) => ({ ...f, ...data })); setScan({ phase: 'done', img }); }, 1900); }; // ─── ID-card photo upload (Vorderseite / Rückseite) ─── const idPhotoRef = useRef(null); const [pendingIdSide, setPendingIdSide] = useState(null); const triggerIdPhoto = (side) => { setPendingIdSide(side); if (idPhotoRef.current) { idPhotoRef.current.value = ''; idPhotoRef.current.click(); } }; const onIdPhotoFile = async (e) => { const file = e.target.files && e.target.files[0]; if (!file) return; const side = pendingIdSide; setPendingIdSide(null); try { const dataUrl = await compressImage(file, 900); const label = side === 'front' ? 'Ausweis Vorderseite' : 'Ausweis Rückseite'; setForm(f => { const others = (f.photos || []).filter(p => p.label !== label); return { ...f, photos: [...others, { id: 'ph-' + Date.now(), label, dataUrl, addedAt: todayDE() }] }; }); } catch (err) { toast && toast('Foto konnte nicht geladen werden'); } }; const removeIdPhoto = (side) => { const label = side === 'front' ? 'Ausweis Vorderseite' : 'Ausweis Rückseite'; setForm(f => ({ ...f, photos: (f.photos || []).filter(p => p.label !== label) })); }; // Availability check — block double bookings (per item) const allConflicts = (form.items || []).flatMap((it) => equipmentConflicts(it.equipmentId, form.start, form.end, rentals, events).map((c) => ({ ...c, equipmentName: it.equipmentName }))); const free = allConflicts.length === 0 && (form.items || []).length > 0; const conflicts = allConflicts; const save = () => { if (!valid) return; const primary = form.items && form.items[0] || null; const computedStatus = autoRentalStatus(form.start, form.end); const out = { id: 'r-' + Date.now(), ...form, items: (form.items || []).map((it) => ({ equipmentId: it.equipmentId, equipmentName: it.equipmentName, dailyRate: Number(it.dailyRate) || 0, quantity: Math.max(1, Number(it.quantity) || 1), isPair: !!it.isPair })), // Keep legacy fields populated from primary item for back-compat equipmentId: primary ? primary.equipmentId : '', equipmentName: primary ? primary.equipmentName : '', dailyRate: primary ? primary.dailyRate : 0, quantity: primary ? primary.quantity : 1, deposit: Number(form.deposit) || 0, discount: Number(form.discount) || 0, // Auto-derive status from the date range. Payment & Kaution remain // exactly as the user picked them — no override. status: computedStatus, depositStatus: form.depositStatus || 'offen', paymentStatus: form.paymentStatus || 'offen' }; onSave(out); onClose(); }; const fInp = { width: '100%', padding: '12px 14px', borderRadius: 12, border: `0.5px solid ${t.inputBorder}`, background: t.card, fontSize: 15, color: t.text, outline: 'none', fontFamily: 'inherit', boxSizing: 'border-box' }; return (
Neuer Mieter
Mietvertrag anlegen
Abbrechen
{/* Perso-Scan */}
{scan.phase === 'idle' && fileRef.current && fileRef.current.click()} scale={0.98}>
Personalausweis scannen
Foto aufnehmen – Daten werden automatisch erkannt
} {scan.phase === 'scanning' &&
{scan.img ? :
🪪
}
Daten werden erkannt…
Name · Adresse · Ausweis-Nr.
} {scan.phase === 'done' &&
Daten übernommen
Bitte zur Sicherheit prüfen.
fileRef.current && fileRef.current.click()} scale={0.95}>
Erneut
}
Mieter
{(() => { const matched = findBlockedCustomer(form.tenantName, blockedCustomers); return ( <> set('tenantName', e.target.value)} placeholder="Vor- und Nachname *" style={{ ...fInp, border: matched ? `1.5px solid ${t.red}` : `0.5px solid ${t.inputBorder}` }} /> {matched && (
Kunde ist gesperrt
{matched.reason ?
{matched.reason}
:
Kein Grund hinterlegt.
}
Gesperrt seit {fmtDateDE(matched.since)}
)} ); })()}