// 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 (
{/* 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 ────────────────────── // Pairing/bundling removed — every position is always billed individually, // piece by piece, with its own daily rate and quantity. const isPairEq = () => false; // expose for cross-screen peeks (kept for back-compat; always false now) if (typeof window !== 'undefined') window.isPairEq = isPairEq; function EquipmentPicker({ available, onCommit, onCancel, showIcons = true }) { const t = useTheme(); const [qty, setQty] = useState({}); const [query, setQuery] = useState(''); const bump = (id, d) => setQty((p) => ({ ...p, [id]: Math.max(0, (p[id] || 0) + d) })); const q = query.trim().toLowerCase(); const filtered = q ? available.filter((eq) => (eq.name + ' ' + (eq.cat || '')).toLowerCase().includes(q)) : available; const distinct = available.filter((eq) => (qty[eq.id] || 0) > 0); const totalUnits = distinct.reduce((s, eq) => s + qty[eq.id], 0); const commit = () => { if (!distinct.length) return; onCommit(distinct.map((eq) => ({ eq, quantity: qty[eq.id] }))); }; return (
Equipment auswählen
Abbrechen
{available.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((eq) => { const n = qty[eq.id] || 0; const active = n > 0; const stockQty = Math.max(1, Number(eq.qty) || 1); const over = n > stockQty; return (
{showIcons && }
{eq.name}
{over ? `nur ${stockQty} verfügbar` : `${eq.cat} · ${eq.price} €/Tag`}
{active ?
bump(eq.id, -1)} scale={0.85}>
{n}
bump(eq.id, 1)} scale={0.85}>
: bump(eq.id, 1)} scale={0.85}>
}
); })}
{totalUnits ? `${totalUnits} hinzufügen` : 'Menge wählen …'}
); } function RentalItemsEditor({ items, onChange, equipment, days, calendarDays, billDays, onBillDaysChange, rentals = [], events = [], rentalStart, rentalEnd, excludeRentalId, showIcons = true }) { 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 addEquipmentBulk = (picks) => { onChange([...items, ...picks.map(({ eq, quantity }) => ({ equipmentId: eq.id, equipmentName: eq.name, dailyRate: Number(eq.price) || 0, quantity, }))]); setPicking(false); }; const itemDayCost = (it) => (Number(it.dailyRate) || 0) * Math.max(1, Number(it.quantity) || 1); const dayTotal = items.reduce((s, it) => s + itemDayCost(it), 0); const calDays = Math.max(1, Number(calendarDays != null ? calendarDays : days) || 1); const billed = Math.max(1, Number(days) || 1); const grandTotal = dayTotal * billed; const canEditDays = typeof onBillDaysChange === 'function'; const setBilled = (n) => onBillDaysChange && onBillDaysChange(Math.max(1, Math.floor(n))); return (
{/* Existing items */} {items.length === 0 ? null :
{items.map((it, idx) => { const eq = equipment.find((e) => e.id === it.equipmentId); // 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; const catalogPrice = Number(eq && eq.price) || 0; const edited = catalogPrice > 0 && Number(it.dailyRate) !== catalogPrice; const warn = !free || overbook; return (
{showIcons && eq && }
{it.equipmentName}
{warn ? (
{!free ? '⚠ belegt' : `⚠ nur ${stockQty} verfügbar`}
) : (
updateItem(idx, { dailyRate: e.target.value === '' ? '' : Number(e.target.value) })} onBlur={(e) => updateItem(idx, { dailyRate: Math.max(0, Number(e.target.value) || 0) })} style={{ width: 54, padding: '2px 6px', borderRadius: 8, border: `0.5px solid ${edited ? t.accent : t.inputBorder}`, background: t.inputBg, color: edited ? t.accent : t.text, fontSize: 12, fontWeight: 700, textAlign: 'right', fontVariantNumeric: 'tabular-nums', outline: 'none', fontFamily: 'inherit' }} /> €/Tag·St. {edited && updateItem(idx, { dailyRate: catalogPrice })} scale={0.9}> {catalogPrice} € }
)}
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}>
); })} {days > 0 && dayTotal > 0 &&
{canEditDays &&
Berechnete Tage
{calDays} Kalendertag{calDays > 1 ? 'e' : ''}{billed !== calDays ? ' · abweichend berechnet' : ''}
setBilled(billed - 1)} scale={0.85}>
onBillDaysChange(e.target.value === '' ? '' : Math.max(1, Math.floor(Number(e.target.value))))} onBlur={(e) => setBilled(Number(e.target.value) || 1)} style={{ width: 44, padding: '4px 6px', borderRadius: 9, border: `0.5px solid ${billed !== calDays ? t.accent : t.inputBorder}`, background: t.inputBg, color: billed !== calDays ? t.accent : t.text, fontSize: 15, fontWeight: 700, textAlign: 'center', fontVariantNumeric: 'tabular-nums', outline: 'none', fontFamily: 'inherit' }} /> setBilled(billed + 1)} scale={0.85}>
{billed !== calDays && setBilled(calDays)} scale={0.9}> Reset }
} {canEditDays &&
}
{dayTotal} €/Tag · {billed} Tag{billed > 1 ? 'e' : ''}
{grandTotal} €
}
} {/* Add equipment */} {available.length > 0 &&
{picking ? setPicking(false)} showIcons={showIcons} onCommit={addEquipmentBulk} /> : setPicking(true)} scale={0.97}>
Equipment hinzufügen
}
}
); } function NewRentalSheet({ open, onClose, onSave, equipment, accessories = [], tabSettings = {}, rentals = [], events = [], prefill = null, logistik = {}, company = {}, blockedCustomers = [], toast }) { const t = useTheme(); // Editable, persisted default calendar palette — changes here become the new standard everywhere. const [palette, setPalette] = window.useLocal('rf-event-colors', EVENT_COLORS); const blank = { tenantName: '', address: '', email: '', phone: '', idCard: '', items: [], accessoryItems: [], purpose: '', start: todayISO(), end: todayISO(), billDays: '', 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); const [showContact, setShowContact] = useState(false); // collapsible address/contact block const rentalsCfg = tabSettings.rentals || {}; useEffect(() => { if (open) setShowContact(!!rentalsCfg.expandContact); }, [open]); 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) || 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), })), // Billable-days override (empty string when it matches the calendar span) billDays: (form.billDays === '' || Number(form.billDays) === daysBetween(form.start, form.end)) ? '' : Math.max(1, Math.floor(Number(form.billDays) || 1)), // 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-Fotos file input (used by the Ausweis-Fotos grid below) */}
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)}
)} ); })()}
{/* Kontaktdaten — collapsible. When delivery is on, the address lives in the delivery section. */} setShowContact((s) => !s)} scale={0.99}>
Adresse & Kontakt {!showContact && (form.address || form.email || form.phone) ? · ausgefüllt : !showContact ? · optional : null}
{showContact && (