e.stopPropagation()}
style={{ position: 'fixed', inset: 0, zIndex: 200, background: 'rgba(0,0,0,0.25)', backdropFilter: 'blur(2px)', WebkitBackdropFilter: 'blur(2px)' }}/>
{items.map((it, i) => {
const handler = it.id === 'edit' ? onEdit : onDelete;
return (
{it.label}
{it.id === 'edit'
?
: }
);
})}
);
}
// ─────────────────────────────────────────────────────────────
// Modal sheet
// ─────────────────────────────────────────────────────────────
function Sheet({ open, onClose, children, maxHeight = '85%' }) {
const t = useTheme();
useBodyScrollLock(open);
const [dragY, setDragY] = React.useState(0);
const [dragging, setDragging] = React.useState(false);
const [visible, setVisible] = React.useState(open);
const startY = React.useRef(0);
React.useEffect(() => { setDragY(0); setDragging(false); }, [open]);
// Keep the panel rendered through its close animation, then hard-hide it so a
// closed sheet can never "peek" into view during page scroll (iOS fixed-paint quirk).
React.useEffect(() => {
if (open) { setVisible(true); return; }
const id = setTimeout(() => setVisible(false), 360);
return () => clearTimeout(id);
}, [open]);
const onGrabDown = (e) => {
startY.current = e.clientY; setDragging(true);
try { e.currentTarget.setPointerCapture(e.pointerId); } catch {}
};
const onGrabMove = (e) => {
if (!dragging) return;
setDragY(Math.max(0, e.clientY - startY.current));
};
const onGrabUp = (e) => {
setDragging(false);
const dy = e.clientY - startY.current;
if (dy > 100) { onClose && onClose(); } else setDragY(0);
};
return ReactDOM.createPortal(
<>
{ setDragging(false); setDragY(0); }}
style={{ display: 'flex', justifyContent: 'center', padding: '10px 0 8px', position: 'sticky', top: 0, background: t.sheetBg, zIndex: 2, touchAction: 'none', cursor: 'grab' }}>
{children}
>,
document.body
);
}
// ─────────────────────────────────────────────────────────────
// Confirm dialog
// ─────────────────────────────────────────────────────────────
function Confirm({ open, title, message, confirmLabel = 'Löschen', danger = true, onCancel, onConfirm }) {
const t = useTheme();
useBodyScrollLock(open);
return ReactDOM.createPortal(
<>
{title}
{message &&
{message}
}
>,
document.body
);
}
// ─────────────────────────────────────────────────────────────
// Inline editable field — tap to edit
// ─────────────────────────────────────────────────────────────
function Field({ label, value, onChange, placeholder = '—', multiline = false, type = 'text' }) {
const t = useTheme();
const [editing, setEditing] = useState(false);
const [tmp, setTmp] = useState(value || '');
useEffect(() => { setTmp(value || ''); }, [value]);
const save = () => {
setEditing(false);
if (tmp !== value) onChange(tmp);
};
return (
{label}
{editing ? (
multiline ? (
);
}
// ─────────────────────────────────────────────────────────────
// Date helpers
// ─────────────────────────────────────────────────────────────
const DE_DAYS = ['Sonntag','Montag','Dienstag','Mittwoch','Donnerstag','Freitag','Samstag'];
const DE_MONTHS = ['Januar','Februar','März','April','Mai','Juni','Juli','August','September','Oktober','November','Dezember'];
const DE_MONTHS_SHORT = ['Jan','Feb','Mär','Apr','Mai','Jun','Jul','Aug','Sep','Okt','Nov','Dez'];
const fmtDateISO = (d) => {
if (typeof d === 'string') return d;
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const dd = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${dd}`;
};
const fromISO = (s) => { const [y,m,d] = s.split('-').map(Number); return new Date(y, m-1, d); };
const fmtDateDE = (s) => { const d = fromISO(s); return `${String(d.getDate()).padStart(2,'0')}.${String(d.getMonth()+1).padStart(2,'0')}.${d.getFullYear()}`; };
const fmtDateLong = (s) => { const d = fromISO(s); return `${DE_DAYS[d.getDay()]}, ${d.getDate()}. ${DE_MONTHS[d.getMonth()]}`; };
const fmtRange = (s, e) => {
const ds = fromISO(s), de = fromISO(e);
if (s === e) return `${ds.getDate()}. ${DE_MONTHS[ds.getMonth()]} ${ds.getFullYear()}`;
if (ds.getMonth() === de.getMonth() && ds.getFullYear() === de.getFullYear())
return `${ds.getDate()}.–${de.getDate()}. ${DE_MONTHS[ds.getMonth()]} ${ds.getFullYear()}`;
return `${ds.getDate()}. ${DE_MONTHS_SHORT[ds.getMonth()]} – ${de.getDate()}. ${DE_MONTHS_SHORT[de.getMonth()]} ${de.getFullYear()}`;
};
const daysBetween = (s, e) => {
return Math.round((fromISO(e) - fromISO(s)) / 86400000) + 1;
};
// Billable days for pricing. Defaults to the calendar span, but a rental may
// carry a `billDays` override (e.g. rent over a week, charge only 1 day).
const billableDays = (r) => {
const bd = r && r.billDays;
if (bd != null && bd !== '' && Number(bd) >= 1) return Math.floor(Number(bd));
return daysBetween(r.start, r.end);
};
const todayISO = () => fmtDateISO(new Date()); // real "today"
// ─────────────────────────────────────────────────────────────
// Tab bar — Home · Kalender · Equipment · Finanzen · Akten
// ─────────────────────────────────────────────────────────────
function InteractiveTabBar({ active, onChange, dark, variant, showLabels }) {
const t = useTheme();
const items = [
{ id: 'home', d: 'M3 12L12 3l9 9M5 10v10h14V10' },
{ id: 'cal', d: icons.cal },
{ id: 'eq', img: IMG_ICONS.warenhaus },
{ id: 'eur', d: icons.euro },
{ id: 'doc', d: icons.doc },
];
// Which visual style: floating pill vs. full-width bottom bar.
// `variant` (from the Darstellung setting) wins; otherwise the theme decides
// (Studio → full bar, classic → floating pill).
const style = variant || (t.studio ? 'bar' : 'pill');
// Full-width bottom bar (Studio look): labelled 4-tab bar — Start · Kalender · Lager · Akten.
// Finanzen is reached from the dashboard; Mieter/Rechnungen/Schnelltexte live under Akten.
if (style === 'bar') {
const studioItems = t.studio ? [
{ id: 'home', label: 'Start', d: 'M3 12L12 3l9 9M5 10v10h14V10' },
{ id: 'cal', label: 'Kalender', d: icons.cal },
{ id: 'eq', label: 'Lager', d: 'M3 7l9-4 9 4v10l-9 4-9-4V7zM3 7l9 4 9-4M12 11v10' },
{ id: 'doc', label: 'Akten', d: icons.doc },
] : [
{ id: 'home', label: 'Start', d: 'M3 12L12 3l9 9M5 10v10h14V10' },
{ id: 'cal', label: 'Kalender', d: icons.cal },
{ id: 'eq', label: 'Lager', img: IMG_ICONS.warenhaus },
{ id: 'eur', label: 'Finanzen', d: icons.euro },
{ id: 'doc', label: 'Akten', d: icons.doc },
];
const activeCol = t.accent;
const muteCol = t.textSec;
return (
{studioItems.map(i => {
const on = i.id === active;
return (
onChange(i.id)} scale={0.9} style={{ flex: 1 }}>
{i.img
?
:
}
{showLabels !== false &&
{i.label} }
);
})}
);
}
return (
{items.map(i => {
const isActive = i.id === active;
return (
onChange(i.id)} scale={0.85} style={{
flex: 1, height: 36, display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
{i.img ? (() => {
const isLineArt = !!i.img.invertDark;
if (isLineArt) {
// Use mask-image so the icon renders as a flat solid color
// (no anti-aliased grays leaking through) — pure #000 in light, #fff in dark.
const src = dark ? i.img.dark : i.img.light;
const color = dark ? '#fff' : '#000';
const size = isActive ? 22 : 21;
return (
);
}
return (
);
})() : (
)}
);
})}
);
}
// ─────────────────────────────────────────────────────────────
// Initial seed data (nur Startwert, falls der Schlüssel auf dem Server noch nicht existiert)
// ─────────────────────────────────────────────────────────────
const SEED_EQUIPMENT = [
{ id: 'jbl', name: 'JBL EON 712', cat: 'Aktivlautsprecher', sub: '1.300 W · 12″ · Bluetooth', kind: 'Tontechnik', qty: 2, repairQty: 0, until: 'bis Sa', price: 45, util: 78, uses: 42, ic: 'speaker',
accessories: ['Boxenständer', 'Stromkabel 5m', 'Speakon-Kabel'],
maint: [{ id: 'm-jbl1', type: 'check', title: 'Funktionscheck', date: '2026-03-12', done: true, note: 'Alles ok' }] },
{ id: 'tr750', name: 'Anhänger 750kg', cat: 'Kastenanhänger', sub: '2,05 × 1,10 m · ungebremst', kind: 'Anhänger', qty: 1, repairQty: 0, until: '', price: 40, util: 62, uses: 28, ic: 'trailer',
accessories: ['Spanngurte (4×)', 'Sicherungsnetz'],
maint: [
{ id: 'm-tr1', type: 'tuev', title: 'Nächster TÜV / HU', date: '2026-06-14', done: false, note: 'GTÜ Prüfstelle München-Süd' },
{ id: 'm-tr2', type: 'check', title: 'Reifendruck geprüft', date: '2026-04-30', done: true, note: '' },
] },
{ id: 'sm58', name: 'Shure SM58 ×4', cat: 'Mikrofon-Set', sub: '4× Gesangs-Mikro mit Stativen', kind: 'Tontechnik', qty: 4, repairQty: 1, until: 'bis Mi', price: 30, util: 71, uses: 56, ic: 'mic',
accessories: ['XLR-Kabel 5m', 'Mikrofonständer', 'Popschutz'],
maint: [{ id: 'm-sm1', type: 'defect', title: 'Mikro #3: Klinke wackelt', date: '2026-05-16', done: false, note: 'Kabelbruch vermutet – vor nächster Vermietung prüfen' }] },
{ id: 'mg10', name: 'Yamaha MG10XU', cat: 'Mischpult', sub: '10 Kanäle · USB · Effekte', kind: 'Tontechnik', qty: 1, repairQty: 1, until: '', price: 35, util: 54, uses: 31, ic: 'mixer',
accessories: ['USB-Kabel', 'Klinke-Klinke 3m'],
maint: [{ id: 'm-mg1', type: 'repair', title: 'Kanal 4 rauscht', date: '2026-05-10', done: false, note: 'In Reparatur bei Werkstatt Müller' }] },
{ id: 'tr1500', name: 'Anhänger 1500kg', cat: 'Pferdeanhänger', sub: 'Doppelachser · gebremst', kind: 'Anhänger', qty: 1, repairQty: 0, until: '', price: 75, util: 28, uses: 9, ic: 'trailer',
accessories: ['Spanngurte (4×)'],
maint: [{ id: 'm-tr15', type: 'tuev', title: 'Nächster TÜV / HU', date: '2027-02-28', done: false, note: '' }] },
{ id: 'ew100', name: 'Sennheiser EW100', cat: 'Funkmikro-Set', sub: 'Funkstrecke · 2× Handsender', kind: 'Tontechnik', qty: 2, repairQty: 0, until: '', price: 55, util: 36, uses: 14, ic: 'mic',
accessories: ['XLR-Kabel 5m', 'Ersatz-Batterien (AA ×4)'],
maint: [] },
];
const SEED_RENTALS = [
{ id: 'r1', status: 'aktiv',
tenantName: 'Andreas Müller', address: 'Hauptstr. 12\n80331 München', email: 'a.mueller@example.com', phone: '+49 89 123 4567', idCard: 'L1234567',
equipmentId: 'jbl', equipmentName: 'JBL EON 712', purpose: 'Hochzeit',
start: '2026-05-18', end: '2026-05-22', startTime: '14:00', endTime: '11:00',
dailyRate: 45, deposit: 200, depositStatus: 'erhalten',
delivery: { enabled: true, address: 'Hauptstr. 12, 80331 München', km: 18, tripFactor: 4 } },
{ id: 'r6', status: 'aktiv',
tenantName: 'Familie Bauer', address: 'Lindenweg 4\n82152 Planegg', email: 's.bauer@example.com', phone: '+49 89 700 2211', idCard: 'T2233445',
equipmentId: 'sm58', equipmentName: 'Shure SM58 ×4', purpose: 'Gartenfest',
start: '2026-05-19', end: '2026-05-21', dailyRate: 30, deposit: 80, depositStatus: 'erhalten' },
{ id: 'r2', status: 'reserviert',
tenantName: 'Konstantin Klein', address: 'Birkenallee 22\n80809 München', email: 'k.klein@gmail.com', phone: '+49 89 444 1122', idCard: 'L7654321',
equipmentId: 'jbl', equipmentName: 'PA-Komplettset', purpose: 'Geburtstag (40)',
start: '2026-06-01', end: '2026-06-02', startTime: '16:00', endTime: '12:00',
dailyRate: 160, deposit: 300, depositStatus: 'offen',
delivery: { enabled: true, address: 'Birkenallee 22, 80809 München', km: 22, tripFactor: 4 } },
{ id: 'r3', status: 'abgeschlossen',
tenantName: 'TSV Sportverein e.V.', address: 'Sportweg 5\n82110 Germering', email: 'kontakt@tsv-germering.de', phone: '+49 89 555 8800', idCard: '–',
equipmentId: 'sm58', equipmentName: 'Shure SM58 ×4', purpose: 'Vereinsfest',
start: '2026-04-26', end: '2026-04-27', startTime: '09:00', endTime: '18:00', dailyRate: 30, deposit: 60, depositStatus: 'zurueckgezahlt' },
{ id: 'r4', status: 'abgeschlossen',
tenantName: 'Petra Schmidt', address: 'Ringstr. 8\n85375 Neufahrn', email: 'p.schmidt@web.de', phone: '+49 8165 123 45', idCard: 'X9876543',
equipmentId: 'tr750', equipmentName: 'Anhänger 750kg', purpose: 'Umzug',
start: '2026-05-15', end: '2026-05-15', startTime: '08:00', endTime: '18:00', dailyRate: 40, deposit: 100, depositStatus: 'zurueckgezahlt' },
{ id: 'r5', status: 'abgeschlossen',
tenantName: 'Andreas Müller', address: 'Hauptstr. 12\n80331 München', email: 'a.mueller@example.com', phone: '+49 89 123 4567', idCard: 'L1234567',
equipmentId: 'tr750', equipmentName: 'Anhänger 750kg', purpose: 'Gartenabfälle',
start: '2026-03-07', end: '2026-03-08', dailyRate: 40, deposit: 100, depositStatus: 'zurueckgezahlt' },
];
// Protocol templates — now editable. {tenant} {equipment} {period} {start} {end} {total} {deposit} {days} {rate} {address} {today} placeholders.
const SEED_PROTOCOLS = [
{ id: 'reservation', name: 'Reservierungsbestätigung', icon: '📋', color: '#007AFF',
desc: 'Bestätigung an den Mieter senden',
text:
`Hallo {tenant},
vielen Dank für Ihre Reservierung!
📅 Zeitraum: {period}
🕐 Abholung: {start}, {startTime} Uhr
🕐 Rückgabe: {end}, {endTime} Uhr
🚚 {delivery}
🎤 Equipment: {equipment}
💰 Gesamtpreis: {total} €
🔒 Kaution: {deposit} €
Bitte zur Übergabe einen gültigen Ausweis mitbringen.
Bei Fragen melden Sie sich gerne.
Viele Grüße
RentFlow Manager`,
},
{ id: 'handover', name: 'Übergabeprotokoll', icon: '📦', color: '#34C759',
desc: 'Bei Abholung dokumentieren',
text:
`ÜBERGABEPROTOKOLL
Datum: {today}
Mieter: {tenant}
Adresse: {address}
Equipment: {equipment}
Mietzeitraum: {period}
Abholung/Anlieferung: {start} {startTime} Uhr
Rückgabe spätestens: {end} {endTime} Uhr
Logistik: {delivery}
Zustand bei Übergabe:
☑ Vollständig und funktionsfähig
☑ Keine sichtbaren Mängel
☐ Anmerkung: ____________________
Kaution erhalten: {deposit} €
___________________ ___________________
Mieter Vermieter`,
},
{ id: 'return', name: 'Rückgabeprotokoll', icon: '↩️', color: '#FF9500',
desc: 'Bei Rückgabe dokumentieren',
text:
`RÜCKGABEPROTOKOLL
Datum: {today}
Mieter: {tenant}
Equipment: {equipment}
Mietzeitraum: {period}
Rückgabe-Modus: {delivery}
Zustand bei Rückgabe:
☑ Vollständig
☑ Funktionsfähig
☑ Keine neuen Mängel
☐ Anmerkung: ____________________
Kaution zurückerstattet: {deposit} €
Abrechnung: {total} € ({days} Tage à {rate} €)
___________________ ___________________
Mieter Vermieter`,
},
{ id: 'note', name: 'Erinnerung / Notiz', icon: '📝', color: '#AF52DE',
desc: 'Kurze Nachricht zum Mieter',
text:
`Hallo {tenant},
kurze Erinnerung: Ihr Termin für {equipment} ist am {start}.
Bei Fragen einfach melden!
Viele Grüße`,
},
];
// ─────────────────────────────────────────────────────────────
// Seed emails (Posteingang) — incoming rental requests + normal mail
// App's fixed "today" is 2026-05-20.
// ─────────────────────────────────────────────────────────────
const SEED_EMAILS = [
{ id: 'm1', from: 'Lena Wagner', fromEmail: 'lena.wagner@gmail.com',
subject: 'Anfrage Lautsprecher für Hochzeit', dateISO: '2026-05-20', time: '09:14',
unread: true, starred: false, kind: 'request', replied: false, archived: false, linkedRentalId: null,
parsed: { equipmentHint: 'JBL Lautsprecher / PA', startISO: '2026-06-13', endISO: '2026-06-14', purpose: 'Hochzeit', phone: '+49 170 2233445', deliveryAddress: 'Seestr. 9, 82211 Herrsching' },
body: 'Hallo,\n\nwir heiraten am 13. Juni und suchen eine Musikanlage für ca. 80 Gäste im Festsaal. Zwei aktive Lautsprecher mit Bluetooth wären super. Wir würden gerne am Freitag (13.6.) abholen und am Sonntag zurückbringen.\n\nKönnt ihr auch liefern? Die Adresse wäre Seestr. 9 in Herrsching.\n\nViele Grüße\nLena Wagner\nTel. 0170 2233445' },
{ id: 'm2', from: 'Thomas Brandt', fromEmail: 't.brandt@web.de',
subject: 'Anhänger am Wochenende frei?', dateISO: '2026-05-20', time: '08:02',
unread: true, starred: false, kind: 'request', replied: false, archived: false, linkedRentalId: null,
parsed: { equipmentHint: 'Anhänger 750kg', startISO: '2026-05-24', endISO: '2026-05-24', purpose: 'Umzug', phone: '+49 151 99887766', deliveryAddress: '' },
body: 'Guten Morgen,\n\nich bräuchte für einen kleinen Umzug am Samstag, den 24.5., einen Kastenanhänger (750 kg reicht). Selbstabholung ist kein Problem.\n\nWas kostet das für einen Tag?\n\nBeste Grüße\nThomas Brandt' },
{ id: 'm3', from: 'SV Eintracht Germering', fromEmail: 'vorstand@sv-eintracht.de',
subject: 'Tontechnik Sommerfest 18.–19. Juli', dateISO: '2026-05-19', time: '17:46',
unread: false, starred: true, kind: 'request', replied: false, archived: false, linkedRentalId: null,
parsed: { equipmentHint: 'PA + Mikrofone (Komplettset)', startISO: '2026-07-18', endISO: '2026-07-19', purpose: 'Vereins-Sommerfest', phone: '+49 89 880011', deliveryAddress: 'Sportplatz Germering' },
body: 'Sehr geehrte Damen und Herren,\n\nfür unser Sommerfest am 18. und 19. Juli benötigen wir eine Beschallungsanlage inkl. 2–3 Mikrofonen für Ansagen und eine kleine Bühne. Lieferung und Aufbau auf unseren Sportplatz wären ideal.\n\nBitte um ein Angebot.\n\nMit freundlichen Grüßen\nVorstand SV Eintracht' },
{ id: 'm4', from: 'Andreas Müller', fromEmail: 'a.mueller@example.com',
subject: 'Re: Rückgabe JBL EON 712', dateISO: '2026-05-19', time: '14:20',
unread: false, starred: false, kind: 'normal', replied: false, archived: false, linkedRentalId: 'r1',
parsed: null,
body: 'Hallo,\n\npasst es, wenn ich die Boxen am Freitag erst gegen 18 Uhr zurückbringe? Komme direkt von der Arbeit.\n\nDanke & Gruß\nAndreas' },
{ id: 'm5', from: 'Petra Schmidt', fromEmail: 'p.schmidt@web.de',
subject: 'Frage zur Rechnung RE-2026-118', dateISO: '2026-05-18', time: '11:05',
unread: false, starred: false, kind: 'normal', replied: true, archived: false, linkedRentalId: 'r4',
parsed: null,
body: 'Guten Tag,\n\nich habe die Rechnung für den Anhänger erhalten – vielen Dank. Eine kurze Frage: Ist die Kaution darin schon verrechnet oder bekomme ich die separat zurück?\n\nFreundliche Grüße\nPetra Schmidt' },
{ id: 'm6', from: 'Markus Reiter', fromEmail: 'm.reiter@gmx.de',
subject: 'Funkmikrofone für Konferenz', dateISO: '2026-05-17', time: '16:30',
unread: false, starred: false, kind: 'request', replied: false, archived: false, linkedRentalId: null,
parsed: { equipmentHint: 'Sennheiser EW100 Funkmikro', startISO: '2026-06-05', endISO: '2026-06-05', purpose: 'Firmen-Konferenz', phone: '', deliveryAddress: '' },
body: 'Hallo zusammen,\n\nfür eine Tagesveranstaltung am 5. Juni bräuchten wir 2 Funkmikrofone (Handsender). Abholung am Vortag möglich?\n\nDanke\nM. Reiter' },
];
const SEED_EVENTS = [
{ id: 'g1', title: 'Werkstatt-Termin Anhänger', equipmentId: '', start: '2026-05-21', end: '2026-05-21', color: '#5856D6', note: 'TÜV-Vorbereitung 750kg' },
{ id: 'g2', title: 'Inventur Lager', equipmentId: '', start: '2026-05-25', end: '2026-05-25', color: '#FF2D55', note: '' },
{ id: 'g3', title: 'Messe AV-Technik München', equipmentId: '', start: '2026-05-28', end: '2026-05-29', color: '#AF52DE', note: 'Networking & Einkauf' },
];
const PROTOCOL_COLORS = ['#007AFF', '#34C759', '#FF9500', '#AF52DE', '#FF3B30', '#5856D6', '#FF2D55', '#30D158'];
const EVENT_COLORS = ['#6c8388', '#4f7a5b', '#b07a3c', '#6c6f8a', '#b0474b', '#5a6b86', '#b06a52'];
const EQUIPMENT_ICONS = ['speaker', 'mic', 'cable', 'trailer'];
// Editable Schnelltexte — message + document templates (WhatsApp, E-Mail, Protokolle).
const SEED_QUICKTEXTS = [
{ id: 'wa-res', name: 'Reservierungsbestätigung (WhatsApp)',
desc: 'Kurze Bestätigung per WhatsApp', icon: '💬',
text: 'Hallo {tenant}, ✨\n\nIhre Reservierung ist bestätigt:\n\n• {equipment}\n• Zeitraum: {period}\n• Gesamt: {total} €\n\nBei Fragen melden Sie sich gerne. Vielen Dank!' },
{ id: 'mail-res', name: 'Reservierungsbestätigung (E-Mail)',
desc: 'Formellere Variante per E-Mail', icon: '✉️',
text: 'Sehr geehrte/r {tenant},\n\nvielen Dank für Ihre Reservierung. Hiermit bestätigen wir folgenden Mietvorgang:\n\nEquipment: {equipment}\nZeitraum: {period}\nGesamt: {total} €\n\nBei Rückfragen stehen wir Ihnen jederzeit zur Verfügung.\n\nMit freundlichen Grüßen' },
{ id: 'reminder', name: 'Erinnerung Abholung',
desc: 'Am Tag vor der Abholung schicken', icon: '⏰',
text: 'Hallo {tenant}, kleine Erinnerung: Ihr {equipment} steht morgen für Sie bereit. Bis dann!' },
{ id: 'handover', name: 'Übergabeprotokoll', icon: '📦',
desc: 'Bei Abholung dokumentieren',
text:
`ÜBERGABEPROTOKOLL
Datum: {today}
Mieter: {tenant}
Adresse: {address}
Equipment: {equipment}
Mietzeitraum: {period}
Abholung/Anlieferung: {start} {startTime} Uhr
Rückgabe spätestens: {end} {endTime} Uhr
Logistik: {delivery}
Zustand bei Übergabe:
☑ Vollständig und funktionsfähig
☑ Keine sichtbaren Mängel
☐ Anmerkung: ____________________
Kaution erhalten: {deposit} €
___________________ ___________________
Mieter Vermieter` },
{ id: 'return', name: 'Rückgabeprotokoll', icon: '↩️',
desc: 'Bei Rückgabe dokumentieren',
text:
`RÜCKGABEPROTOKOLL
Datum: {today}
Mieter: {tenant}
Equipment: {equipment}
Mietzeitraum: {period}
Rückgabe-Modus: {delivery}
Zustand bei Rückgabe:
☑ Vollständig
☑ Funktionsfähig
☑ Keine neuen Mängel
☐ Anmerkung: ____________________
Kaution zurückerstattet: {deposit} €
Abrechnung: {total} € ({days} Tage à {rate} €)
___________________ ___________________
Mieter Vermieter` },
];
const fillTemplate = (tpl, rental, eqName) => {
const days = billableDays(rental);
const items = (window.getRentalItems ? window.getRentalItems(rental) : []);
const itemsSubtotal = items.length
? items.reduce((s, it) => {
return s + days * (Number(it.dailyRate) || 0) * Math.max(1, Number(it.quantity) || 1);
}, 0)
: days * (Number(rental.dailyRate) || 0) * Math.max(1, Number(rental.quantity) || 1);
// Delivery cost
const logistik = window.__rfLogistik || {};
const d = rental.delivery || {};
let deliveryTotal = 0, deliveryLine = '';
if (d.enabled) {
const calc = window.calcDelivery
? window.calcDelivery(d.km, d.tripFactor || logistik.tripFactor || 4, logistik.kmRate || 0.7, logistik.pauschale || 10)
: { total: 0, totalKm: 0 };
deliveryTotal = calc.total || 0;
deliveryLine = deliveryTotal > 0
? `Anlieferung & Abholung: ${(d.address || rental.address || '').replace(/\n/g, ', ')} · ${String(calc.totalKm).replace('.', ',')} km — ${deliveryTotal.toFixed(2).replace('.', ',')} €`
: '';
} else {
deliveryLine = 'Selbstabholung & Selbstrückgabe durch Mieter';
}
const subtotal = itemsSubtotal + deliveryTotal;
const discount = Number(rental.discount) || 0;
const total = Math.max(0, subtotal - discount);
const qty = items[0] ? items[0].quantity : Math.max(1, Number(rental.quantity) || 1);
const equipmentList = items.length
? items.map(it => (it.quantity > 1 ? it.quantity + ' × ' : '') + it.equipmentName).join(', ')
: (eqName || rental.equipmentName || '');
const accList = (rental.accessoryItems || []).map(a => (a.quantity > 1 ? a.quantity + ' × ' : '') + a.name).join(', ') || '–';
return tpl
.replaceAll('{tenant}', rental.tenantName)
.replaceAll('{equipment}', equipmentList)
.replaceAll('{zubehoer}', accList)
.replaceAll('{period}', fmtRange(rental.start, rental.end))
.replaceAll('{start}', fmtDateDE(rental.start))
.replaceAll('{end}', fmtDateDE(rental.end))
.replaceAll('{startTime}', rental.startTime || '–')
.replaceAll('{endTime}', rental.endTime || '–')
.replaceAll('{delivery}', deliveryLine)
.replaceAll('{deliveryAddress}', (d.address || rental.address || '').replaceAll('\n', ', '))
.replaceAll('{deliveryFee}', deliveryTotal.toFixed(2).replace('.', ','))
.replaceAll('{total}', String(total))
.replaceAll('{subtotal}', String(subtotal))
.replaceAll('{discount}', String(discount))
.replaceAll('{quantity}', String(qty))
.replaceAll('{deposit}', String(rental.deposit))
.replaceAll('{days}', String(days))
.replaceAll('{rate}', String(rental.dailyRate))
.replaceAll('{address}', (rental.address || '').replaceAll('\n', ', '))
.replaceAll('{today}', todayDE());
};
const todayDE = () => new Date().toLocaleDateString('de-DE');
// ─────────────────────────────────────────────────────────────
// Status colors for rentals
// ─────────────────────────────────────────────────────────────
const statusMeta = (s) => ({
aktiv: { color: '#34C759', label: 'AKTIV' },
reserviert: { color: '#FF9500', label: 'RESERVIERT' },
abgeschlossen: { color: 'rgba(60,60,67,0.55)', label: 'ABGESCHLOSSEN' },
}[s] || { color: 'rgba(60,60,67,0.55)', label: s.toUpperCase() });
// Auto-compute a rental's status from its date range.
// Future → 'reserviert', Past → 'abgeschlossen', Today/active span → 'aktiv'.
const autoRentalStatus = (start, end) => {
const today = todayISO();
if (!start || !end) return 'reserviert';
if (today < start) return 'reserviert';
if (today > end) return 'abgeschlossen';
return 'aktiv';
};
// Deposit status meta (Kaution)
const depositMeta = (s) => ({
offen: { color: '#FF9500', label: 'Kaution offen', short: 'Offen' },
bezahlt: { color: '#5856D6', label: 'Voraus bezahlt', short: 'Bezahlt' },
erhalten: { color: '#34C759', label: 'Kaution aktiv', short: 'Aktiv' },
zurueckgezahlt: { color: 'rgba(60,60,67,0.55)', label: 'Kaution zurückgezahlt', short: 'Zurückgezahlt' },
}[s || 'offen']);
const DEPOSIT_STEPS = ['offen', 'bezahlt', 'zurueckgezahlt'];
// Auto-derive Kaution status from the rental status.
// reserviert → 'offen' (unless user marked 'bezahlt' = paid-in-advance)
// aktiv → 'erhalten' (active — locked in once the rental runs)
// abgeschlossen → 'zurueckgezahlt'
const autoDepositStatus = (rentalStatus, current) => {
if (rentalStatus === 'aktiv') return 'erhalten';
if (rentalStatus === 'abgeschlossen') return 'zurueckgezahlt';
return current === 'bezahlt' ? 'bezahlt' : 'offen';
};
// Mietbetrag-Status (separate from deposit) — same logic.
// reserviert → user toggles offen / bezahlt
// aktiv / abgeschlossen → auto 'bezahlt' (the rent has been collected by then)
const PAYMENT_STEPS = ['offen', 'bezahlt'];
const paymentMeta = (s) => ({
offen: { color: '#FF9500', label: 'Mietbetrag offen', short: 'Offen' },
bezahlt: { color: '#34C759', label: 'Mietbetrag bezahlt', short: 'Bezahlt' },
}[s || 'offen']);
const autoPaymentStatus = (rentalStatus, current) => {
if (rentalStatus === 'aktiv' || rentalStatus === 'abgeschlossen') return 'bezahlt';
return current === 'bezahlt' ? 'bezahlt' : 'offen';
};
// Maintenance record meta
const maintMeta = (type) => ({
tuev: { label: 'TÜV / HU', color: '#AF52DE', icon: '🛡️' },
repair: { label: 'Reparatur', color: '#FF3B30', icon: '🔧' },
defect: { label: 'Defekt', color: '#FF9500', icon: '⚠️' },
check: { label: 'Wartung', color: '#34C759', icon: '✅' },
}[type] || { label: 'Notiz', color: 'rgba(60,60,67,0.55)', icon: '📝' });
const MAINT_TYPES = ['check', 'defect', 'repair', 'tuev'];
// "Tomorrow" relative to the app's fixed today (2026-05-20)
const tomorrowISO = () => { const d = fromISO(todayISO()); d.setDate(d.getDate() + 1); return fmtDateISO(d); };
// ─────────────────────────────────────────────────────────────
// Blocked customers — global registry keyed by normalized tenant name.
// One entry per customer; status follows them across all rentals.
// Shape: { key, name, reason, since }
// ─────────────────────────────────────────────────────────────
const normalizeCustomerKey = (name) => String(name || '').trim().toLowerCase().replace(/\s+/g, ' ');
const findBlockedCustomer = (name, blocked) => {
const key = normalizeCustomerKey(name);
if (!key) return null;
return (blocked || []).find(b => b.key === key) || null;
};
const upsertBlockedCustomer = (blocked, name, reason) => {
const key = normalizeCustomerKey(name);
if (!key) return blocked || [];
const list = blocked || [];
const idx = list.findIndex(b => b.key === key);
const entry = {
key, name: String(name || '').trim(),
reason: String(reason || ''),
since: (idx >= 0 ? list[idx].since : todayISO()),
};
if (idx >= 0) { const next = list.slice(); next[idx] = entry; return next; }
return [...list, entry];
};
const removeBlockedCustomer = (blocked, name) => {
const key = normalizeCustomerKey(name);
return (blocked || []).filter(b => b.key !== key);
};
// Inclusive overlap test for two ISO date ranges
const rangesOverlap = (s1, e1, s2, e2) => s1 <= e2 && s2 <= e1;
// Stock breakdown for one piece of equipment.
// total = Anzahl, vermietet = aktive Vermietungen, reparatur = manuell rausgenommen,
// verfuegbar = Rest. Single source of truth for availability across the app.
function eqStock(e, rentals = []) {
const total = Math.max(1, Number(e && e.qty) || 1);
const vermietet = rentals
.filter(r => r && r.equipmentId === (e && e.id) && r.status === 'aktiv')
.reduce((s, r) => s + (Number(r.quantity) || 1), 0);
const reparatur = Math.max(0, Math.min(total, Number(e && e.repairQty) || 0));
const verfuegbar = Math.max(0, total - vermietet - reparatur);
return { total, vermietet, reparatur, verfuegbar };
}
// Find rentals that book an accessory in [start,end] beyond its available stock.
function accessoryConflicts(accessoryId, need, start, end, accessories = [], rentals = [], excludeRentalId = null) {
if (!accessoryId || !start || !end) return null;
const acc = accessories.find((a) => a.id === accessoryId);
if (!acc) return null;
const total = Math.max(0, Number(acc.qty) || 0) - Math.max(0, Number(acc.defectQty) || 0);
let booked = 0;
const clashRentals = [];
for (const r of rentals) {
if (excludeRentalId && r.id === excludeRentalId) continue;
if (r.status !== 'aktiv' && r.status !== 'reserviert') continue;
if (!rangesOverlap(start, end, r.start, r.end)) continue;
for (const a of (r.accessoryItems || [])) {
if (a.accessoryId === accessoryId && !a.returned) {
booked += Math.max(0, Number(a.quantity) || 0);
clashRentals.push({ rentalId: r.id, tenantName: r.tenantName, qty: Number(a.quantity) || 0 });
}
}
}
const over = (booked + Math.max(0, need)) - total;
if (over > 0) return { acc, total, booked, need, over, clashRentals };
return null;
}
if (typeof window !== 'undefined') window.accessoryConflicts = accessoryConflicts;
// Find rentals (and calendar events) that block a piece of equipment in [start,end]
function equipmentConflicts(equipmentId, start, end, rentals = [], events = [], excludeRentalId = null) {
if (!equipmentId || !start || !end) return [];
const out = [];
rentals.forEach(r => {
if (r.id === excludeRentalId) return;
if (r.status === 'abgeschlossen') return;
// Multi-item or legacy single-equipment check
const ids = (Array.isArray(r.items) && r.items.length) ? r.items.map(it => it.equipmentId) : [r.equipmentId];
if (!ids.includes(equipmentId)) return;
if (rangesOverlap(start, end, r.start, r.end)) {
out.push({ kind: 'rental', id: r.id, title: r.tenantName, start: r.start, end: r.end, status: r.status });
}
});
events.forEach(ev => {
if (ev.equipmentId !== equipmentId) return;
if (rangesOverlap(start, end, ev.start, ev.end)) {
out.push({ kind: 'event', id: ev.id, title: ev.title, start: ev.start, end: ev.end, color: ev.color });
}
});
return out;
}
// Build the list of due reminders (returns due tomorrow / today, overdue, TÜV soon)
function buildReminders(rentals = [], equipment = []) {
const today = todayISO(), tmrw = tomorrowISO();
const rem = [];
rentals.forEach(r => {
if (r.status === 'abgeschlossen') return;
if (r.end === tmrw) rem.push({ id: 'ret-' + r.id, urgency: 'soon', icon: '📦', title: 'Rückgabe morgen fällig', sub: `${r.tenantName} · ${r.equipmentName}` });
else if (r.end === today) rem.push({ id: 'ret-' + r.id, urgency: 'today', icon: '📦', title: 'Rückgabe heute fällig', sub: `${r.tenantName} · ${r.equipmentName}` });
else if (r.end < today) rem.push({ id: 'ret-' + r.id, urgency: 'over', icon: '⏰', title: 'Rückgabe überfällig', sub: `${r.tenantName} · ${r.equipmentName}` });
if (r.status === 'reserviert' && r.start === tmrw) rem.push({ id: 'pick-' + r.id, urgency: 'soon', icon: '🚀', title: outLabel(r) + ' morgen', sub: `${r.tenantName} · ${r.equipmentName}` });
});
equipment.forEach(e => {
(e.maint || []).forEach(m => {
if (!m.done && m.type === 'tuev') {
const days = daysBetween(todayISO(), m.date) - 1;
if (days >= 0 && days <= 45) rem.push({ id: 'tuev-' + m.id, urgency: days <= 14 ? 'soon' : 'info', icon: '🛡️', title: `TÜV in ${days} Tag${days === 1 ? '' : 'en'}`, sub: `${e.name} · ${fmtDateDE(m.date)}` });
}
if (!m.done && (m.type === 'defect' || m.type === 'repair')) {
rem.push({ id: 'def-' + m.id, urgency: 'info', icon: '🔧', title: m.title, sub: `${e.name}` });
}
});
});
// urgency order: over, today, soon, info
const ord = { over: 0, today: 1, soon: 2, info: 3 };
return rem.sort((a, b) => ord[a.urgency] - ord[b.urgency]);
}
// ─────────────────────────────────────────────────────────────
// Router / App
// ─────────────────────────────────────────────────────────────
function App() {
// Theme: 'light' | 'dark' | 'nord' | 'pastell'. Migrate the old rf-dark boolean once.
const [themeId, setThemeId] = useLocal('rf-theme', (() => {
try {
const saved = localStorage.getItem('rf-theme');
if (saved) { const v = JSON.parse(saved); if (v === 'light' || v === 'dark') return 'studio'; }
} catch {}
return 'studio';
})());
const [accents, setAccents] = useLocal('rf-accents', {}); // { themeId: '#hex' } accent overrides
const [navLayoutRaw, setNavLayout] = useLocal('rf-navlayout', 'classic'); // classic | command | launcher
const navLayout = navLayoutRaw === 'rail' ? 'classic' : navLayoutRaw; // 'rail' layout retired → fall back to classic
const [navLabels, setNavLabels] = useLocal('rf-navlabels', true); // show text labels under bottom-bar icons
const baseTheme = THEMES[themeId] || studioTheme;
// Retire old light/dark themes — force anyone still on them into Studio.
React.useEffect(() => {
if (themeId === 'light' || themeId === 'dark') setThemeId('studio');
}, [themeId]);
const t = React.useMemo(() => applyAccent(baseTheme, accents[themeId]), [baseTheme, accents, themeId]);
const dark = t.dark;
// Keep the document root background + theme-color in sync with the theme so
// the area revealed when the mobile address bar collapses matches the app.
React.useEffect(() => {
document.documentElement.style.background = t.pageBg;
document.body.style.background = t.bg;
let m = document.querySelector('meta[name="theme-color"]');
if (!m) { m = document.createElement('meta'); m.setAttribute('name', 'theme-color'); document.head.appendChild(m); }
m.setAttribute('content', t.pageBg);
}, [t]);
const [route, setRoute] = useState({ screen: 'home', params: {} });
const [toast, setToast] = useState('');
const [rfdbErr, clearRfdbErr] = useRfdbErrorBanner();
// Lifted, persisted state
const [equipment, setEquipment] = useLocal('rf-equipment-v2', []);
const [accessories, setAccessories] = useLocal('rf-accessories', []);
const [workflows, setWorkflows] = useLocal('rf-workflows', {});
const [reviews, setReviews] = useLocal('rf-reviews', []);
// Auto-merge any reviews the review site left in this browser (same-origin case).
React.useEffect(() => {
const dismissed = window.loadDismissedReviews ? window.loadDismissedReviews() : new Set();
const site = (window.loadSiteReviews ? window.loadSiteReviews() : []).filter((r) => r && !dismissed.has(String(r.id)));
if (site.length) {
setReviews((prev) => {
const merged = window.mergeReviews ? window.mergeReviews(prev, site) : prev;
return merged.length !== prev.length ? merged : prev;
});
}
}, []);
const [tabSettings, setTabSettings] = useLocal('rf-tab-settings', {}); // per-tab display prefs
const [accCats, setAccCats] = useLocal('rf-acc-cats', []);
const [categories, setCategories] = useLocal('rf-categories', []);
const [rentals, setRentals] = useLocal('rf-rentals-v2', []);
// Auto-derive each rental's status from its date range whenever rentals change
// or the day rolls over. Payment & Kaution stay 100% user-controlled — the user
// explicitly asked to be able to pick any value at any time.
React.useEffect(() => {
const normalize = (r) => {
const wantStatus = autoRentalStatus(r.start, r.end);
if (wantStatus === r.status) return r;
return { ...r, status: wantStatus };
};
setRentals(prev => {
let changed = false;
const next = prev.map(r => { const n = normalize(r); if (n !== r) changed = true; return n; });
return changed ? next : prev;
});
const id = setInterval(() => {
setRentals(prev => {
let changed = false;
const next = prev.map(r => { const n = normalize(r); if (n !== r) changed = true; return n; });
return changed ? next : prev;
});
}, 60 * 60 * 1000);
return () => clearInterval(id);
}, []);
const [protocols, setProtocols] = useLocal('rf-protocols', SEED_PROTOCOLS);
const [quickTexts, setQuickTexts] = useLocal('rf-quicktexts-v2', SEED_QUICKTEXTS);
const [events, setEvents] = useLocal('rf-events-v2', []);
const [genDocs, setGenDocs] = useLocal('rf-gendocs', {}); // { rentalId: [...] }
const [tasks, setTasks] = useLocal('rf-tasks', { items: [], autoDone: {} }); // manual + auto completion
const [logistik, setLogistik] = useLocal('rf-logistik', window.DEFAULT_LOGISTIK || {});
// Expose so fillTemplate (defined in module scope) can read live values
React.useEffect(() => { window.__rfLogistik = logistik; }, [logistik]);
const [emails, setEmails] = useLocal('rf-emails-v1', []);
// Finance — single source of truth, shared with Home dashboard
const [transactions, setTransactions] = useLocal('rf-transactions', []);
// First-boot timestamp so charts can start “here” instead of from January
const [installedAt, setInstalledAt] = useLocal('rf-installed-at', '');
React.useEffect(() => {
if (!installedAt) setInstalledAt(new Date().toISOString());
}, []);
const [company, setCompany] = useLocal('rf-company', window.DEFAULT_COMPANY || {});
const [blockedCustomers, setBlockedCustomers] = useLocal('rf-blocked-customers', []);
const [search, setSearch] = useState(false); // global search overlay
const [profileOpen, setProfileOpen] = useState(false); // global Firmenprofil/Einstellungen
const [quickRental, setQuickRental] = useState(false); // new-rental sheet opened as overlay (e.g. from dashboard) — stays on current screen
const [quickRentalPrefill, setQuickRentalPrefill] = useState(null);
const showToast = (msg) => setToast(msg);
const navDirRef = React.useRef(0); // -1 / +1 directional hint for the screen entrance animation
const go = (screen, params = {}) => {
// "Quick" new rental: open the sheet as an overlay over the CURRENT screen
// instead of navigating into the Dokumente tab, so the user returns here on close/save.
if (screen === 'doc' && params.newRental && params.origin === 'quick') {
setQuickRentalPrefill(params.defaultStart ? { start: params.defaultStart, end: params.defaultStart } : null);
setQuickRental(true);
return;
}
navDirRef.current = 0; setRoute({ screen, params });
};
// Save handler for the overlay sheet — adds the rental without leaving the current screen.
const addRentalQuick = (r) => {
setRentals((prev) => [r, ...prev]);
const its = r.items && r.items.length ? r.items : r.equipmentId ? [{ equipmentId: r.equipmentId }] : [];
if (its.length) {
const ids = new Set(its.map((it) => it.equipmentId));
setEquipment((prev) => prev.map((e) => ids.has(e.id) ? { ...e, uses: (Number(e.uses) || 0) + 1 } : e));
}
setQuickRental(false);
showToast(`„${r.tenantName}“ angelegt`);
};
// Back-compat: toggleDark flips between Hell and Dunkel (used by older entry points).
const toggleDark = () => { setThemeId((id) => id === 'dark' ? 'light' : 'dark'); };
// Swipe between the 5 main tabs
const TAB_ORDER = ['home', 'cal', 'eq', 'eur', 'doc'];
const [docHomeNonce, setDocHomeNonce] = useState(0);
const goTab = (id) => {
// Re-tapping the Akten tab while already there returns to its index landing.
if (id === 'doc' && route.screen === 'doc') setDocHomeNonce((n) => n + 1);
const cur = TAB_ORDER.indexOf(tabActive);
const nx = TAB_ORDER.indexOf(id);
navDirRef.current = (cur >= 0 && nx >= 0) ? Math.sign(nx - cur) : 0;
setRoute({ screen: id, params: {} });
};
const swipe = React.useRef({ x: 0, y: 0, active: false, allowed: false, dir: null });
// Block tab-swipe when the gesture starts on something that manages its own
// horizontal gesture (calendar pills / swipe-delete rows use touch-action,
// scrollers overflow-x) so we never hijack those.
const swipeBlocked = (target) => {
let el = target;
while (el && el.nodeType === 1 && el !== document.body) {
const cs = window.getComputedStyle(el);
if (cs.touchAction === 'none' || cs.touchAction === 'pan-y') return true;
if ((cs.overflowX === 'auto' || cs.overflowX === 'scroll') && el.scrollWidth > el.clientWidth + 4) return true;
if (el.getAttribute && el.getAttribute('data-noswipe') != null) return true;
el = el.parentElement;
}
return false;
};
const onSwipeDown = (e) => {
if (e.pointerType === 'mouse') { swipe.current.active = false; return; }
swipe.current = { x: e.clientX, y: e.clientY, active: true, allowed: !swipeBlocked(e.target), dir: null };
};
const onSwipeMove = (e) => {
const s = swipe.current;
if (!s.active || s.dir) return;
const dx = e.clientX - s.x, dy = e.clientY - s.y;
if (Math.abs(dx) > 12 || Math.abs(dy) > 12) s.dir = Math.abs(dx) > Math.abs(dy) ? 'h' : 'v';
};
const onSwipeUp = (e) => {
const s = swipe.current; if (!s.active) return; s.active = false;
if (!s.allowed || s.dir !== 'h') return;
if (!TAB_ORDER.includes(route.screen)) return;
const dx = e.clientX - s.x, dy = e.clientY - s.y;
if (Math.abs(dx) < 64 || Math.abs(dy) > 90) return;
const cur = TAB_ORDER.indexOf(route.screen);
const next = dx < 0 ? cur + 1 : cur - 1;
if (next < 0 || next >= TAB_ORDER.length) return;
goTab(TAB_ORDER[next]);
};
const tabActive = ({
home: 'home', detail: 'home',
cal: 'cal', eq: 'eq', log: 'eq', eur: 'eur', doc: 'doc',
})[route.screen] || 'home';
let body;
switch (route.screen) {
case 'home':
body =
setSearch(true)} toast={showToast} company={company}/>;
break;
case 'detail':
body = ;
break;
case 'cal':
body = ;
break;
case 'log':
case 'eq':
body = ;
break;
case 'eur':
body = ;
break;
case 'doc':
body = ;
break;
default:
body = ;
}
return (
setProfileOpen(true) }}>
{t.bgGradient && t.glass &&
}
{ swipe.current.active = false; }}
style={{ position: 'relative', zIndex: 1 }}>
0 ? 'rfSlideR' : navDirRef.current < 0 ? 'rfSlideL' : 'fadeIn'} 0.3s cubic-bezier(0.2,0.7,0.3,1)`,
}}>
{body}
{(navLayout === 'classic' || navLayout === 'pill' || navLayout === 'bar')
? goTab(id)} dark={dark}
variant={navLayout === 'classic' ? undefined : navLayout} showLabels={navLabels !== false}/>
: goTab(id)}
onNew={() => { setQuickRentalPrefill(null); setQuickRental(true); }}
onSearch={() => setSearch(true)}
onSettings={() => setProfileOpen(true)} />}
setToast('')}/>
setSearch(false)}
equipment={equipment} rentals={rentals} go={go}/>
setProfileOpen(false)}
company={company} setCompany={setCompany} toast={showToast}
logistik={logistik} setLogistik={setLogistik}
dark={dark} toggleDark={toggleDark}
themeId={themeId} setThemeId={setThemeId} accents={accents} setAccents={setAccents}
navLayout={navLayout} setNavLayout={setNavLayout}
navLabels={navLabels} setNavLabels={setNavLabels}
tabSettings={tabSettings} setTabSettings={setTabSettings}/>
{window.NewRentalSheet &&
setQuickRental(false)}
onSave={addRentalQuick} equipment={equipment} accessories={accessories} tabSettings={tabSettings} rentals={rentals} events={events} prefill={quickRentalPrefill}
logistik={logistik} company={company} blockedCustomers={blockedCustomers} toast={showToast} />
}
);
}
// ── Multi-item rentals (backward-compatible) ──
function getRentalItems(r) {
if (r && Array.isArray(r.items) && r.items.length) {
return r.items.map(it => ({
equipmentId: it.equipmentId,
equipmentName: it.equipmentName || '',
dailyRate: Number(it.dailyRate) || 0,
quantity: Math.max(1, Number(it.quantity) || 1),
}));
}
if (r && r.equipmentId) {
return [{
equipmentId: r.equipmentId,
equipmentName: r.equipmentName || '',
dailyRate: Number(r.dailyRate) || 0,
quantity: Math.max(1, Number(r.quantity) || 1),
}];
}
return [];
}
function rentalEquipmentLabel(r) {
const its = getRentalItems(r);
if (its.length === 0) return r.equipmentName || '';
const head = its[0].equipmentName + (its[0].quantity > 1 ? ' ×' + its[0].quantity : '');
return its.length > 1 ? head + ' +' + (its.length - 1) : head;
}
// Total a rental costs across all items, minus discount. Never negative.
const rentalQty = (r) => {
const its = getRentalItems(r);
return its[0] ? its[0].quantity : Math.max(1, Number(r.quantity) || 1);
};
const rentalSubtotal = (r) => {
const days = billableDays(r);
const its = getRentalItems(r);
const items = its.length ? its.reduce((s, it) => {
return s + days * (Number(it.dailyRate) || 0) * Math.max(1, Number(it.quantity) || 1);
}, 0) : days * (Number(r.dailyRate) || 0) * Math.max(1, Number(r.quantity) || 1);
return items + rentalAccessoryCost(r) + rentalDeliveryFee(r);
};
// Paid accessories add to the rental price: days × dailyRate × quantity.
// Accessories without a dailyRate (>0) are "zugehörig" and cost nothing.
const rentalAccessoryCost = (r) => {
const days = billableDays(r);
const accs = (r && Array.isArray(r.accessoryItems)) ? r.accessoryItems : [];
return accs.reduce((s, a) => s + days * (Number(a.dailyRate) || 0) * Math.max(1, Number(a.quantity) || 1), 0);
};
// Delivery surcharge for a rental (0 if customer self-pickup or no km set).
const rentalDeliveryFee = (r) => {
const d = r && r.delivery;
if (!d || !d.enabled) return 0;
// Manual flat fee overrides the km-based calculation when chosen.
if (d.feeMode === 'manual') {
const f = Number(String(d.manualFee).replace(',', '.'));
return Math.max(0, Math.round(f || 0));
}
const l = window.__rfLogistik || {};
const factor = d.tripFactor || l.tripFactor || 4;
const rate = l.kmRate ?? 0.7;
const pauschale = l.pauschale ?? 10;
if (window.calcDelivery) return window.calcDelivery(d.km, factor, rate, pauschale).total || 0;
const k = Math.max(0, Number(String(d.km).replace(',', '.')) || 0);
if (k === 0) return 0;
return k * factor * rate + pauschale;
};
const rentalTotal = (r) => Math.max(0, rentalSubtotal(r) - (Number(r.discount) || 0));
// Virtual finance income from rentals: every rental whose rent is paid
// (active/finished rentals auto-count; reserved ones once marked paid) is
// surfaced as a read-only "Einnahme" so the user never re-enters it manually.
const rentalIncomeTxs = (rentals = []) => {
const out = [];
for (const r of rentals) {
if (!r) continue;
const paid = (window.autoPaymentStatus
? window.autoPaymentStatus(r.status, r.paymentStatus)
: (r.status === 'aktiv' || r.status === 'abgeschlossen' ? 'bezahlt' : (r.paymentStatus || 'offen'))) === 'bezahlt';
if (!paid) continue;
const amount = rentalTotal(r);
if (!amount) continue;
out.push({
id: 'rental:' + r.id,
kind: 'in',
label: 'Vermietung · ' + (r.tenantName || 'Mieter'),
sub: window.rentalEquipmentLabel ? window.rentalEquipmentLabel(r) : (r.equipmentName || ''),
amount,
date: (r.start || todayISO()).slice(0, 10),
_rental: true,
rentalId: r.id,
});
}
return out;
};
// True when a rental/event uses delivery (Lieferung) instead of self-pickup (Abholung).
const isDelivery = (x) => !!(x && x.delivery && x.delivery.enabled);
// Dynamic outbound label: delivery → "Lieferung"/"Lieferzeitpunkt", else "Abholung".
const outLabel = (x, long) => isDelivery(x) ? (long ? 'Lieferzeitpunkt' : 'Lieferung') : 'Abholung';
Object.assign(window, {
isDelivery, outLabel,
useLocal, Pressable, Toast, Sheet, Confirm, Field, LongPressRow, RowContextMenu,
fmtDateISO, fromISO, fmtDateDE, fmtDateLong, fmtRange, daysBetween, billableDays, todayISO, todayDE, tomorrowISO,
DE_DAYS, DE_MONTHS, DE_MONTHS_SHORT,
PROTOCOL_COLORS, EVENT_COLORS, EQUIPMENT_ICONS,
fillTemplate, statusMeta, autoRentalStatus, depositMeta, autoDepositStatus, DEPOSIT_STEPS,
paymentMeta, autoPaymentStatus, PAYMENT_STEPS, maintMeta, MAINT_TYPES,
rangesOverlap, equipmentConflicts, buildReminders, eqStock,
rentalTotal, rentalSubtotal, rentalDeliveryFee, rentalAccessoryCost, rentalQty, rentalIncomeTxs,
autoRentalStatus, autoDepositStatus, autoPaymentStatus,
getRentalItems, rentalEquipmentLabel,
normalizeCustomerKey, findBlockedCustomer, upsertBlockedCustomer, removeBlockedCustomer,
});
// App wird NICHT hier gemountet, sondern vom letzten