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;
};
const todayISO = () => fmtDateISO(new Date()); // real "today"
// ─────────────────────────────────────────────────────────────
// Tab bar — Home · Kalender · Equipment · Finanzen · Akten
// ─────────────────────────────────────────────────────────────
function InteractiveTabBar({ active, onChange, dark }) {
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 },
];
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 (used once, then state persists in localStorage)
// ─────────────────────────────────────────────────────────────
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 = ['#007AFF', '#34C759', '#FF9500', '#AF52DE', '#FF3B30', '#5856D6', '#FF2D55'];
const EQUIPMENT_ICONS = ['speaker', 'mic', 'cable', 'trailer'];
// Editable WhatsApp/E-Mail "Schnelltexte" — short message templates
const SEED_QUICKTEXTS = [
{ id: 'wa-res', name: 'Reservierungsbestätigung (WhatsApp)',
desc: 'Kurze Bestätigung per WhatsApp', icon: '💬', kind: 'whatsapp',
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: '✉️', kind: 'email',
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: '⏰', kind: 'whatsapp',
text: 'Hallo {tenant}, kleine Erinnerung: Ihr {equipment} steht morgen für Sie bereit. Bis dann!' },
];
const fillTemplate = (tpl, rental, eqName) => {
const days = daysBetween(rental.start, rental.end);
const items = (window.getRentalItems ? window.getRentalItems(rental) : []);
const itemsSubtotal = items.length
? items.reduce((s, it) => {
const perPiece = it.isPair ? (Number(it.dailyRate) || 0) / 2 : (Number(it.dailyRate) || 0);
return s + days * perPiece * 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 || '');
return tpl
.replaceAll('{tenant}', rental.tenantName)
.replaceAll('{equipment}', equipmentList)
.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 (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: 'Abholung 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() {
const [dark, setDark] = useLocal('rf-dark', false);
const t = dark ? darkTheme : lightTheme;
// 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', dark ? '#000000' : '#F2F2F7');
}, [dark]);
const [route, setRoute] = useState({ screen: 'home', params: {} });
const [toast, setToast] = useState('');
// Lifted, persisted state
const [equipment, setEquipment] = useLocal('rf-equipment-v2', []);
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', 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 showToast = (msg) => setToast(msg);
const navDirRef = React.useRef(0); // -1 / +1 directional hint for the screen entrance animation
const go = (screen, params = {}) => { navDirRef.current = 0; setRoute({ screen, params }); };
const toggleDark = () => { setDark(d => !d); };
// Swipe between the 5 main tabs
const TAB_ORDER = ['home', 'cal', 'eq', 'eur', 'doc'];
const goTab = (id) => {
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}/>;
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) }}>
{ swipe.current.active = false; }}
style={{ position: 'relative' }}>
0 ? 'rfSlideR' : navDirRef.current < 0 ? 'rfSlideL' : 'fadeIn'} 0.3s cubic-bezier(0.2,0.7,0.3,1)`,
}}>
{body}
goTab(id)} dark={dark}/>
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}/>
);
}
// ── 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 = daysBetween(r.start, r.end);
const its = getRentalItems(r);
const items = its.length ? its.reduce((s, it) => {
const perPiece = it.isPair ? (Number(it.dailyRate) || 0) / 2 : (Number(it.dailyRate) || 0);
return s + days * perPiece * Math.max(1, Number(it.quantity) || 1);
}, 0) : days * (Number(r.dailyRate) || 0) * Math.max(1, Number(r.quantity) || 1);
return items + rentalDeliveryFee(r);
};
// 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;
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));
Object.assign(window, {
useLocal, Pressable, Toast, Sheet, Confirm, Field, LongPressRow, RowContextMenu,
fmtDateISO, fromISO, fmtDateDE, fmtDateLong, fmtRange, daysBetween, 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, rentalQty,
getRentalItems, rentalEquipmentLabel,
normalizeCustomerKey, findBlockedCustomer, upsertBlockedCustomer, removeBlockedCustomer,
});
// Wait for IndexedDB to finish loading before mounting React.
// RFDB.initPromise is already in-flight since db.js ran first.
Promise.resolve(window.RFDB ? window.RFDB.initPromise : null).then(() => {
const loading = document.getElementById('rf-loading');
if (loading) loading.style.display = 'none';
ReactDOM.createRoot(document.getElementById('root')).render();
});