// Analytik — KPIs, YoY trends, predictions
function AnalyticsPage({ data, setRoute }) {
const { properties, transactions, tenants } = data;
// Empty state — analytics needs real data to render
if (properties.length === 0 || transactions.length < 5) {
return (
Analytik
KPIs, Trends, Prognosen
📈
Noch nicht genug Daten
Sobald du einige Objekte und Buchungen erfasst hast, zeigen wir hier Cashflow-Trends, Auslastung, Prognosen und YoY-Vergleiche.
);
}
// ───── Date helpers ─────
const today = new Date(2026, 4, 1);
const monthKey = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
// YTD 2026 (Jan–Apr) vs YTD 2025 (Jan–Apr)
const inRange = (date, year, monthsCount) => {
const d = new Date(date);
return d.getFullYear() === year && d.getMonth() < monthsCount;
};
const ytdMonths = 4; // Jan–Apr
const cur = transactions.filter(t => inRange(t.date, 2026, ytdMonths));
const prev = transactions.filter(t => inRange(t.date, 2025, ytdMonths));
const sumIncome = (txns) => txns.filter(t => t.type === 'einnahme').reduce((s, t) => s + t.amount, 0);
const sumExpense = (txns) => txns.filter(t => t.type === 'ausgabe').reduce((s, t) => s + Math.abs(t.amount), 0);
const curIncome = sumIncome(cur);
const prevIncome = sumIncome(prev);
const curExpense = sumExpense(cur);
const prevExpense = sumExpense(prev);
const curNet = curIncome - curExpense;
const prevNet = prevIncome - prevExpense;
const pct = (now, then) => then === 0 ? 0 : ((now - then) / then) * 100;
// Occupancy stats
const totalUnits = properties.reduce((s, p) => s + p.units.length, 0);
const occupiedUnits = properties.reduce((s, p) => s + p.units.filter(u => u.status === 'vermietet').length, 0);
const occupancyRate = (occupiedUnits / totalUnits) * 100;
// Average rent / m²
const totalRent = properties.reduce((s, p) => s + p.units.reduce((x, u) => x + u.rent, 0), 0);
const totalArea = properties.reduce((s, p) => s + p.units.reduce((x, u) => x + u.size, 0), 0);
const rentPerSqm = totalRent / totalArea;
// Total portfolio value
const totalValue = properties.reduce((s, p) => s + p.purchasePrice, 0);
const grossYield = ((totalRent * 12) / totalValue) * 100;
// 24-month income/expense for trend chart with prediction
const histMonths = [];
for (let i = 23; i >= -7; i--) {
const d = new Date(today.getFullYear(), today.getMonth() - i, 1);
const key = monthKey(d);
const inc = transactions.filter(t => t.date.startsWith(key) && t.type === 'einnahme').reduce((s, t) => s + t.amount, 0);
const exp = transactions.filter(t => t.date.startsWith(key) && t.type === 'ausgabe').reduce((s, t) => s + Math.abs(t.amount), 0);
const isFuture = d > today;
histMonths.push({
key, label: d.toLocaleDateString('de-DE', { month: 'short' }),
year: d.getFullYear(),
date: d,
income: isFuture ? null : inc,
expense: isFuture ? null : exp,
net: isFuture ? null : inc - exp,
isFuture,
});
}
// Predictions: simple linear extrapolation + occupancy assumption
const predIncome = totalRent * 0.97; // accounting for vacancy
const avgMonthlyExp = curExpense / ytdMonths;
histMonths.forEach(m => {
if (m.isFuture) {
// assume rent stays + small seasonal variance for expense
const seasonal = m.date.getMonth() === 11 ? 1.4 : m.date.getMonth() === 6 ? 1.2 : 0.9;
m.predIncome = predIncome;
m.predExpense = avgMonthlyExp * seasonal;
m.predNet = m.predIncome - m.predExpense;
}
});
// Cashflow projection for full year 2026
const projAnnualIncome = curIncome + predIncome * (12 - ytdMonths);
const projAnnualExpense = curExpense + (avgMonthlyExp * (12 - ytdMonths) * 1.05);
const projAnnualNet = projAnnualIncome - projAnnualExpense;
// Per-property comparison
const propStats = properties.map(p => {
const cur = transactions.filter(t => t.propertyId === p.id && t.date.startsWith('2026') && t.type === 'einnahme').reduce((s, t) => s + t.amount, 0);
const prev = transactions.filter(t => t.propertyId === p.id && t.date.startsWith('2025') && t.date < '2025-05-01' && t.type === 'einnahme').reduce((s, t) => s + t.amount, 0);
const occ = p.units.filter(u => u.status === 'vermietet').length / p.units.length;
return { ...p, curIncome: cur, prevIncome: prev, change: pct(cur, prev), occupancy: occ };
});
return (
Stand 1. Mai 2026
Analytik
Trends, Vergleiche zum Vorjahr und Vorhersagen
Cashflow-Trend mit Prognose
Mai 2024 – Dez 2026 · ✦ Vorhersage
Objekte im Vergleich zum Vorjahr
Vorausschauende Hinweise
Modell-basiert · 60 Tage Horizont
);
}
// ───── KPI Grid ─────
function KpiBigGrid({ items }) {
return (
{items.map((k, i) => (
{k.label}
{k.value}
{k.prevLabel}
{!k.hideDelta && k.delta !== 0 && (
)}
))}
);
}
function DeltaBadge({ delta, negativeIsGood }) {
const positive = delta > 0;
const isGood = negativeIsGood ? !positive : positive;
const arrow = positive ? '↑' : '↓';
return (
{arrow} {Math.abs(delta).toFixed(1)}%
);
}
// ───── Trend Chart with Prediction ─────
function TrendChart({ data, today }) {
const W = 1200, H = 320;
const pad = { l: 50, r: 30, t: 16, b: 36 };
const cw = W - pad.l - pad.r, ch = H - pad.t - pad.b;
const allVals = data.flatMap(d => [d.income, d.expense, d.predIncome, d.predExpense].filter(v => v != null));
const max = Math.max(...allVals) * 1.1;
const niceMax = Math.ceil(max / 5000) * 5000;
const ticks = [0, 0.25, 0.5, 0.75, 1].map(t => Math.round(niceMax * t));
const xFor = (i) => pad.l + (cw / (data.length - 1)) * i;
const yFor = (v) => pad.t + ch - (v / niceMax) * ch;
const buildLine = (key) => data.map((d, i) => {
if (d[key] == null) return null;
return `${i === 0 || data[i - 1][key] == null ? 'M' : 'L'} ${xFor(i)} ${yFor(d[key])}`;
}).filter(Boolean).join(' ');
const incPath = buildLine('income');
const expPath = buildLine('expense');
const predIncPath = buildLine('predIncome');
const predExpPath = buildLine('predExpense');
// Find today line
const todayIdx = data.findIndex(d => d.isFuture);
const todayX = todayIdx > 0 ? xFor(todayIdx - 0.5) : null;
return (
Einnahmen
Ausgaben
Prognose
);
}
// ───── Projection Card ─────
function ProjectionCard({ curIncome, curExpense, projAnnualIncome, projAnnualExpense, projAnnualNet, ytdMonths }) {
const completion = (ytdMonths / 12) * 100;
return (
Erwarteter Jahresnetto 2026
{fmtEUR(projAnnualNet)}
Hochrechnung mit aktueller Auslastung
YTD ({completion.toFixed(0)} %)
Prognose Rest
);
}
function KvLine({ k, v, sub, tone }) {
return (
);
}
// ───── Distribution Panel ─────
function DistributionPanel({ transactions }) {
const cats = {};
transactions.filter(t => t.date.startsWith('2026') && t.type === 'ausgabe').forEach(t => {
cats[t.category] = (cats[t.category] || 0) + Math.abs(t.amount);
});
const total = Object.values(cats).reduce((s, v) => s + v, 0);
const entries = Object.entries(cats).sort((a, b) => b[1] - a[1]);
// Donut chart
const R = 60, r = 36;
let cumAngle = -Math.PI / 2;
const colors = ['var(--accent)', 'var(--ink)', 'var(--ink-3)', 'var(--warn)', 'var(--ink-4)', 'var(--ink-5)'];
const segs = entries.map(([cat, val], i) => {
const angle = (val / total) * Math.PI * 2;
const startA = cumAngle;
cumAngle += angle;
const endA = cumAngle;
const x1 = 75 + R * Math.cos(startA), y1 = 75 + R * Math.sin(startA);
const x2 = 75 + R * Math.cos(endA), y2 = 75 + R * Math.sin(endA);
const x3 = 75 + r * Math.cos(endA), y3 = 75 + r * Math.sin(endA);
const x4 = 75 + r * Math.cos(startA), y4 = 75 + r * Math.sin(startA);
const large = angle > Math.PI ? 1 : 0;
const path = `M ${x1} ${y1} A ${R} ${R} 0 ${large} 1 ${x2} ${y2} L ${x3} ${y3} A ${r} ${r} 0 ${large} 0 ${x4} ${y4} Z`;
return { path, cat, val, color: colors[i % colors.length] };
});
return (
{segs.map((s, i) => (
{s.cat}
{fmtEUR(s.val)}
{((s.val / total) * 100).toFixed(0)}%
))}
);
}
// ───── YoY Table ─────
function YoYTable({ propStats, setRoute }) {
const maxIncome = Math.max(...propStats.map(p => p.curIncome));
return (
| Objekt |
Auslastung |
Einn. YTD 2026 |
Einn. YTD 2025 |
Veränderung |
Trend |
{propStats.map(p => (
setRoute({ page: 'properties', propertyId: p.id })}>
|
{p.name}
{p.units.length} Einheiten
|
{(p.occupancy * 100).toFixed(0)}%
|
{fmtEUR(p.curIncome)} |
{fmtEUR(p.prevIncome)} |
|
|
))}
);
}
function MiniSpark({ change }) {
const positive = change > 0;
const points = positive
? "0,16 8,14 16,12 24,10 32,6 40,4"
: "0,4 8,5 16,8 24,11 32,13 40,15";
return (
);
}
// ───── Predictions list ─────
const PREDICTIONS = [
{
title: 'Wohnung 4 Lindenstraße — Neuvermietung Q3',
prob: 0.82,
impact: '+1.080 €/Monat',
horizon: '60 Tage',
body: 'Modell schätzt Vermarktungsdauer auf 38 Tage basierend auf Vergleichsangeboten. Bei zügiger Renovierung Erstbezug Juli realistisch.',
tone: 'pos',
},
{
title: 'Mieter Maier (Stadtpark 7 W3) — kein Anschlussvertrag',
prob: 0.91,
impact: '−1.290 €/Monat',
horizon: '31. Juli',
body: 'Kündigung bereits vorliegend. Neuvermietungsdauer in München Westend liegt bei 14–28 Tagen.',
tone: 'neg',
},
{
title: 'Heizölpreis-Anstieg erwartet',
prob: 0.66,
impact: '+340 € Heizkosten Q4',
horizon: '120 Tage',
body: 'Marktdaten zeigen +12 % Großhandelspreis seit März. Frühbestellung im Juni statistisch günstigster Zeitpunkt.',
tone: 'warn',
},
{
title: 'Café Mira Indexmiete-Anpassung',
prob: 0.95,
impact: '+55 €/Monat ab Sept.',
horizon: 'Sept. 2026',
body: 'VPI-Anpassung gemäß Mietvertrag automatisch. Anpassung ankündigen empfohlen 2 Monate vorab.',
tone: 'pos',
},
];
function PredictionsList() {
return (
{PREDICTIONS.map((p, i) => (
{p.impact}
Wahrscheinlichkeit
{Math.round(p.prob * 100)} %
{p.title}
{p.body}
))}
);
}
function ProbBar({ prob }) {
return (
);
}
Object.assign(window, { AnalyticsPage });