// Desktop Mietvorgänge-Analyse — analytics modal opened from the rentals toolbar. // Focus areas (per user): Auslastung & Zeiträume, Verwendungszweck, Logistik. // ── Helpers ───────────────────────────────────────────────────────────── const _raMonthsBack = (n) => { const today = new Date(); const out = []; for (let i = n - 1; i >= 0; i--) { const d = new Date(today.getFullYear(), today.getMonth() - i, 1); out.push({ key: `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`, label: DE_MONTHS_SHORT[d.getMonth()], year: d.getFullYear(), month: d.getMonth(), isCurrent: i === 0 }); } return out; }; const _raYM = (iso) => iso ? iso.slice(0, 7) : ''; // Filter rentals by period. function _raFilterByPeriod(rentals, period) { if (period === 'all') return rentals; const today = new Date(); let cutoff; if (period === 'month') cutoff = new Date(today.getFullYear(), today.getMonth(), 1);else if (period === '3m') cutoff = new Date(today.getFullYear(), today.getMonth() - 2, 1);else if (period === '12m') cutoff = new Date(today.getFullYear(), today.getMonth() - 11, 1);else if (period === 'ytd') cutoff = new Date(today.getFullYear(), 0, 1);else return rentals; const cutoffISO = `${cutoff.getFullYear()}-${String(cutoff.getMonth() + 1).padStart(2, '0')}-01`; return rentals.filter((r) => (r.start || '') >= cutoffISO); } // Customer identity (name + phone normalized so same person counts once). function _raCustKey(r) { const n = (r.tenantName || '').trim().toLowerCase().replace(/\s+/g, ' '); const p = (r.phone || '').replace(/[^\d+]/g, ''); return p && p.length >= 4 ? p : n; } // ── Atoms (reuse pattern from equipment-analyse) ──────────────────────── function RaKpi({ label, value, sub, color, icon }) { const t = useTheme(); return (
{icon &&
}
{label}
{value}
{sub &&
{sub}
}
); } function RaSection({ title, sub, children, action }) { const t = useTheme(); return (
{title}
{sub &&
{sub}
}
{action}
{children}
); } // ── Main view ────────────────────────────────────────────────────────── function RentalsAnalyseSheet({ open, onClose, rentals, equipment, go }) { const t = useTheme(); const [tab, setTab] = useState('overview'); // overview | usage | purpose | logistics const [period, setPeriod] = useState('12m'); // month | 3m | 12m | ytd | all useEffect(() => {if (!open) {setTab('overview');setPeriod('12m');}}, [open]); if (!open) return null; const periodOpts = [['month', 'Monat'], ['3m', '3 M.'], ['12m', '12 M.'], ['ytd', 'YTD'], ['all', 'Gesamt']]; const filtered = _raFilterByPeriod(rentals, period); // ── KPI aggregates ───────────────────────────────────────────── const totalRevenue = filtered.reduce((s, r) => s + (rentalTotal(r) || 0), 0); const totalCount = filtered.length; const avgValue = totalCount > 0 ? Math.round(totalRevenue / totalCount) : 0; const avgDuration = totalCount > 0 ? Math.round(filtered.reduce((s, r) => s + Math.max(1, daysBetween(r.start, r.end)), 0) / totalCount * 10) / 10 : 0; // Unique customers — count over THIS period const custCounts = {}; filtered.forEach((r) => {const k = _raCustKey(r);if (k) custCounts[k] = (custCounts[k] || 0) + 1;}); const uniqueCust = Object.keys(custCounts).length; const returningCust = Object.values(custCounts).filter((c) => c >= 2).length; const returnPct = uniqueCust > 0 ? Math.round(returningCust / uniqueCust * 100) : 0; // Live state (always now, not period-bound) const activeNow = rentals.filter((r) => r.status === 'aktiv').length; const reservedNow = rentals.filter((r) => r.status === 'reserviert').length; // Logistics share over period const deliveryCount = filtered.filter((r) => r.delivery && r.delivery.enabled).length; const deliveryPct = totalCount > 0 ? Math.round(deliveryCount / totalCount * 100) : 0; // ── Verwendungszweck-Ranking ────────────────────────────────── const byPurpose = {}; filtered.forEach((r) => { const p = (r.purpose || 'Ohne Angabe').trim() || 'Ohne Angabe'; if (!byPurpose[p]) byPurpose[p] = { purpose: p, count: 0, revenue: 0, days: 0 }; byPurpose[p].count += 1; byPurpose[p].revenue += rentalTotal(r) || 0; byPurpose[p].days += Math.max(1, daysBetween(r.start, r.end)); }); const purposes = Object.values(byPurpose).sort((a, b) => b.count - a.count || b.revenue - a.revenue); // ── Wochentag-Heatmap (Abholtag) ────────────────────────────── // index 0=Mo … 6=So (German week) const weekdayCounts = [0, 0, 0, 0, 0, 0, 0]; const weekdayRevenue = [0, 0, 0, 0, 0, 0, 0]; filtered.forEach((r) => { if (!r.start) return; const jsDay = fromISO(r.start).getDay(); // 0=Sun … 6=Sat const idx = (jsDay + 6) % 7; // 0=Mo weekdayCounts[idx] += 1; weekdayRevenue[idx] += rentalTotal(r) || 0; }); const weekdayMax = Math.max(1, ...weekdayCounts); const weekdayLabels = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']; // ── Saisonalität: Vorgänge pro Monat im Jahresvergleich ─────── const monthBuckets = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]; // Jan..Dez const monthRevenue = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]; filtered.forEach((r) => { if (!r.start) return; const m = fromISO(r.start).getMonth(); monthBuckets[m] += 1; monthRevenue[m] += rentalTotal(r) || 0; }); const monthMax = Math.max(1, ...monthBuckets); // ── Umsatz-Verlauf (12-month rolling, bars) ─────────────────── const months = _raMonthsBack(period === 'month' ? 6 : period === '3m' ? 6 : period === '12m' ? 12 : period === 'ytd' ? new Date().getMonth() + 1 : 24); const monthlyTotal = months.map((m) => ({ ...m, value: 0, count: 0 })); rentals.forEach((r) => { const k = _raYM(r.start); const i = months.findIndex((m) => m.key === k); if (i >= 0) {monthlyTotal[i].value += rentalTotal(r) || 0;monthlyTotal[i].count += 1;} }); const maxMonthly = Math.max(1, ...monthlyTotal.map((m) => m.value)); // ── Dauer-Verteilung ────────────────────────────────────────── const durationBuckets = [ { label: '1 Tag', min: 1, max: 1, n: 0, rev: 0 }, { label: '2–3 Tage', min: 2, max: 3, n: 0, rev: 0 }, { label: '4–7 Tage', min: 4, max: 7, n: 0, rev: 0 }, { label: '> 7 Tage', min: 8, max: Infinity, n: 0, rev: 0 }]; filtered.forEach((r) => { const d = Math.max(1, daysBetween(r.start, r.end)); const b = durationBuckets.find((x) => d >= x.min && d <= x.max); if (b) {b.n += 1;b.rev += rentalTotal(r) || 0;} }); const durationMax = Math.max(1, ...durationBuckets.map((b) => b.n)); // ── Logistik aggregates ─────────────────────────────────────── const withDelivery = filtered.filter((r) => r.delivery && r.delivery.enabled); const selfPickup = filtered.filter((r) => !(r.delivery && r.delivery.enabled)); const totalKm = withDelivery.reduce((s, r) => s + (Number(r.delivery.km) || 0) * (r.delivery.tripFactor || LOGISTIK_DEFAULTS.tripFactor), 0); const avgKm = withDelivery.length > 0 ? Math.round(totalKm / withDelivery.length * 10) / 10 : 0; const deliveryRevenue = withDelivery.reduce((s, r) => s + rentalDeliveryFee(r), 0); const tripFactorMix = { 1: 0, 2: 0, 4: 0 }; withDelivery.forEach((r) => { const f = r.delivery.tripFactor || LOGISTIK_DEFAULTS.tripFactor; tripFactorMix[f] = (tripFactorMix[f] || 0) + 1; }); const longestTrip = withDelivery.reduce((mx, r) => Math.max(mx, Number(r.delivery.km) || 0), 0); // City / address aggregation const cityCounts = {}; withDelivery.forEach((r) => { const addr = (r.delivery.address || r.address || '').trim(); // pull "PLZ Ort" or last line const lines = addr.split(/\n/).map((s) => s.trim()).filter(Boolean); const last = lines[lines.length - 1] || addr; const m = last.match(/\d{5}\s+(.+)/); const city = m ? m[1] : last || 'Unbekannt'; if (!city) return; cityCounts[city] = (cityCounts[city] || 0) + 1; }); const topCities = Object.entries(cityCounts).sort((a, b) => b[1] - a[1]).slice(0, 8); // ── Tabs definition ────────────────────────────────────────── const tabs = [ ['overview', 'Übersicht', icons.bolt], ['usage', 'Zeiträume', icons.cal], ['purpose', 'Verwendung', icons.tag || icons.bell], ['logistics', 'Logistik', icons.truck]]; const card = { background: t.card, borderRadius: 14, padding: 18, border: `0.5px solid ${t.sep}` }; return (
{if (e.target === e.currentTarget) onClose();}} style={{ position: 'absolute', inset: 0, background: 'rgba(0,0,0,0.45)', backdropFilter: 'blur(4px)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 100 }}>
{/* Header */}
Mietaufträge-Analyse
{totalCount} Vorgänge im Zeitraum · {uniqueCust} Kunden · {Math.round(totalRevenue).toLocaleString('de-DE')} € Umsatz
{/* Tabs */}
{tabs.map(([k, label, ic]) => { const sel = tab === k; return ( setTab(k)} scale={0.97}>
{label}
); })}
{/* Body */}
{tab === 'overview' && <> {/* KPIs */}
{/* Umsatz-Verlauf */}
{monthlyTotal.map((m) => { const h = m.value / maxMonthly * 170; return (
0 ? 1 : 0.3, ...NUMS }}>{m.value > 0 ? Math.round(m.value) + '€' : ''}
); })}
{monthlyTotal.map((m) =>
{m.label}
)}
{/* Top Verwendungszwecke (kompakt auf Übersicht) */} {purposes.length > 0 &&
{purposes.slice(0, 5).map((p, i, arr) => { const maxC = Math.max(1, ...arr.map((x) => x.count)); const pct = p.count / maxC * 100; return (
{i + 1}
{p.purpose}
{p.count}×
{Math.round(p.revenue).toLocaleString('de-DE')} €
); })}
} } {tab === 'usage' && <> {/* Wochentag-Heatmap */}
{weekdayLabels.map((lbl, i) => { const intensity = weekdayCounts[i] / weekdayMax; const isWeekend = i >= 5; const cellColor = isWeekend ? t.accent : t.green; return (
{lbl}
0 ? `linear-gradient(180deg, ${cellColor + Math.round(Math.max(0.18, intensity) * 255).toString(16).padStart(2, '0').toUpperCase()} 0%, ${cellColor + Math.round(Math.max(0.32, intensity * 1.2) * 255).toString(16).padStart(2, '0').toUpperCase()} 100%)` : t.cardAlt, border: `0.5px solid ${t.sep}`, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '8px 4px' }}>
0 ? '#fff' : t.textTer, ...NUMS, lineHeight: 1 }}>{weekdayCounts[i]}
0 ? '#ffffffc0' : t.textTer, marginTop: 4, ...NUMS }}>{Math.round(weekdayRevenue[i]).toLocaleString('de-DE')} €
); })}
Werktag Wochenende
{/* Saisonalität */}
{monthBuckets.map((c, i) => { const h = c / monthMax * 120; return (
0 ? 1 : 0.3, ...NUMS }}>{c > 0 ? c : ''}
0 ? t.accent : t.text + '88' }} />
); })}
{DE_MONTHS_SHORT.map((m, i) =>
{m}
)}
{(() => { const peak = monthBuckets.indexOf(monthMax); if (monthMax > 0) return (
Spitzenmonat: {DE_MONTHS[peak]} mit {monthMax} Vorgängen {Math.round(monthRevenue[peak]).toLocaleString('de-DE')} €
); return null; })()}
{/* Dauer-Verteilung */}
{durationBuckets.map((b) => { const pct = b.n / durationMax * 100; const sharePct = totalCount > 0 ? Math.round(b.n / totalCount * 100) : 0; return (
{b.label}
{b.n}
{sharePct}% · {Math.round(b.rev).toLocaleString('de-DE')} €
); })}
} {tab === 'purpose' && <>
{const top = [...purposes].sort((a, b) => b.revenue - a.revenue)[0];return top ? top.purpose : '—';})()} sub={(() => {const top = [...purposes].sort((a, b) => b.revenue - a.revenue)[0];return top ? `${Math.round(top.revenue).toLocaleString('de-DE')} €` : '';})()} icon={icons.euro} color={t.green} />
#
Zweck
Anzahl
Ø Dauer
Umsatz
Ø Vorgang
{purposes.length === 0 ?
Keine Daten im Zeitraum.
: purposes.map((p, i) => { const avg = p.count > 0 ? Math.round(p.days / p.count * 10) / 10 : 0; const perVorgang = p.count > 0 ? Math.round(p.revenue / p.count) : 0; return (
{i + 1}
{p.purpose}
{p.count}×
{avg} {avg === 1 ? 'Tag' : 'Tage'}
{Math.round(p.revenue).toLocaleString('de-DE')} €
{perVorgang} €
); })}
} {tab === 'logistics' && <>
{/* Trip-factor mix */}
{TRIP_OPTIONS.map((opt) => { const n = tripFactorMix[opt.f] || 0; const pct = withDelivery.length > 0 ? Math.round(n / withDelivery.length * 100) : 0; return (
{opt.label}
{opt.sub}
{n}
{pct}% der Lieferungen
); })}
{/* Top-Lieferorte */}
{topCities.length === 0 ?
Keine Lieferadressen im Zeitraum.
: topCities.map(([city, n], i) => { const pct = n / topCities[0][1] * 100; return (
{i + 1}
{city}
{n}×
); })}
{/* Längste Touren */}
{[...withDelivery].sort((a, b) => (Number(b.delivery.km) || 0) - (Number(a.delivery.km) || 0)).slice(0, 6).map((r, i, arr) => { const km = Number(r.delivery.km) || 0; const fee = rentalDeliveryFee(r); const factor = r.delivery.tripFactor || LOGISTIK_DEFAULTS.tripFactor; return ( {go && go('rentals', { rentalId: r.id });onClose();}} scale={0.998} hoverBg={t.cardAlt}>
{r.tenantName}
{r.purpose || '—'} · {fmtRange(r.start, r.end)}
{km.toString().replace('.', ',')} km
×{factor} Fahrten
{fee.toFixed(2).replace('.', ',')} €
Liefer-Fee
); })} {withDelivery.length === 0 &&
Keine Lieferungen im Zeitraum.
}
}
); } Object.assign(window, { RentalsAnalyseSheet });