// 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 (
);
}
function RaSection({ title, sub, children, action }) {
const t = useTheme();
return (
);
}
// ── 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 (
{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 (
);
})}
{/* 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 });