// Tenants, Calendar, Maintenance, Reports
function TenantsPage({ data, route, setRoute }) {
const { properties, tenants, store } = data;
const [showAdd, setShowAdd] = React.useState(false);
const [editing, setEditing] = React.useState(null);
const [deleting, setDeleting] = React.useState(null);
// If route carries tenantId → render detail view
if (route?.tenantId) {
return ;
}
const allTenants = Object.values(tenants);
// Build a map of tenant_id → assigned unit/property (if any)
const assignmentByTenant = {};
for (const p of properties) {
for (const u of p.units) {
if (u.tenant) assignmentByTenant[u.tenant] = { unit: u, property: p };
}
}
return (
Mieter
{allTenants.length} {allTenants.length === 1 ? 'Mieter' : 'Mieter'} insgesamt
setShowAdd(false)} store={store} initial={editing} />
setDeleting(null)}
onConfirm={async () => { await store.deleteTenant(deleting.id); }}
title="Mieter löschen"
message={`„${deleting?.name}" endgültig löschen? Wohnungen werden auf Leerstand gesetzt.`}
confirmLabel="Endgültig löschen"
/>
{allTenants.length === 0 ? (
👥
Noch keine Mieter
Lege deinen ersten Mieter an. Du kannst ihn dann einer Wohnung zuordnen.
) : (
| Mieter |
Objekt / Einheit |
Mieter seit |
Vertragsende |
Kaution |
|
{allTenants.map(tenant => {
const a = assignmentByTenant[tenant.id];
return (
setRoute({ page: 'tenants', tenantId: tenant.id })}>
{tenant.name.split(' ').map(s => s[0]).slice(0, 2).join('')}
{tenant.name}
{tenant.email || tenant.phone || ''}
|
e.stopPropagation()}>
{a ? (
) : — nicht zugeordnet —}
|
{tenant.since ? fmtDate(tenant.since) : '—'} |
{tenant.until ? {fmtDate(tenant.until)} : {a ? 'unbefristet' : '—'}} |
{tenant.deposit ? fmtEUR(tenant.deposit) : '—'} |
e.stopPropagation()} style={{ whiteSpace: 'nowrap' }}>
|
);
})}
)}
);
}
// ───── Tenant Detail Page ─────
function TenantDetail({ data, tenantId, setRoute }) {
const { tenants, properties, transactions, store } = data;
const [showEdit, setShowEdit] = React.useState(false);
const [showDelete, setShowDelete] = React.useState(false);
const tenant = tenants[tenantId];
if (!tenant) return ;
// Find unit + property
let unit = null, property = null;
for (const p of properties) {
const u = p.units.find(u => u.tenant === tenantId);
if (u) { unit = u; property = p; break; }
}
// Cloud documents linked to this tenant's unit
const cloudDocs = useDocuments(unit ? { propertyId: property.id, unitId: unit.id } : {});
// Transactions with this tenant: filter by unit_id (rent payments) OR counterparty matches name
const nameNorm = tenant.name.toLowerCase();
const tenantTxns = transactions.filter(t =>
(unit && t.unitId === unit.id) ||
(t.counterparty || '').toLowerCase().includes(nameNorm)
);
const incomeTxns = tenantTxns.filter(t => t.type === 'einnahme');
const expenseTxns = tenantTxns.filter(t => t.type === 'ausgabe');
const totalIncome = incomeTxns.reduce((s, t) => s + t.amount, 0);
const totalExpense = expenseTxns.reduce((s, t) => s + Math.abs(t.amount), 0);
const netRevenue = totalIncome - totalExpense;
// Years as tenant
const sinceDate = tenant.since ? new Date(tenant.since) : null;
const untilDate = tenant.until ? new Date(tenant.until) : null;
const refDate = untilDate || new Date();
const yearsAsTenant = sinceDate ? ((refDate - sinceDate) / (1000 * 60 * 60 * 24 * 365.25)).toFixed(1) : null;
const isActive = !untilDate || untilDate > new Date();
// Rent saldo: expected rent (months × Kaltmiete) vs actual income
let expectedRent = 0;
if (unit && sinceDate) {
const monthsActive = Math.max(0, Math.floor((refDate - sinceDate) / (1000 * 60 * 60 * 24 * 30.44)));
expectedRent = monthsActive * (unit.rent || 0);
}
const rentSaldo = totalIncome - expectedRent;
// Birthday gimmick: days until next birthday
let bdayInfo = null;
if (tenant.birthdate) {
const bd = new Date(tenant.birthdate);
const today = new Date(); today.setHours(0,0,0,0);
const next = new Date(today.getFullYear(), bd.getMonth(), bd.getDate());
if (next < today) next.setFullYear(next.getFullYear() + 1);
const days = Math.round((next - today) / (1000 * 60 * 60 * 24));
const ageNext = next.getFullYear() - bd.getFullYear();
bdayInfo = { days, ageNext, dateNext: next };
}
// Contract end alert: <90 days remaining
let contractAlert = null;
if (untilDate && untilDate > new Date()) {
const days = Math.round((untilDate - new Date()) / (1000 * 60 * 60 * 24));
if (days <= 90) contractAlert = { days };
}
return (
setShowEdit(false)} store={store} initial={tenant} />
setShowDelete(false)}
onConfirm={async () => { await store.deleteTenant(tenant.id); setRoute({ page: 'tenants' }); }}
title="Mieter löschen"
message={`„${tenant.name}" endgültig löschen? Wohnung wird auf Leerstand gesetzt.`}
confirmLabel="Endgültig löschen"
/>
{tenant.name.split(/\s+/).map(s => s[0]).filter(Boolean).slice(0, 2).join('')}
{tenant.name}
{isActive ?
Aktiver Mieter :
Ehemaliger Mieter}
{yearsAsTenant &&
seit {yearsAsTenant} {Number(yearsAsTenant) === 1 ? 'Jahr' : 'Jahren'}}
{!unit &&
Keiner Wohnung zugeordnet}
{tenant.email &&
✉ E-Mail}
{tenant.phone &&
📞 Anrufen}
{/* Alerts */}
{(bdayInfo?.days <= 30 || contractAlert) && (
{bdayInfo && bdayInfo.days <= 30 && (
🎂 Geburtstag {bdayInfo.days === 0 ? 'heute' : bdayInfo.days === 1 ? 'morgen' : `in ${bdayInfo.days} Tagen`} — {bdayInfo.ageNext}. Geburtstag am {bdayInfo.dateNext.toLocaleDateString('de-DE')}
)}
{contractAlert && (
⚠ Vertragsende in {contractAlert.days} Tagen — Verlängerung oder Neuvermietung jetzt anstoßen
)}
)}
{/* KPI cards */}
Mieteinnahmen gesamt
{fmtEUR(totalIncome)}
{incomeTxns.length} Buchungen
Ausgaben mit Bezug
{fmtEUR(totalExpense)}
{expenseTxns.length} Buchungen (z.B. Reparaturen)
Netto-Beitrag
= 0 ? 'var(--pos)' : 'var(--neg)' }}>{fmtEUR(netRevenue)}
seit Mietbeginn
Mietsaldo
= -10 ? 'var(--pos)' : 'var(--neg)', fontSize: 22 }}>
{rentSaldo >= -10 ? '✓ ausgeglichen' : fmtEUR(rentSaldo)}
{unit ? `Soll: ${fmtEUR(expectedRent)}` : 'keine Wohnung'}
{/* Left column: Stammdaten + Wohnung */}
Stammdaten
E-Mail{tenant.email || '—'}
Telefon{tenant.phone || '—'}
Geburtsdatum{tenant.birthdate ? fmtDate(tenant.birthdate) : '—'}
Mietbeginn{tenant.since ? fmtDate(tenant.since) : '—'}
Mietende{tenant.until ? fmtDate(tenant.until) : 'unbefristet'}
Kaution{tenant.deposit ? fmtEUR(tenant.deposit) : '—'}
IBAN{tenant.iban || '—'}
Vorherige Adresse{tenant.addressPrev || '—'}
{tenant.notes && (
)}
{/* Right column: Wohnung-Karte */}
Wohnung
{unit && property ? (
{unit.label}
{property.name} · {property.address}
Etage / Lage{unit.floor || '—'}
Wohnfläche{unit.size ? `${unit.size} m²` : '—'}
Zimmer{unit.rooms || '—'}
Kaltmiete{unit.rent ? `${fmtEUR(unit.rent)}/Monat` : '—'}
) : (
Keiner Wohnung zugeordnet
Geh zu einem Objekt → Wohnung → Bearbeiten → Mieter wählen.
)}
Schnellaktionen
{unit && (
)}
{unit && (
)}
{tenant.email && (
✉ E-Mail schreiben
)}
{/* Buchungen */}
Buchungen mit diesem Mieter
{tenantTxns.length} {tenantTxns.length === 1 ? 'Eintrag' : 'Einträge'}
{tenantTxns.length === 0 ? (
Noch keine Buchungen mit Bezug zu diesem Mieter.
) : (
| Datum | Beschreibung | Kategorie | Empfänger | Betrag |
{tenantTxns.slice(0, 100).map(t => (
| {fmtDateShort(t.date)} |
{t.description || '—'} |
{t.category ? {t.category} : —} |
{t.counterparty || '—'} |
= 0 ? 'var(--pos)' : 'var(--neg)', whiteSpace: 'nowrap' }}>
{t.amount >= 0 ? '+' : ''}{fmtEUR(t.amount)}
|
))}
)}
{/* Cloud documents linked to the unit */}
{unit && cloudDocs.items.length > 0 && (
Dokumente
{cloudDocs.items.length} aus Cloud
{cloudDocs.items.map(doc => (
{doc.name}
{(doc.sizeBytes / 1024).toFixed(0)} KB · {fmtDateShort(doc.uploadedAt)}
{doc.category || 'Cloud'}
↓
))}
)}
);
}
function CalendarPage({ data }) {
const { reminders, transactions, properties, store } = data;
const today = new Date();
const [cursor, setCursor] = React.useState(new Date(today.getFullYear(), today.getMonth(), 1));
const [showForm, setShowForm] = React.useState(false);
const [editing, setEditing] = React.useState(null);
const [deleting, setDeleting] = React.useState(null);
const hasRentDue = properties?.some(p => p.units.some(u => u.rent && u.rent > 0));
const openAdd = () => { setEditing(null); setShowForm(true); };
const openEdit = (r) => { setEditing(r); setShowForm(true); };
const year = cursor.getFullYear();
const month = cursor.getMonth();
const firstDay = new Date(year, month, 1);
const startWeekday = (firstDay.getDay() + 6) % 7; // Mon=0
const daysInMonth = new Date(year, month + 1, 0).getDate();
const cells = [];
for (let i = 0; i < startWeekday; i++) {
const d = new Date(year, month, -startWeekday + i + 1);
cells.push({ date: d, muted: true });
}
for (let i = 1; i <= daysInMonth; i++) {
cells.push({ date: new Date(year, month, i), muted: false });
}
while (cells.length % 7 !== 0) {
const last = cells[cells.length - 1].date;
cells.push({ date: new Date(last.getFullYear(), last.getMonth(), last.getDate() + 1), muted: true });
}
const monthName = cursor.toLocaleDateString('de-DE', { month: 'long', year: 'numeric' });
const eventsForDate = (d) => {
const key = d.toISOString().slice(0, 10);
const evs = reminders.filter(r => r.date === key).map(r => ({ ...r, kind: 'reminder' }));
if (d.getDate() === 1 && hasRentDue) evs.push({ id: `rent-${key}`, title: 'Mieteingänge', kind: 'rent' });
return evs;
};
return (
Kalender
Fristen, Wartungen, Mietzahlungen
setShowForm(false)} store={store} properties={properties} initial={editing} />
setDeleting(null)}
onConfirm={async () => { await store.deleteReminder(deleting.id); }}
title="Termin löschen"
message={`„${deleting?.title}" endgültig löschen?`}
confirmLabel="Endgültig löschen"
/>
{['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'].map(d =>
{d}
)}
{cells.map((c, i) => {
const isToday = c.date.toDateString() === today.toDateString();
const evs = c.muted ? [] : eventsForDate(c.date);
return (
{c.date.getDate()}
{evs.map((e, j) => (
{e.title}
))}
);
})}
Anstehende Fristen
{reminders.length === 0 ? (
Noch keine Termine
Lege Erinnerungen an Wartungen, Vertragsfristen, Versicherungserneuerungen etc. an.
) : (
| Datum | Titel | Kategorie | Priorität | Objekt | |
{[...reminders].sort((a, b) => a.date.localeCompare(b.date)).map(r => {
const prop = properties.find(p => p.id === r.propertyId);
return (
| {fmtDate(r.date)} |
{r.title} |
{r.category && {r.category}} |
{r.priority === 'hoch' ? Hoch : r.priority === 'mittel' ? Mittel : Niedrig}
|
{prop ? prop.name : '—'} |
|
);
})}
)}
);
}
function MaintenancePage({ data, setRoute }) {
const { maintenance, properties, contacts, store } = data;
const [showForm, setShowForm] = React.useState(false);
const [editing, setEditing] = React.useState(null);
const [deleting, setDeleting] = React.useState(null);
const openAdd = () => { setEditing(null); setShowForm(true); };
const openEdit = (m) => { setEditing(m); setShowForm(true); };
const open = maintenance.filter(m => m.status !== 'erledigt');
return (
Wartung & Reparatur
{open.length} {open.length === 1 ? 'offener Vorgang' : 'offene Vorgänge'} · {maintenance.length} insgesamt
setShowForm(false)} store={store} properties={properties} contacts={contacts} initial={editing} />
setDeleting(null)}
onConfirm={async () => { await store.deleteMaintenance(deleting.id); }}
title="Wartungsvorgang löschen"
message={`„${deleting?.title}" endgültig löschen?`}
confirmLabel="Endgültig löschen"
/>
{maintenance.length === 0 ? (
🔧
Keine Wartungsvorgänge
Lege geplante oder akute Reparaturen an, weise sie einem Handwerker zu, verfolge den Status.
) : (
| Titel | Objekt | Beauftragt an | Status | Fällig | Kosten | |
{maintenance.map(m => {
const prop = properties.find(p => p.id === m.propertyId);
const unit = prop?.units.find(u => u.id === m.unitId);
const contact = contacts?.find(c => c.id === m.contactId);
const tenantId = unit?.tenant;
return (
| {m.title} |
{prop ? prop.name : '—'}{unit ? ` · ${unit.label}` : ''} |
{contact ? contact.company : '—'} |
{m.status === 'erledigt' ? Erledigt
: m.status === 'in-arbeit' ? In Arbeit
: Geplant}
|
{m.dueDate ? fmtDate(m.dueDate) : '—'} |
{m.cost ? fmtEUR(m.cost) : '—'} |
store.reload()}
/>
|
);
})}
)}
);
}
// ─── Steuerberater-Export Section ────────────────────────────────────
// Wählt Monat + Objekt aus, lädt ZIP mit Buchungen-CSV + verknüpften Belegen.
function SteuerExportSection({ properties, transactions }) {
const today = new Date();
const [year, setYear] = React.useState(today.getFullYear());
const [month, setMonth] = React.useState(today.getMonth() === 0 ? 12 : today.getMonth()); // default: letzter Monat
React.useEffect(() => {
if (today.getMonth() === 0 && month === 12) setYear(today.getFullYear() - 1);
}, []);
const [propertyId, setPropertyId] = React.useState('');
const ym = `${year}-${String(month).padStart(2, '0')}`;
const txInMonth = transactions.filter(t => t.date.startsWith(ym) && (!propertyId || t.propertyId === propertyId));
const withDocsCount = txInMonth.filter(t => (t.documentCount || 0) > 0).length;
const totalIncome = txInMonth.filter(t => t.type === 'einnahme').reduce((s, t) => s + t.amount, 0);
const totalExpense = txInMonth.filter(t => t.type === 'ausgabe').reduce((s, t) => s + Math.abs(t.amount), 0);
const monthLabels = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'];
const downloadUrl = window.monthlyExportZipUrl({ year, month, propertyId: propertyId || undefined });
return (
📦 Steuerberater-Mappe
ZIP mit Buchungen + Belegen + Zusammenfassung — direkt zum Versand an deinen Steuerberater
Buchungen
{txInMonth.length}
Mit Beleg
{withDocsCount} / {txInMonth.length}
Einnahmen
{fmtEUR(totalIncome)}
Ausgaben
{fmtEUR(totalExpense)}
{txInMonth.length > 0 && withDocsCount < txInMonth.length && (
⚠ {txInMonth.length - withDocsCount} ohne Beleg
)}
📥 ZIP herunterladen
);
}
// ─── Berichte / Reports ─────────────────────────────────────────────
// Filterleiste oben (Zeitraum-Presets + Custom + Objekt-Filter), KPI-Karten,
// Cashflow-Chart, gefilterte Tx-Tabelle mit Klick-zu-Bearbeiten + Löschen,
// Pro-Objekt + Kategorien-Auswertung, CSV-Export.
const RANGE_PRESETS = [
{ id: 'thisMonth', label: 'Aktueller Monat' },
{ id: 'lastMonth', label: 'Letzter Monat' },
{ id: 'thisQuarter',label: 'Aktuelles Quartal' },
{ id: 'thisYear', label: 'Aktuelles Jahr' },
{ id: 'lastYear', label: 'Letztes Jahr' },
{ id: 'last12', label: 'Letzte 12 Monate' },
{ id: 'all', label: 'Alles' },
{ id: 'custom', label: 'Benutzerdefiniert' },
];
function rangeForPreset(preset, custom) {
const today = new Date();
const y = today.getFullYear();
const m = today.getMonth();
const fmt = (d) => d.toISOString().slice(0, 10);
switch (preset) {
case 'thisMonth': return { from: fmt(new Date(y, m, 1)), to: fmt(new Date(y, m + 1, 0)) };
case 'lastMonth': return { from: fmt(new Date(y, m - 1, 1)), to: fmt(new Date(y, m, 0)) };
case 'thisQuarter':{ const qs = Math.floor(m / 3) * 3; return { from: fmt(new Date(y, qs, 1)), to: fmt(new Date(y, qs + 3, 0)) }; }
case 'thisYear': return { from: `${y}-01-01`, to: `${y}-12-31` };
case 'lastYear': return { from: `${y - 1}-01-01`, to: `${y - 1}-12-31` };
case 'last12': return { from: fmt(new Date(y, m - 11, 1)), to: fmt(today) };
case 'all': return { from: '1900-01-01', to: '2999-12-31' };
case 'custom': return custom || { from: '', to: '' };
default: return { from: `${y}-01-01`, to: `${y}-12-31` };
}
}
function downloadCsv(filename, rows) {
const escape = (v) => {
const s = v == null ? '' : String(v);
return /[";,\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
};
const csv = rows.map(r => r.map(escape).join(';')).join('\r\n');
const blob = new Blob(['' + csv], { type: 'text/csv;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = filename;
document.body.appendChild(a);
a.click();
setTimeout(() => { URL.revokeObjectURL(url); a.remove(); }, 0);
}
function SmartBarChart({ months }) {
// Empty / no data → nice empty state instead of broken axes
if (months.length === 0) {
return Keine Buchungen im gewählten Zeitraum.
;
}
const W = 1100, H = 240;
const pad = { l: 56, r: 16, t: 12, b: 28 };
const cw = W - pad.l - pad.r, ch = H - pad.t - pad.b;
const maxRaw = Math.max(0, ...months.map(d => Math.max(d.income, d.expense)));
if (maxRaw === 0) {
return Keine Werte > 0 im gewählten Zeitraum.
;
}
// Nice axis: auto-scale to a power-of-10 step
const niceStep = (raw) => {
const e = Math.pow(10, Math.floor(Math.log10(raw)));
const m = raw / e;
if (m < 1.5) return e * 0.2;
if (m < 3) return e * 0.5;
if (m < 7) return e;
return e * 2;
};
const step = niceStep(maxRaw);
const niceMax = Math.ceil(maxRaw / step) * step;
const nTicks = Math.round(niceMax / step);
const ticks = Array.from({ length: nTicks + 1 }, (_, i) => i * step);
const bw = cw / months.length;
const barW = Math.min(28, bw * 0.32);
const fmtAxis = (v) => v >= 1000 ? `${(v / 1000).toFixed(v >= 10000 ? 0 : 1)}k` : String(v);
return (
Einnahmen
Ausgaben
Netto
);
}
function ReportsPage({ data }) {
const { properties, transactions, store } = data;
const [preset, setPreset] = React.useState('thisYear');
const [custom, setCustom] = React.useState({ from: '', to: '' });
const [propertyFilter, setPropertyFilter] = React.useState('all');
const [typeFilter, setTypeFilter] = React.useState('all'); // all | einnahme | ausgabe
const [search, setSearch] = React.useState('');
const [sortBy, setSortBy] = React.useState('date-desc');
const [editingTx, setEditingTx] = React.useState(null);
const [showTxForm, setShowTxForm] = React.useState(false);
const [deletingTx, setDeletingTx] = React.useState(null);
const range = rangeForPreset(preset, custom);
// Apply filters
let filtered = transactions;
filtered = filtered.filter(t => t.date >= range.from && t.date <= range.to);
if (propertyFilter !== 'all')
filtered = filtered.filter(t => t.propertyId === propertyFilter);
if (typeFilter !== 'all')
filtered = filtered.filter(t => t.type === typeFilter);
if (search.trim()) {
const q = search.trim().toLowerCase();
filtered = filtered.filter(t =>
(t.description || '').toLowerCase().includes(q) ||
(t.counterparty || '').toLowerCase().includes(q) ||
(t.category || '').toLowerCase().includes(q)
);
}
// Sort
const sorted = [...filtered].sort((a, b) => {
switch (sortBy) {
case 'date-asc': return a.date.localeCompare(b.date);
case 'date-desc': return b.date.localeCompare(a.date);
case 'amount-asc': return Math.abs(a.amount) - Math.abs(b.amount);
case 'amount-desc':return Math.abs(b.amount) - Math.abs(a.amount);
default: return 0;
}
});
// Aggregate totals
const totalIncome = filtered.filter(t => t.type === 'einnahme').reduce((s, t) => s + t.amount, 0);
const totalExpense = filtered.filter(t => t.type === 'ausgabe').reduce((s, t) => s + Math.abs(t.amount), 0);
const totalNet = totalIncome - totalExpense;
const txCount = filtered.length;
const uncategorizedCount = filtered.filter(t => !t.category).length;
// Monthly aggregation for chart
const monthMap = new Map();
filtered.forEach(t => {
const key = t.date.slice(0, 7);
if (!monthMap.has(key)) monthMap.set(key, { key, income: 0, expense: 0 });
const e = monthMap.get(key);
if (t.type === 'einnahme') e.income += t.amount;
else e.expense += Math.abs(t.amount);
});
const months = Array.from(monthMap.values())
.sort((a, b) => a.key.localeCompare(b.key))
.map(m => ({
...m,
net: m.income - m.expense,
label: new Date(m.key + '-01').toLocaleDateString('de-DE', { month: 'short', year: '2-digit' }),
}));
// Per-property breakdown
const propRows = properties.map(p => {
const propTxns = filtered.filter(t => t.propertyId === p.id);
const inc = propTxns.filter(t => t.type === 'einnahme').reduce((s, t) => s + t.amount, 0);
const exp = propTxns.filter(t => t.type === 'ausgabe').reduce((s, t) => s + Math.abs(t.amount), 0);
const monthlyRent = p.units.reduce((s, u) => s + (u.rent || 0), 0);
const grossYield = p.purchasePrice > 0 ? ((monthlyRent * 12) / p.purchasePrice * 100) : 0;
return { ...p, income: inc, expense: exp, net: inc - exp, count: propTxns.length, grossYield };
}).filter(p => p.count > 0 || propertyFilter === p.id || propertyFilter === 'all');
const maxPropIncome = Math.max(1, ...propRows.map(p => p.income));
// Per-category breakdown (expenses only)
const catMap = {};
filtered.filter(t => t.type === 'ausgabe').forEach(t => {
const c = t.category || 'Ohne Kategorie';
catMap[c] = (catMap[c] || 0) + Math.abs(t.amount);
});
const catRows = Object.entries(catMap).sort((a, b) => b[1] - a[1]);
const maxCat = catRows.length ? catRows[0][1] : 1;
const exportCsv = () => {
const fname = `hausgut-buchungen_${range.from}_${range.to}.csv`;
const rows = [['Datum', 'Art', 'Beschreibung', 'Empfänger', 'Kategorie', 'Objekt', 'Betrag']];
sorted.forEach(t => {
const prop = properties.find(p => p.id === t.propertyId);
rows.push([
t.date,
t.type === 'einnahme' ? 'Einnahme' : 'Ausgabe',
t.description || '',
t.counterparty || '',
t.category || '',
prop ? prop.name : '',
t.amount.toFixed(2).replace('.', ','),
]);
});
downloadCsv(fname, rows);
};
// Empty: no transactions at all
if (transactions.length === 0) {
return (
Berichte
Cashflow, Rendite, Steuerübersicht
📊
Noch keine Buchungen
Erfasse Buchungen oder importiere eine Bank-CSV — dann werden hier Auswertungen erscheinen.
);
}
return (
Berichte
{txCount} {txCount === 1 ? 'Buchung' : 'Buchungen'} im gewählten Zeitraum
{ setShowTxForm(false); setEditingTx(null); }}
store={store}
properties={properties}
initial={editingTx}
/>
setDeletingTx(null)}
onConfirm={async () => { await store.deleteTransaction(deletingTx.id); }}
title="Buchung löschen"
message={`„${deletingTx?.description || deletingTx?.category || 'Buchung'}" über ${deletingTx ? fmtEUR(deletingTx.amount) : ''} endgültig löschen?`}
confirmLabel="Endgültig löschen"
/>
{/* Steuerberater-Export */}
{/* Filterleiste */}
{/* KPI-Karten */}
Einnahmen
{fmtEUR(totalIncome)}
{filtered.filter(t => t.type === 'einnahme').length} Buchungen
Ausgaben
{fmtEUR(totalExpense)}
{filtered.filter(t => t.type === 'ausgabe').length} Buchungen
Netto-Cashflow
= 0 ? 'var(--pos)' : 'var(--neg)' }}>{fmtEUR(totalNet)}
{totalIncome > 0 ? `Marge: ${((totalNet / totalIncome) * 100).toFixed(1)} %` : '—'}
Buchungen
{txCount}
{uncategorizedCount > 0 ? `${uncategorizedCount} ohne Kategorie` : 'alle kategorisiert'}
{/* Cashflow-Chart */}
{/* Transaktionen-Tabelle */}
Buchungen
{sorted.length === 0 ? (
Keine Buchungen im Filter
Lockere Zeitraum, Objekt oder Suchbegriff.
) : (
| Datum |
Beschreibung |
Empfänger |
Kategorie |
Objekt |
📎 |
Betrag |
|
{sorted.slice(0, 200).map(t => {
const prop = properties.find(p => p.id === t.propertyId);
const docCount = t.documentCount || 0;
return (
{ setEditingTx(t); setShowTxForm(true); }}>
| {fmtDateShort(t.date)} |
{t.description || '—'} |
{t.counterparty || '—'} |
{t.category ? {t.category} : —} |
{prop ? prop.name : '—'} |
0 ? `${docCount} Beleg(e) verknüpft` : 'Kein Beleg verknüpft'} style={{ textAlign: 'center', color: docCount > 0 ? 'var(--accent)' : 'var(--ink-5)' }}>
{docCount > 0 ? `📎 ${docCount}` : '—'}
|
= 0 ? 'var(--pos)' : 'var(--neg)', whiteSpace: 'nowrap' }}>
{t.amount >= 0 ? '+' : ''}{fmtEUR(t.amount)}
|
e.stopPropagation()} style={{ whiteSpace: 'nowrap' }}>
|
);
})}
{sorted.length > 200 && (
Zeige erste 200 von {sorted.length} Buchungen — engere Filter setzen für vollständige Übersicht oder CSV exportieren.
)}
)}
{/* Pro-Objekt + Kategorien */}
Auswertung pro Objekt
{propRows.length === 0 ? (
Keine Objekt-Daten im Zeitraum.
) : (
{propRows.map(p => (
{p.name}
Netto: = 0 ? 'var(--pos)' : 'var(--neg)' }}>{fmtEUR(p.net)}
{p.grossYield > 0 && ` · ${p.grossYield.toFixed(2)} %`}
{fmtEUR(p.income)}
))}
)}
Ausgaben nach Kategorie
{catRows.length === 0 ? (
Keine Ausgaben im Zeitraum.
) : (
{catRows.map(([cat, amt]) => (
))}
Summe
{fmtEUR(totalExpense)}
)}
);
}
Object.assign(window, { TenantsPage, TenantDetail, CalendarPage, MaintenancePage, ReportsPage });