// RentFlow Manager Desktop — Theme, primitives, icons, seed data.
// Ported and refined from the mobile app's screens-light.jsx / interactive.jsx.
const { useState, useEffect, useMemo, useRef, createContext, useContext } = React;
// ─────────────────────────────────────────────────────────────
// THEME — same tokens as mobile, slightly retuned for desktop
// ─────────────────────────────────────────────────────────────
const lightTheme = {
dark: false,
bg: '#F5F5F7',
bgGradient:'linear-gradient(180deg, #EEF2F8 0%, #F5F2EC 60%, #F2F2F7 100%)',
pageBg: 'radial-gradient(ellipse at top left, #E5EAF2 0%, #D6CFC2 60%, #C2B9A8 100%)',
card: '#FFFFFF',
cardAlt: '#F7F7FA',
sidebar: 'rgba(232,235,242,0.72)',
text: '#0a0a0a',
textSec: 'rgba(60,60,67,0.62)',
textTer: 'rgba(60,60,67,0.32)',
accent: '#007AFF',
accentSoft:'rgba(0,122,255,0.12)',
onAccent: '#FFFFFF',
green: '#34C759',
greenSoft: 'rgba(52,199,89,0.14)',
orange: '#FF9500',
orangeSoft:'rgba(255,149,0,0.14)',
red: '#FF3B30',
redSoft: 'rgba(255,59,48,0.10)',
purple: '#AF52DE',
purpleSoft:'rgba(175,82,222,0.14)',
sep: 'rgba(60,60,67,0.10)',
chip: 'rgba(120,120,128,0.12)',
chipStrong:'rgba(120,120,128,0.18)',
solidBg: '#1c1c1e',
solidFg: '#ffffff',
sheetBg: '#FFFFFF',
inputBg: '#FFFFFF',
inputBorder:'rgba(60,60,67,0.18)',
scrim: 'rgba(0,0,0,0.4)',
shadowCard:'0 1px 2px rgba(0,0,0,0.04), 0 6px 20px rgba(0,0,0,0.05)',
shadowSoft:'0 1px 2px rgba(0,0,0,0.04)',
};
const darkTheme = {
dark: true,
bg: '#1C1C1E',
bgGradient:'linear-gradient(180deg, #0a0a0c 0%, #000000 60%, #000000 100%)',
pageBg: 'radial-gradient(ellipse at top left, #2a2a32 0%, #14141a 50%, #050507 100%)',
card: '#1C1C1E',
cardAlt: '#2C2C2E',
sidebar: 'rgba(28,28,32,0.78)',
text: '#FFFFFF',
textSec: 'rgba(235,235,245,0.62)',
textTer: 'rgba(235,235,245,0.32)',
accent: '#0A84FF',
accentSoft:'rgba(10,132,255,0.22)',
onAccent: '#FFFFFF',
green: '#30D158',
greenSoft: 'rgba(48,209,88,0.20)',
orange: '#FF9F0A',
orangeSoft:'rgba(255,159,10,0.20)',
red: '#FF453A',
redSoft: 'rgba(255,69,58,0.20)',
purple: '#BF5AF2',
purpleSoft:'rgba(191,90,242,0.22)',
sep: 'rgba(84,84,88,0.45)',
chip: 'rgba(120,120,128,0.28)',
chipStrong:'rgba(120,120,128,0.36)',
solidBg: '#48484A',
solidFg: '#ffffff',
sheetBg: '#1C1C1E',
inputBg: '#2C2C2E',
inputBorder:'rgba(84,84,88,0.6)',
scrim: 'rgba(0,0,0,0.6)',
shadowCard:'0 1px 2px rgba(0,0,0,0.4), 0 8px 24px rgba(0,0,0,0.35)',
shadowSoft:'0 1px 2px rgba(0,0,0,0.4)',
};
const ThemeContext = createContext(lightTheme);
const useTheme = () => useContext(ThemeContext);
// ─────────────────────────────────────────────────────────────
// Icons
// ─────────────────────────────────────────────────────────────
const Icon = ({ d, size = 17, color = 'currentColor', sw = 1.8, fill = false }) => (
);
const icons = {
home: 'M3 12L12 3l9 9M5 10v10h14V10',
cal: 'M4 6a2 2 0 012-2h12a2 2 0 012 2v14a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM4 10h16M8 2v4M16 2v4',
euro: 'M18 7a6 6 0 00-9 0M18 17a6 6 0 01-9 0M5 10h10M5 14h10',
doc: 'M6 2h8l6 6v12a2 2 0 01-2 2H6a2 2 0 01-2-2V4a2 2 0 012-2zM14 2v6h6M9 14h6M9 18h4',
bell: 'M12 2a6 6 0 016 6v4l2 3H4l2-3V8a6 6 0 016-6zM10 19a2 2 0 004 0',
chart: 'M4 20V10M10 20V4M16 20v-8M22 20H2',
plus: 'M12 5v14M5 12h14',
search: 'M11 4a7 7 0 100 14 7 7 0 000-14zM21 21l-5.2-5.2',
filter: 'M4 6h16M7 12h10M10 18h4',
back: 'M15 6l-6 6 6 6',
chev: 'M9 6l6 6-6 6',
chevDown:'M5 9l7 7 7-7',
chevUp: 'M5 15l7-7 7 7',
more: 'M5 12h.01M12 12h.01M19 12h.01',
moreV: 'M12 5h.01M12 12h.01M12 19h.01',
check: 'M5 12l5 5L20 7',
arrow: 'M5 12h14M13 5l7 7-7 7',
download:'M12 4v12M6 10l6 6 6-6M4 20h16',
edit: 'M4 20h4l11-11-4-4L4 16v4zM14 5l4 4',
trash: 'M4 6h16M9 6V4h6v2M6 6l1 14h10l1-14',
user: 'M12 12a4 4 0 100-8 4 4 0 000 8zM4 20a8 8 0 0116 0',
truck: 'M3 16h2m14 0h2M5 16a2 2 0 104 0M15 16a2 2 0 104 0M3 16V8a1 1 0 011-1h14a2 2 0 012 2v7M3 16h12',
mail: 'M3 6a1 1 0 011-1h16a1 1 0 011 1v12a1 1 0 01-1 1H4a1 1 0 01-1-1V6zM3 7l9 6 9-6',
reply: 'M9 17l-5-5 5-5M4 12h11a4 4 0 014 4v2',
star: 'M12 2.5l2.9 6 6.6.9-4.8 4.6 1.2 6.5L12 18.2 6.1 20.5l1.2-6.5L2.5 9.4l6.6-.9z',
bolt: 'M13 2L3 14h8l-1 8 10-12h-8l1-8z',
archive: 'M3 6h18v3H3zM5 9v10a1 1 0 001 1h12a1 1 0 001-1V9M9 13h6',
link: 'M10 13a5 5 0 007 0l2-2a5 5 0 00-7-7l-1 1M14 11a5 5 0 00-7 0l-2 2a5 5 0 007 7l1-1',
sun: 'M12 3v2M12 19v2M3 12h2M19 12h2M5.6 5.6l1.4 1.4M17 17l1.4 1.4M18.4 5.6L17 7M7 17l-1.4 1.4M12 8a4 4 0 100 8 4 4 0 000-8z',
moon: 'M21 12.8A8.5 8.5 0 1111.2 3 6.6 6.6 0 0021 12.8z',
building:'M3 21V7l9-4 9 4v14M3 21h18M9 21V11M15 21V11M9 7h.01M15 7h.01',
settings:'M12.22 2h-.44a2 2 0 00-2 2v.18a2 2 0 01-1 1.73l-.43.25a2 2 0 01-2 0l-.15-.08a2 2 0 00-2.73.73l-.22.38a2 2 0 00.73 2.73l.15.1a2 2 0 011 1.72v.51a2 2 0 01-1 1.74l-.15.09a2 2 0 00-.73 2.73l.22.38a2 2 0 002.73.73l.15-.08a2 2 0 012 0l.43.25a2 2 0 011 1.73V20a2 2 0 002 2h.44a2 2 0 002-2v-.18a2 2 0 011-1.73l.43-.25a2 2 0 012 0l.15.08a2 2 0 002.73-.73l.22-.38a2 2 0 00-.73-2.73l-.15-.09a2 2 0 01-1-1.74v-.5a2 2 0 011-1.74l.15-.09a2 2 0 00.73-2.73l-.22-.38a2 2 0 00-2.73-.73l-.15.08a2 2 0 01-2 0l-.43-.25a2 2 0 01-1-1.73V4a2 2 0 00-2-2zM12 15a3 3 0 100-6 3 3 0 000 6z',
fileText:'M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8l-6-6zM14 2v6h6M9 13h6M9 17h6M9 9h2',
invoice: 'M6 2h8l6 6v12a2 2 0 01-2 2H6a2 2 0 01-2-2V4a2 2 0 012-2zM14 2v6h6M8 13l8 0M8 16h6M8 10h4',
folder: 'M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2V7z',
refresh: 'M3 12a9 9 0 0115-6.7L21 8M21 3v5h-5M21 12a9 9 0 01-15 6.7L3 16M3 21v-5h5',
print: 'M6 9V2h12v7M6 18H4a2 2 0 01-2-2v-5a2 2 0 012-2h16a2 2 0 012 2v5a2 2 0 01-2 2h-2M6 14h12v8H6z',
send: 'M22 2L11 13M22 2l-7 20-4-9-9-4 20-7z',
warning: 'M12 9v4M12 17h.01M10.3 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z',
clock: 'M12 21a9 9 0 100-18 9 9 0 000 18zM12 7v5l3 2',
pin: 'M12 22s-7-6-7-12a7 7 0 1114 0c0 6-7 12-7 12zM12 11a2 2 0 100-4 2 2 0 000 4z',
upload: 'M12 16V4M6 10l6-6 6 6M4 20h16',
camera: 'M3 8a2 2 0 012-2h2l1.5-2h7L17 6h2a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V8zM12 17a3.5 3.5 0 100-7 3.5 3.5 0 000 7z',
external:'M14 3h7v7M21 3l-9 9M19 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h6',
};
// ─────────────────────────────────────────────────────────────
// Equipment glyphs (solid, filled)
// ─────────────────────────────────────────────────────────────
function EqGlyph({ kind = 'speaker', size = 26, color = '#1c1c1e' }) {
const common = { width: size, height: size, viewBox: '0 0 24 24' };
switch (kind) {
case 'speaker':
return ;
case 'mic':
return ;
case 'mixer':
return ;
case 'trailer':
return ;
case 'light':
return ;
case 'cable':
return ;
case 'case':
return ;
default:
return ;
}
}
// Equipment avatar tile (photo or glyph on tinted square)
function EqAvatar({ item = {}, size = 44, radius = 11 }) {
const t = useTheme();
const tileFill = t.dark ? '#2C2C2E' : '#EFEFF4';
const glyphInk = t.dark ? 'rgba(235,235,245,0.85)' : '#1c1c1e';
const tile = { width: size, height: size, borderRadius: radius, flexShrink: 0,
display: 'flex', alignItems: 'center', justifyContent: 'center', overflow: 'hidden',
background: tileFill };
if (item.photo) return

;
if (item.emoji) return {item.emoji}
;
return
;
}
// ─────────────────────────────────────────────────────────────
// Pressable (hover + active scale, like a Mac button)
// ─────────────────────────────────────────────────────────────
function Pressable({ children, onClick, style = {}, scale = 0.98, disabled, hoverBg }) {
const [pressed, setPressed] = useState(false);
const [hover, setHover] = useState(false);
return (
!disabled && setPressed(true)}
onPointerUp={() => setPressed(false)}
onPointerLeave={() => { setPressed(false); setHover(false); }}
onPointerEnter={() => setHover(true)}
onClick={disabled ? undefined : onClick}
style={{
cursor: disabled ? 'default' : 'pointer',
transform: pressed ? `scale(${scale})` : 'scale(1)',
transition: 'transform 0.12s cubic-bezier(0.2,0.7,0.3,1), background 0.12s',
opacity: disabled ? 0.45 : 1,
background: hover && hoverBg ? hoverBg : undefined,
...style,
}}
>{children}
);
}
// ─────────────────────────────────────────────────────────────
// Date helpers
// ─────────────────────────────────────────────────────────────
const DE_DAYS = ['Sonntag','Montag','Dienstag','Mittwoch','Donnerstag','Freitag','Samstag'];
const DE_DAYS_SHORT = ['So','Mo','Di','Mi','Do','Fr','Sa'];
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;
return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
};
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) => Math.round((fromISO(e) - fromISO(s)) / 86400000) + 1;
const todayISO = () => fmtDateISO(new Date(2026, 4, 20));
const tomorrowISO = () => { const d = fromISO(todayISO()); d.setDate(d.getDate() + 1); return fmtDateISO(d); };
// ─────────────────────────────────────────────────────────────
// Seed data (verbatim from interactive.jsx)
// ─────────────────────────────────────────────────────────────
const SEED_EQUIPMENT = [
{ id: 'jbl', name: 'JBL EON 712', cat: 'Aktivlautsprecher', sub: '1.300 W · 12″ · Bluetooth', kind: 'Tontechnik', qty: 2, repairQty: 0, price: 45, util: 78, uses: 42, ic: 'speaker', accessories: ['Boxenständer', 'Stromkabel 5m', 'Speakon-Kabel'],
maint: [{ id: 'm-jbl1', type: 'service', 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, 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: 'service', 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, 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, 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, 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, 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', paymentStatus: 'bezahlt' },
{ 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', startTime: '17:00', endTime: '12:00',
dailyRate: 30, deposit: 80, depositStatus: 'erhalten', paymentStatus: 'bezahlt' },
{ 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', paymentStatus: 'offen' },
{ id: 'r7', status: 'reserviert', tenantName: 'Lena Wagner', address: 'Seestr. 9\n82211 Herrsching', email: 'lena.wagner@gmail.com', phone: '+49 170 223 3445', idCard: '',
equipmentId: 'jbl', equipmentName: 'JBL EON 712', purpose: 'Hochzeit',
start: '2026-06-13', end: '2026-06-14', startTime: '14:00', endTime: '10:00',
dailyRate: 45, deposit: 200, depositStatus: 'offen', paymentStatus: 'offen' },
{ 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', paymentStatus: 'bezahlt' },
{ 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', paymentStatus: 'bezahlt' },
{ 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', paymentStatus: 'bezahlt' },
];
// Seed archived documents — invoices, contracts, protocols, etc. tied to rentals.
const SEED_DOCS = [
// Abgeschlossen — TSV Sportverein (r3)
{ id: 'd-r3-1', kind: 'vertrag', format: 'pdf', title: 'Mietvertrag MV-2026-003', rentalId: 'r3', templateId: 'vertrag', number: 'MV-2026-003', createdISO: '2026-04-24' },
{ id: 'd-r3-2', kind: 'rechnung', format: 'pdf', title: 'Rechnung RE-2026-003', rentalId: 'r3', templateId: 'rechnung', number: 'RE-2026-003', createdISO: '2026-04-27' },
{ id: 'd-r3-3', kind: 'protokoll',format: 'pdf', title: 'Übergabeprotokoll', rentalId: 'r3', templateId: 'protokoll',createdISO: '2026-04-26' },
// Abgeschlossen — Petra Schmidt (r4)
{ id: 'd-r4-1', kind: 'vertrag', format: 'pdf', title: 'Mietvertrag MV-2026-004', rentalId: 'r4', templateId: 'vertrag', number: 'MV-2026-004', createdISO: '2026-05-14' },
{ id: 'd-r4-2', kind: 'rechnung', format: 'pdf', title: 'Rechnung RE-2026-004', rentalId: 'r4', templateId: 'rechnung', number: 'RE-2026-004', createdISO: '2026-05-15' },
// Abgeschlossen — Andreas Müller / Anhänger (r5)
{ id: 'd-r5-1', kind: 'rechnung', format: 'pdf', title: 'Rechnung RE-2026-005', rentalId: 'r5', templateId: 'rechnung', number: 'RE-2026-005', createdISO: '2026-03-08' },
// Aktiv — Andreas Müller / JBL (r1)
{ id: 'd-r1-1', kind: 'vertrag', format: 'pdf', title: 'Mietvertrag MV-2026-001', rentalId: 'r1', templateId: 'vertrag', number: 'MV-2026-001', createdISO: '2026-05-17' },
{ id: 'd-r1-2', kind: 'rechnung', format: 'pdf', title: 'Rechnung RE-2026-001', rentalId: 'r1', templateId: 'rechnung', number: 'RE-2026-001', createdISO: '2026-05-18' },
{ id: 'd-r1-3', kind: 'reservierung', format: 'text', title: 'Reservierungsbestätigung (WhatsApp)', rentalId: 'r1', templateId: 'reservation', createdISO: '2026-05-12' },
// Aktiv — Familie Bauer (r6)
{ id: 'd-r6-1', kind: 'vertrag', format: 'pdf', title: 'Mietvertrag MV-2026-006', rentalId: 'r6', templateId: 'vertrag', number: 'MV-2026-006', createdISO: '2026-05-18' },
// Reserviert — Konstantin Klein (r2)
{ id: 'd-r2-1', kind: 'reservierung', format: 'text', title: 'Reservierungsbestätigung (WhatsApp)', rentalId: 'r2', templateId: 'reservation', createdISO: '2026-05-20' },
// Reserviert — Lena Wagner (r7)
{ id: 'd-r7-1', kind: 'reservierung', format: 'text', title: 'Reservierungsbestätigung (WhatsApp)', rentalId: 'r7', templateId: 'reservation', createdISO: '2026-05-21' },
];
const SEED_EVENTS = [
{ id: 'g1', title: 'Werkstatt-Termin Anhänger', equipmentId: 'tr750', 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 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: 'r7',
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,
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,
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',
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',
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,
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 DEFAULT_COMPANY = {
// Stammdaten
name: 'RentFlow Vermietung GbR',
owner: 'Max Mustermann',
street: 'Musterstraße 12',
city: '80331 München',
email: 'kontakt@rentflow.de',
phone: '+49 89 123 4567',
website: 'www.rentflow.de',
taxNumber: '143/256/12345',
ust: 'DE123456789',
handelsregister: '',
amtsgericht: '',
// Bank & Steuer
iban: 'DE12 3456 7890 1234 5678 90',
bic: 'BYLADEM1MUC',
bankName: 'Bayerische Landesbank',
kleinunternehmer: false,
mwstSatz: 19,
// Logistik / Lager
lagerAddress: 'Bahnhofstr. 18, 82110 Germering',
kmRate: 0.70,
pauschale: 10,
tripFactor: 4,
// Dokumenten-Defaults
invoiceScheme: 'RE-{YYYY}-{####}',
contractScheme: 'MV-{YYYY}-{####}',
paymentDays: 14,
defaultDeposit: 200,
invoiceFooter: 'Vielen Dank für Ihren Auftrag. Zahlbar ohne Abzug innerhalb der Zahlungsfrist.',
// Erinnerungen
remindContract: true,
remindContractDays: 3,
remindReturn: true,
remindMaintenance: true,
remindInvoice: true,
remindInvoiceDays: 7,
};
// ─────────────────────────────────────────────────────────────
// Status helpers
// ─────────────────────────────────────────────────────────────
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() });
const depositMeta = (s) => ({
offen: { color: '#FF9500', label: 'Kaution offen' },
bezahlt: { color: '#5856D6', label: 'Voraus bezahlt' },
erhalten: { color: '#34C759', label: 'Kaution aktiv' },
zurueckgezahlt: { color: 'rgba(60,60,67,0.55)', label: 'Kaution rückerstattet' },
}[s || 'offen']);
const autoRentalStatus = (start, end) => {
const today = todayISO();
if (!start || !end) return 'reserviert';
if (today < start) return 'reserviert';
if (today > end) return 'abgeschlossen';
return 'aktiv';
};
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));
return { total, vermietet, reparatur, verfuegbar: Math.max(0, total - vermietet - reparatur) };
}
// ── Date-range availability ─────────────────────────────────────────────────
// How many units of `equipmentId` are already booked by other rentals whose
// date range overlaps [startISO, endISO] (inclusive). `excludeRentalId` is
// skipped so editing a rental doesn't count it against itself. Abgeschlossene
// Verträge zählen nicht — die Geräte sind ja zurück.
function eqBookedInRange(equipmentId, startISO, endISO, rentals = [], excludeRentalId) {
if (!equipmentId || !startISO || !endISO) return 0;
let booked = 0;
for (const r of rentals) {
if (!r || r.id === excludeRentalId) continue;
if (r.status === 'abgeschlossen') continue;
if (!r.start || !r.end) continue;
// Overlap test: ranges intersect if neither ends before the other starts.
if (r.end < startISO || r.start > endISO) continue;
if (Array.isArray(r.items) && r.items.length) {
for (const it of r.items) {
if (it && it.equipmentId === equipmentId) booked += Math.max(1, Number(it.quantity) || 1);
}
} else if (r.equipmentId === equipmentId) {
booked += Math.max(1, Number(r.quantity) || 1);
}
}
return booked;
}
// Returns the actual list of rentals that overlap [startISO,endISO] and book
// `equipmentId`, so the conflict warning can tell the user WHERE the equipment
// is in use (not just how many units are over). One entry per rental.
function eqConflictsInRange(equipmentId, startISO, endISO, rentals = [], excludeRentalId) {
if (!equipmentId || !startISO || !endISO) return [];
const hits = [];
for (const r of (rentals || [])) {
if (!r || r.id === excludeRentalId) continue;
if (r.status === 'abgeschlossen') continue;
if (!r.start || !r.end) continue;
if (r.end < startISO || r.start > endISO) continue;
let qty = 0;
if (Array.isArray(r.items) && r.items.length) {
for (const it of r.items) {
if (it && it.equipmentId === equipmentId) qty += Math.max(1, Number(it.quantity) || 1);
}
} else if (r.equipmentId === equipmentId) {
qty = Math.max(1, Number(r.quantity) || 1);
}
if (qty > 0) hits.push({ rental: r, qty });
}
hits.sort((a, b) => (a.rental.start || '').localeCompare(b.rental.start || ''));
return hits;
}
// Combined view for a single equipment: total stock vs repair vs booked-in-range.
// Used by the rental dialog to flag conflicts before saving.
function eqAvailabilityInRange(equipment, equipmentId, startISO, endISO, rentals = [], excludeRentalId) {
const e = (equipment || []).find((x) => x && x.id === equipmentId);
if (!e) return { total: 0, booked: 0, repair: 0, available: 0, exists: false };
const total = Math.max(1, Number(e.qty) || 1);
const repair = Math.max(0, Math.min(total, Number(e.repairQty) || 0));
const booked = eqBookedInRange(equipmentId, startISO, endISO, rentals, excludeRentalId);
const available = Math.max(0, total - repair - booked);
return { total, booked, repair, available, exists: true };
}
// ── Derived from rentals: how many rentals reference this equipment? ──
function eqUses(eq, rentals = []) {
if (!eq || !eq.id) return 0;
const id = eq.id;
return rentals.filter((r) => {
if (!r) return false;
if (r.equipmentId === id) return true;
if (Array.isArray(r.items)) return r.items.some((it) => it && it.equipmentId === id);
return false;
}).length;
}
// ── Derived utilisation: this equipment's share of all rental bookings (0–100%) ──
// 1 product rented → 100%. 2 products rented equally → 50% each. Pure popularity ranking.
function eqUtil(eq, rentals = [], equipment = []) {
if (!eq || !eq.id) return 0;
const mine = eqUses(eq, rentals);
if (mine === 0) return 0;
const pool = (equipment && equipment.length) ? equipment : [eq];
const total = pool.reduce((s, x) => s + eqUses(x, rentals), 0);
return total > 0 ? Math.round((mine / total) * 100) : 0;
}
const LOGISTIK_DEFAULTS = { kmRate: 0.70, pauschale: 10, tripFactor: 4, lagerAddress: 'Bahnhofstr. 18, 82110 Germering' };
const TRIP_OPTIONS = [
{ f: 1, label: '1×', sub: 'nur Hinfahrt' },
{ f: 2, label: '2×', sub: 'hin & zurück' },
{ f: 4, label: '4×', sub: 'bringen + abholen' },
];
// ─ Geocode + route helpers (OpenStreetMap public APIs, CORS-enabled) ─
async function geocodeAddress(query) {
const url = `https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(query)}&format=json&limit=1&addressdetails=0`;
const res = await fetch(url, { headers: { 'Accept': 'application/json' } });
if (!res.ok) throw new Error('Geocoding fehlgeschlagen');
const j = await res.json();
if (!j || !j[0]) throw new Error('Adresse nicht gefunden');
return { lat: parseFloat(j[0].lat), lon: parseFloat(j[0].lon), label: j[0].display_name };
}
async function routeDistanceKm(from, to) {
const url = `https://router.project-osrm.org/route/v1/driving/${from.lon},${from.lat};${to.lon},${to.lat}?overview=false`;
const res = await fetch(url);
if (!res.ok) throw new Error('Routing fehlgeschlagen');
const j = await res.json();
if (!j.routes || !j.routes[0]) throw new Error('Keine Route gefunden');
return j.routes[0].distance / 1000; // meters → km
}
// Pure: returns the delivery price breakdown for given km / tripFactor / rate / pauschale.
function calcDelivery(km, tripFactor, rate = LOGISTIK_DEFAULTS.kmRate, pauschale = LOGISTIK_DEFAULTS.pauschale) {
const k = Math.max(0, Number(String(km).replace(',', '.')) || 0);
const f = Number(tripFactor) || 1;
if (k === 0) return { tripKm: 0, totalKm: 0, kmCost: 0, pauschale: 0, total: 0, factor: f };
const totalKm = k * f;
const kmCost = totalKm * (Number(rate) || 0);
return { tripKm: k, totalKm, kmCost, pauschale: Number(pauschale) || 0, total: kmCost + (Number(pauschale) || 0), factor: f };
}
const rentalDeliveryFee = (r) => {
if (!r || !r.delivery || !r.delivery.enabled) return 0;
return calcDelivery(r.delivery.km, r.delivery.tripFactor || LOGISTIK_DEFAULTS.tripFactor).total;
};
const rentalTotal = (r) => {
const d = daysBetween(r.start, r.end);
let base;
if (Array.isArray(r.items) && r.items.length) {
const sub = r.items.reduce((s, it) => s + (Number(it.dailyRate) || 0) * Math.max(1, Number(it.quantity) || 1), 0);
base = Math.max(0, d * sub - (Number(r.discount) || 0));
} else {
const rate = Number(r.dailyRate) || 0;
base = Math.max(0, d * rate * Math.max(1, Number(r.quantity) || 1) - (Number(r.discount) || 0));
}
return Math.round((base + rentalDeliveryFee(r)) * 100) / 100;
};
const getRentalItems = (r, equipment) => {
if (Array.isArray(r.items) && r.items.length) return r.items;
if (r.equipmentId) {
const e = equipment && equipment.find ? equipment.find((x) => x.id === r.equipmentId) : null;
return [{ equipmentId: r.equipmentId, equipmentName: (e && e.name) || r.equipmentName, quantity: Math.max(1, Number(r.quantity) || 1), dailyRate: Number(r.dailyRate) || 0 }];
}
return [];
};
// localStorage hook
function useLocal(key, init) {
const [v, setV] = useState(() => {
try { const raw = localStorage.getItem(key); return raw ? JSON.parse(raw) : init; }
catch { return init; }
});
useEffect(() => { try { localStorage.setItem(key, JSON.stringify(v)); } catch {} }, [key, v]);
return [v, setV];
}
// Avatar (initials) — for people in inbox
const AVATAR_TINTS = ['#007AFF', '#34C759', '#FF9500', '#AF52DE', '#FF2D55', '#5856D6', '#30B0C7'];
const tintFor = (s) => AVATAR_TINTS[[...(s || '')].reduce((a, c) => a + c.charCodeAt(0), 0) % AVATAR_TINTS.length];
const initials = (name) => (name || '').split(/\s+/).filter(Boolean).slice(0,2).map(w => w[0]).join('').toUpperCase();
function Avatar({ name, size = 38 }) {
const c = tintFor(name);
return {initials(name || '?')}
;
}
// ─────────────────────────────────────────────────────────────
// Common UI: Card, Tag, Button
// ─────────────────────────────────────────────────────────────
function Card({ children, style = {}, padding = 18, hoverable }) {
const t = useTheme();
return (
{children}
);
}
function Tag({ children, color, soft = true, size = 11 }) {
const t = useTheme();
const c = color || t.textSec;
return (
{children}
);
}
function Button({ children, onClick, variant = 'primary', size = 'md', icon, disabled = false, style = {} }) {
const t = useTheme();
const padding = size === 'sm' ? '7px 12px' : '10px 16px';
const fs = size === 'sm' ? 12.5 : 14;
const bg = variant === 'primary' ? t.solidBg : variant === 'accent' ? t.accent : variant === 'danger' ? (t.red || '#FF3B30') : t.chip;
const fg = variant === 'primary' ? t.solidFg : variant === 'accent' ? '#fff' : variant === 'danger' ? '#fff' : t.text;
return (
{icon && }
{children}
);
}
// Stat tile
function StatTile({ label, value, sub, color, icon, onClick }) {
const t = useTheme();
return (
{icon ? (
) : (
)}
{label}
{value}
{sub &&
{sub}
}
);
}
// Mini sparkline (svg)
function Spark({ data, color, width = 100, height = 36 }) {
if (!data || data.length === 0) return null;
const max = Math.max(1, ...data);
const step = width / (data.length - 1 || 1);
const pts = data.map((v, i) => `${i * step},${height - (v / max) * (height - 4) - 2}`).join(' ');
return (
);
}
Object.assign(window, {
lightTheme, darkTheme, ThemeContext, useTheme,
Icon, icons, EqGlyph, EqAvatar,
Pressable, Card, Tag, Button, StatTile, Spark, Avatar,
DE_DAYS, DE_DAYS_SHORT, DE_MONTHS, DE_MONTHS_SHORT,
fmtDateISO, fromISO, fmtDateDE, fmtDateLong, fmtRange, daysBetween, todayISO, tomorrowISO,
SEED_EQUIPMENT, SEED_RENTALS, SEED_EVENTS, SEED_EMAILS, SEED_DOCS, DEFAULT_COMPANY,
statusMeta, depositMeta, autoRentalStatus, eqStock, eqBookedInRange, eqAvailabilityInRange, eqConflictsInRange, eqUses, eqUtil, rentalTotal, rentalDeliveryFee, calcDelivery, LOGISTIK_DEFAULTS, TRIP_OPTIONS, geocodeAddress, routeDistanceKm, getRentalItems,
useLocal, initials, tintFor,
});