// forms.jsx — modal forms for CRUD on Stammdaten (Properties, Units, Tenants, Transactions)
// Lightweight reusable Modal + form components, registered on window.
function Modal({ open, onClose, title, children, footer, wide }) {
// Esc closes the modal — preserves the keyboard escape route after we removed
// backdrop-click-to-close (which dismissed forms accidentally mid-typing).
React.useEffect(() => {
if (!open || !onClose) return;
const onKey = (e) => { if (e.key === 'Escape') onClose(); };
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [open, onClose]);
if (!open) return null;
return (
{title}
{onClose && ✕ }
{children}
{footer &&
{footer}
}
);
}
// Wrap a form to suppress Enter-submit from single-line inputs.
// Without this, hitting Enter in any text/number input submits the form,
// which closes the modal before the user finishes filling out other fields.
// Textareas still get Enter for line breaks.
const noEnterSubmit = (e) => {
if (e.key === 'Enter' && e.target.tagName === 'INPUT' && e.target.type !== 'submit') {
e.preventDefault();
}
};
function FormField({ label, children, hint, required }) {
return (
{label}{required ? ' *' : ''}
{children}
{hint &&
{hint}
}
);
}
const PROPERTY_TYPES = ['Mehrfamilienhaus', 'Doppelhaushälfte', 'Einfamilienhaus', 'Eigentumswohnung', 'Gewerbeobjekt', 'Sonstiges'];
function PropertyForm({ open, onClose, store, initial }) {
const [busy, setBusy] = React.useState(false);
const [err, setErr] = React.useState(null);
const initForm = (i) => ({
name: i?.name || '',
address: i?.address || '',
type: i?.type || 'Mehrfamilienhaus',
yearBuilt: i?.yearBuilt || '',
purchaseDate: i?.purchaseDate || '',
purchasePrice: i?.purchasePrice || '',
notes: i?.notes || '',
legalOwnerName: i?.legalOwnerName || '',
legalOwnerAddress: i?.legalOwnerAddress || '',
legalOwnerEmail: i?.legalOwnerEmail || '',
ownerIban: i?.ownerIban || '',
ownerUstId: i?.ownerUstId || '',
});
const [form, setForm] = React.useState(() => initForm(initial));
// Auto-expand the override section if any field is filled
const hasOverrides = (i) => !!(i?.legalOwnerName || i?.legalOwnerAddress || i?.legalOwnerEmail || i?.ownerIban || i?.ownerUstId);
const [showOverride, setShowOverride] = React.useState(() => hasOverrides(initial));
React.useEffect(() => {
if (open) { setForm(initForm(initial)); setShowOverride(hasOverrides(initial)); setErr(null); }
}, [open, initial]);
const update = (k, v) => setForm(f => ({ ...f, [k]: v }));
const submit = async (e) => {
e.preventDefault();
if (!form.name.trim()) return;
setBusy(true); setErr(null);
try {
const body = {
name: form.name.trim(),
address: form.address.trim() || null,
type: form.type || null,
yearBuilt: form.yearBuilt ? Number(form.yearBuilt) : null,
purchaseDate: form.purchaseDate || null,
purchasePrice: form.purchasePrice ? Number(form.purchasePrice) : null,
notes: form.notes.trim() || null,
// Per-property overrides — null means "use global settings"
legalOwnerName: form.legalOwnerName.trim() || null,
legalOwnerAddress: form.legalOwnerAddress.trim() || null,
legalOwnerEmail: form.legalOwnerEmail.trim() || null,
ownerIban: form.ownerIban.trim() || null,
ownerUstId: form.ownerUstId.trim() || null,
};
if (initial?.id) await store.updateProperty(initial.id, body);
else await store.addProperty(body);
onClose();
} catch (e) { setErr(e.message); } finally { setBusy(false); }
};
return (
Abbrechen
{busy ? 'Speichert…' : (initial ? 'Speichern' : 'Anlegen')}
>
}>
);
}
const UNIT_STATUSES = [
{ value: 'vermietet', label: 'Vermietet' },
{ value: 'leerstand', label: 'Leerstand' },
{ value: 'kündigung', label: 'In Kündigung' },
];
function UnitForm({ open, onClose, store, propertyId, tenants, initial }) {
const [busy, setBusy] = React.useState(false);
const [err, setErr] = React.useState(null);
const [form, setForm] = React.useState(() => initialUnitForm(initial));
React.useEffect(() => { if (open) { setForm(initialUnitForm(initial)); setErr(null); } }, [open, initial]);
const update = (k, v) => setForm(f => ({ ...f, [k]: v }));
const submit = async (e) => {
e.preventDefault();
if (!form.label.trim()) return;
setBusy(true); setErr(null);
try {
const body = {
propertyId,
label: form.label.trim(),
floor: form.floor.trim() || null,
size: form.size ? Number(form.size) : null,
rooms: form.rooms ? Number(form.rooms) : null,
rent: form.rent ? Number(form.rent) : 0,
status: form.status || 'leerstand',
tenantId: form.tenantId || null,
notes: form.notes.trim() || null,
};
if (initial?.id) await store.updateUnit(initial.id, body);
else await store.addUnit(body);
onClose();
} catch (e) { setErr(e.message); } finally { setBusy(false); }
};
return (
Abbrechen
{busy ? 'Speichert…' : (initial ? 'Speichern' : 'Anlegen')}
>
}>
update('label', e.target.value)} autoFocus />
update('floor', e.target.value)} placeholder="z.B. 1. OG links" />
update('size', e.target.value)} step="0.1" />
update('rooms', e.target.value)} step="0.5" />
update('rent', e.target.value)} step="10" />
update('status', e.target.value)}>
{UNIT_STATUSES.map(s => {s.label} )}
update('tenantId', e.target.value)}>
— kein Mieter (Leerstand) —
{Object.values(tenants || {}).map(t => {t.name} )}
update('notes', e.target.value)} />
{err && {err}
}
);
}
function initialUnitForm(initial) {
return {
label: initial?.label || '',
floor: initial?.floor || '',
size: initial?.size || '',
rooms: initial?.rooms || '',
rent: initial?.rent || '',
status: initial?.status || 'leerstand',
tenantId: initial?.tenantId || initial?.tenant || '',
notes: initial?.notes || '',
};
}
function TenantForm({ open, onClose, store, initial }) {
const [busy, setBusy] = React.useState(false);
const [err, setErr] = React.useState(null);
const [form, setForm] = React.useState(() => initialTenantForm(initial));
React.useEffect(() => { if (open) { setForm(initialTenantForm(initial)); setErr(null); } }, [open, initial]);
const update = (k, v) => setForm(f => ({ ...f, [k]: v }));
const submit = async (e) => {
e.preventDefault();
if (!form.name.trim()) return;
setBusy(true); setErr(null);
try {
const body = {
name: form.name.trim(),
email: form.email.trim() || null,
phone: form.phone.trim() || null,
since: form.since || null,
until: form.until || null,
deposit: form.deposit ? Number(form.deposit) : null,
notes: form.notes.trim() || null,
birthdate: form.birthdate || null,
addressPrev: form.addressPrev.trim() || null,
iban: form.iban.trim() || null,
};
if (initial?.id) await store.updateTenant(initial.id, body);
else await store.addTenant(body);
onClose();
} catch (e) { setErr(e.message); } finally { setBusy(false); }
};
return (
Abbrechen
{busy ? 'Speichert…' : (initial ? 'Speichern' : 'Anlegen')}
>
}>
update('name', e.target.value)} placeholder="Vor- und Nachname" autoFocus />
update('email', e.target.value)} />
update('phone', e.target.value)} />
update('since', e.target.value)} />
update('until', e.target.value)} />
update('deposit', e.target.value)} step="10" />
update('birthdate', e.target.value)} />
update('iban', e.target.value)} placeholder="DE…" />
update('addressPrev', e.target.value)} placeholder="Straße, PLZ, Ort" />
update('notes', e.target.value)} />
{err && {err}
}
);
}
function initialTenantForm(initial) {
return {
name: initial?.name || '',
email: initial?.email || '',
phone: initial?.phone || '',
since: initial?.since || '',
until: initial?.until || '',
deposit: initial?.deposit || '',
notes: initial?.notes || '',
birthdate: initial?.birthdate || '',
addressPrev: initial?.addressPrev || '',
iban: initial?.iban || '',
};
}
const TX_CATEGORIES_INCOME = ['Miete', 'Nebenkostenvorauszahlung', 'Kaution', 'Sonstiges'];
const TX_CATEGORIES_EXPENSE = ['Reparatur', 'Versicherung', 'Grundsteuer', 'Hausgeld', 'Verwaltung', 'Wartung', 'Modernisierung', 'Heizung/Strom', 'Sonstiges'];
function TransactionForm({ open, onClose, store, properties, initial, defaultPropertyId, defaultUnitId, defaultType = 'einnahme' }) {
const [busy, setBusy] = React.useState(false);
const [err, setErr] = React.useState(null);
const today = new Date().toISOString().slice(0, 10);
const [form, setForm] = React.useState(() => initialTxForm(initial, defaultPropertyId, defaultUnitId, defaultType, today));
React.useEffect(() => { if (open) { setForm(initialTxForm(initial, defaultPropertyId, defaultUnitId, defaultType, today)); setErr(null); } }, [open, initial, defaultPropertyId, defaultUnitId, defaultType]);
const update = (k, v) => setForm(f => ({ ...f, [k]: v }));
const propUnits = (properties || []).find(p => p.id === form.propertyId)?.units || [];
const submit = async (e) => {
e.preventDefault();
if (!form.date || !form.amount) return;
setBusy(true); setErr(null);
try {
const body = {
propertyId: form.propertyId || null,
unitId: form.unitId || null,
date: form.date,
type: form.type,
amount: form.type === 'ausgabe' ? -Math.abs(Number(form.amount)) : Math.abs(Number(form.amount)),
category: form.category || null,
description: form.description.trim() || null,
notes: form.notes.trim() || null,
};
if (initial?.id) await store.updateTransaction(initial.id, body);
else await store.addTransaction(body);
onClose();
} catch (e) { setErr(e.message); } finally { setBusy(false); }
};
const categories = form.type === 'einnahme' ? TX_CATEGORIES_INCOME : TX_CATEGORIES_EXPENSE;
return (
Abbrechen
{busy ? 'Speichert…' : (initial ? 'Speichern' : 'Anlegen')}
>
}>
update('type', 'einnahme')}>+ Einnahme
update('type', 'ausgabe')}>− Ausgabe
update('date', e.target.value)} />
update('amount', e.target.value)} step="0.01" placeholder="0,00" />
{ update('propertyId', e.target.value); update('unitId', ''); }}>
— kein Objekt —
{(properties || []).map(p => {p.name} )}
{form.propertyId && propUnits.length > 0 && (
update('unitId', e.target.value)}>
— alle Einheiten / allgemein —
{propUnits.map(u => {u.label} )}
)}
update('category', e.target.value)}>
— wählen —
{categories.map(c => {c} )}
update('description', e.target.value)} placeholder="z.B. Mieteingang, Klempner Notdienst" />
update('notes', e.target.value)} />
{initial?.id && (
)}
{!initial?.id && (
💡 Belege kannst du nach dem Speichern verknüpfen — diese Buchung dann nochmal öffnen.
)}
{err && {err}
}
);
}
// Verknüpfte Belege einer Buchung — wird nur beim Bearbeiten einer existierenden Tx angezeigt.
function TransactionReceiptsSection({ transactionId, propertyId, unitId }) {
const [linked, setLinked] = React.useState([]);
const [unlinked, setUnlinked] = React.useState([]);
const [loading, setLoading] = React.useState(true);
const [showPicker, setShowPicker] = React.useState(false);
const [pendingFiles, setPendingFiles] = React.useState(null);
const inputRef = React.useRef(null);
const reload = React.useCallback(async () => {
setLoading(true);
try {
const lk = await window.listDocumentsForTransaction(transactionId);
setLinked(lk);
// Load all docs to power the "vorhandenes Dokument verknüpfen"-Picker (only the unlinked ones)
const all = await window.listDocuments({});
setUnlinked(all.filter(d => !d.transactionId));
} catch (e) { console.error(e); }
finally { setLoading(false); }
}, [transactionId]);
React.useEffect(() => { reload(); }, [reload]);
const onUnlink = async (docId) => {
await window.linkDocumentToTransaction(docId, null);
reload();
};
const onLinkExisting = async (docId) => {
await window.linkDocumentToTransaction(docId, transactionId);
setShowPicker(false);
reload();
};
const onPickFile = async (e) => {
const files = Array.from(e.target.files || []).filter(Boolean);
if (!files.length) return;
setPendingFiles(files);
if (inputRef.current) inputRef.current.value = '';
};
return (
{loading &&
Lade…
}
{linked.length === 0 && !loading && (
Noch kein Beleg verknüpft. Lade einen hoch oder wähle einen vorhandenen.
)}
{linked.map(doc => (
📄
{doc.name}
{doc.category &&
{doc.category} }
↓
onUnlink(doc.id)} style={{ fontSize: 11, color: 'var(--neg)' }} title="Verknüpfung lösen">✕
))}
{showPicker && unlinked.length > 0 && (
Nicht verknüpfte Dokumente
{unlinked.slice(0, 50).map(doc => (
onLinkExisting(doc.id)}
style={{ display: 'flex', alignItems: 'center', width: '100%', gap: 8, padding: '6px 8px', background: 'transparent', border: 0, textAlign: 'left', cursor: 'pointer', borderRadius: 'var(--radius)' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
>
📄
{doc.name}
{doc.category && {doc.category} }
))}
)}
{pendingFiles && (
{ setPendingFiles(null); reload(); }}
onCancel={() => setPendingFiles(null)}
/>
)}
);
}
function initialTxForm(initial, defaultPropertyId, defaultUnitId, defaultType, today) {
return {
propertyId: initial?.propertyId ?? defaultPropertyId ?? '',
unitId: initial?.unitId ?? defaultUnitId ?? '',
date: initial?.date || today,
type: initial?.type || defaultType || 'einnahme',
amount: initial != null ? String(Math.abs(initial.amount || 0)) : '',
category: initial?.category || '',
description: initial?.description || '',
notes: initial?.notes || '',
};
}
const CONTACT_CATEGORIES = ['Handwerk', 'Service', 'Beratung', 'Versicherung', 'Verwaltung', 'Sonstiges'];
function ContactForm({ open, onClose, store, initial }) {
const [busy, setBusy] = React.useState(false);
const [err, setErr] = React.useState(null);
const [form, setForm] = React.useState(() => initialContactForm(initial));
React.useEffect(() => { if (open) { setForm(initialContactForm(initial)); setErr(null); } }, [open, initial]);
const update = (k, v) => setForm(f => ({ ...f, [k]: v }));
const submit = async (e) => {
e.preventDefault();
if (!form.company.trim()) return;
setBusy(true); setErr(null);
try {
const body = {
company: form.company.trim(),
contactPerson: form.contactPerson.trim() || null,
trade: form.trade.trim() || null,
category: form.category || null,
favorite: !!form.favorite,
rating: form.rating ? Number(form.rating) : null,
phone: form.phone.trim() || null,
mobile: form.mobile.trim() || null,
email: form.email.trim() || null,
website: form.website.trim() || null,
address: form.address.trim() || null,
iban: form.iban.trim() || null,
ustId: form.ustId.trim() || null,
notes: form.notes.trim() || null,
};
if (initial?.id) await store.updateContact(initial.id, body);
else await store.addContact(body);
onClose();
} catch (e) { setErr(e.message); } finally { setBusy(false); }
};
return (
Abbrechen
{busy ? 'Speichert…' : (initial ? 'Speichern' : 'Anlegen')}
>
}>
update('company', e.target.value)} placeholder="z.B. Klempnerei Heinrich GmbH" autoFocus />
update('contactPerson', e.target.value)} />
update('trade', e.target.value)} placeholder="z.B. Klempner / Sanitär" />
update('category', e.target.value)}>
— wählen —
{CONTACT_CATEGORIES.map(c => {c} )}
update('rating', e.target.value)} />
update('favorite', e.target.checked)} />
⭐ Als Favorit markieren
update('phone', e.target.value)} />
update('mobile', e.target.value)} />
update('email', e.target.value)} />
update('website', e.target.value)} placeholder="https://…" />
update('address', e.target.value)} />
update('iban', e.target.value)} placeholder="DE…" />
update('ustId', e.target.value)} />
update('notes', e.target.value)} />
{err && {err}
}
);
}
function initialContactForm(initial) {
return {
company: initial?.company || '',
contactPerson: initial?.contactPerson || '',
trade: initial?.trade || '',
category: initial?.category || '',
favorite: !!initial?.favorite,
rating: initial?.rating || '',
phone: initial?.phone || '',
mobile: initial?.mobile || '',
email: initial?.email || '',
website: initial?.website || '',
address: initial?.address || '',
iban: initial?.iban || '',
ustId: initial?.ustId || '',
notes: initial?.notes || '',
};
}
const MAINT_STATUSES = [
{ value: 'geplant', label: 'Geplant' },
{ value: 'in-arbeit', label: 'In Arbeit' },
{ value: 'erledigt', label: 'Erledigt' },
];
function MaintenanceForm({ open, onClose, store, properties, contacts, initial, defaultPropertyId, defaultUnitId }) {
const [busy, setBusy] = React.useState(false);
const [err, setErr] = React.useState(null);
const [form, setForm] = React.useState(() => initialMaintForm(initial, defaultPropertyId, defaultUnitId));
React.useEffect(() => { if (open) { setForm(initialMaintForm(initial, defaultPropertyId, defaultUnitId)); setErr(null); } }, [open, initial, defaultPropertyId, defaultUnitId]);
const update = (k, v) => setForm(f => ({ ...f, [k]: v }));
const propUnits = (properties || []).find(p => p.id === form.propertyId)?.units || [];
const submit = async (e) => {
e.preventDefault();
if (!form.title.trim()) return;
setBusy(true); setErr(null);
try {
const body = {
propertyId: form.propertyId || null,
unitId: form.unitId || null,
contactId: form.contactId || null,
title: form.title.trim(),
description: form.description.trim() || null,
status: form.status || 'geplant',
cost: form.cost ? Number(form.cost) : null,
dueDate: form.dueDate || null,
notes: form.notes.trim() || null,
};
if (initial?.id) await store.updateMaintenance(initial.id, body);
else await store.addMaintenance(body);
onClose();
} catch (e) { setErr(e.message); } finally { setBusy(false); }
};
return (
Abbrechen
{busy ? 'Speichert…' : (initial ? 'Speichern' : 'Anlegen')}
>
}>
update('title', e.target.value)} placeholder="z.B. Heizung warten, Fassade streichen" autoFocus />
update('description', e.target.value)} placeholder="Details zur Arbeit, Mängel etc." />
{ update('propertyId', e.target.value); update('unitId', ''); }}>
— kein Objekt —
{(properties || []).map(p => {p.name} )}
{form.propertyId && propUnits.length > 0 && (
update('unitId', e.target.value)}>
— Gesamtobjekt —
{propUnits.map(u => {u.label} )}
)}
update('contactId', e.target.value)}>
— noch kein Kontakt zugewiesen —
{(contacts || []).map(c => {c.company}{c.trade ? ` (${c.trade})` : ''} )}
update('status', e.target.value)}>
{MAINT_STATUSES.map(s => {s.label} )}
update('dueDate', e.target.value)} />
update('cost', e.target.value)} step="10" />
update('notes', e.target.value)} />
{err && {err}
}
);
}
function initialMaintForm(initial, defaultPropertyId, defaultUnitId) {
return {
propertyId: initial?.propertyId ?? defaultPropertyId ?? '',
unitId: initial?.unitId ?? defaultUnitId ?? '',
contactId: initial?.contactId ?? '',
title: initial?.title || '',
description: initial?.description || '',
status: initial?.status || 'geplant',
cost: initial?.cost || '',
dueDate: initial?.dueDate || '',
notes: initial?.notes || '',
};
}
const REMINDER_CATEGORIES = ['Wartung', 'Frist', 'Vertrag', 'Versicherung', 'Steuer', 'Miete', 'Sonstiges'];
const REMINDER_PRIORITIES = [
{ value: 'niedrig', label: 'Niedrig' },
{ value: 'mittel', label: 'Mittel' },
{ value: 'hoch', label: 'Hoch' },
];
function ReminderForm({ open, onClose, store, properties, initial }) {
const [busy, setBusy] = React.useState(false);
const [err, setErr] = React.useState(null);
const today = new Date().toISOString().slice(0, 10);
const [form, setForm] = React.useState(() => initialReminderForm(initial, today));
React.useEffect(() => { if (open) { setForm(initialReminderForm(initial, today)); setErr(null); } }, [open, initial]);
const update = (k, v) => setForm(f => ({ ...f, [k]: v }));
const submit = async (e) => {
e.preventDefault();
if (!form.title.trim() || !form.date) return;
setBusy(true); setErr(null);
try {
const body = {
date: form.date,
title: form.title.trim(),
category: form.category || null,
priority: form.priority || 'mittel',
propertyId: form.propertyId || null,
notes: form.notes.trim() || null,
};
if (initial?.id) await store.updateReminder(initial.id, body);
else await store.addReminder(body);
onClose();
} catch (e) { setErr(e.message); } finally { setBusy(false); }
};
return (
Abbrechen
{busy ? 'Speichert…' : (initial ? 'Speichern' : 'Anlegen')}
>
}>
update('title', e.target.value)} placeholder="z.B. Heizungsablesung, Versicherung verlängern" autoFocus />
update('date', e.target.value)} />
update('priority', e.target.value)}>
{REMINDER_PRIORITIES.map(p => {p.label} )}
update('category', e.target.value)}>
— wählen —
{REMINDER_CATEGORIES.map(c => {c} )}
update('propertyId', e.target.value)}>
— kein bestimmtes Objekt —
{(properties || []).map(p => {p.name} )}
update('notes', e.target.value)} />
{err && {err}
}
);
}
function initialReminderForm(initial, today) {
return {
date: initial?.date || today,
title: initial?.title || '',
category: initial?.category || '',
priority: initial?.priority || 'mittel',
propertyId: initial?.propertyId ?? '',
notes: initial?.notes || '',
};
}
// ── Bank-CSV-Import-Modal ────────────────────────────────────────────
function CsvImportModal({ open, onClose, store, properties, defaultPropertyId, defaultUnitId }) {
const [file, setFile] = React.useState(null);
const [propertyId, setPropertyId] = React.useState(defaultPropertyId || '');
const [unitId, setUnitId] = React.useState(defaultUnitId || '');
const [autoCategorize, setAutoCategorize] = React.useState(true);
const [stage, setStage] = React.useState('pick'); // pick | preview | result
const [busy, setBusy] = React.useState(false);
const [preview, setPreview] = React.useState(null);
const [result, setResult] = React.useState(null);
const [err, setErr] = React.useState(null);
const inputRef = React.useRef(null);
const [catProgress, setCatProgress] = React.useState(null); // {total, done, categorized, finished}
const reset = () => { setFile(null); setStage('pick'); setPreview(null); setResult(null); setErr(null); setBusy(false); setCatProgress(null); };
React.useEffect(() => { if (open) { reset(); setPropertyId(defaultPropertyId || ''); setUnitId(defaultUnitId || ''); setAutoCategorize(true); } }, [open, defaultPropertyId, defaultUnitId]);
// Poll the categorize job while it's running
React.useEffect(() => {
if (!result?.categorizeJobId || catProgress?.finished) return;
let cancelled = false;
const tick = async () => {
try {
const s = await window.getCategorizeStatus(result.categorizeJobId);
if (cancelled) return;
setCatProgress(s);
if (!s.finished) setTimeout(tick, 1500);
else if (store?.reload) store.reload(); // load fresh categories into UI
} catch (e) {
if (!cancelled) { setCatProgress(p => ({ ...(p || {}), error: e.message, finished: true })); }
}
};
tick();
return () => { cancelled = true; };
}, [result?.categorizeJobId, catProgress?.finished, store]);
const propUnits = (properties || []).find(p => p.id === propertyId)?.units || [];
const onPick = (files) => {
const f = (files && files[0]) || null;
if (f) setFile(f);
};
const runDryRun = async () => {
if (!file) return;
setBusy(true); setErr(null);
try {
const r = await window.importTransactionsCsv(file, { propertyId, unitId, dryRun: true });
setPreview(r);
setStage('preview');
} catch (e) {
setErr(e.detail || e.message);
} finally {
setBusy(false);
}
};
const runImport = async () => {
setBusy(true); setErr(null);
try {
const r = await window.importTransactionsCsv(file, { propertyId, unitId, dryRun: false, autoCategorize });
setResult(r);
setStage('result');
// Refresh global data so dashboard / unit detail show new transactions
if (store?.reload) await store.reload();
} catch (e) {
setErr(e.detail || e.message);
} finally {
setBusy(false);
}
};
return (
Abbrechen
{busy ? 'Analysiere…' : 'Vorschau'}
>
) : stage === 'preview' ? (
<>
setStage('pick')} disabled={busy}>← Zurück
{busy ? `Importiert…` : `${preview?.total || 0} Zeilen importieren`}
>
) : (
<>
{ setStage('pick'); setFile(null); }}>Weitere Datei
Schließen
>
)
}>
{stage === 'pick' && (
inputRef.current?.click()}
onDragOver={e => e.preventDefault()}
onDrop={e => { e.preventDefault(); onPick(e.dataTransfer.files); }}
style={{ padding: 24, border: '1.5px dashed var(--line-strong)', borderRadius: 'var(--radius)', background: file ? 'var(--accent-soft)' : 'var(--bg-sunken)', cursor: 'pointer', textAlign: 'center' }}
>
📊
{file ? file.name : 'CSV hierher ziehen oder klicken'}
{file ? `${(file.size / 1024).toFixed(1)} KB` : 'DKB · Sparkasse · ING · Comdirect · N26 · Volksbank'}
onPick(e.target.files)} />
{ setPropertyId(e.target.value); setUnitId(''); }}>
— kein Objekt (wird leer importiert) —
{(properties || []).map(p => {p.name} )}
{propertyId && propUnits.length > 0 && (
setUnitId(e.target.value)}>
— Gesamtobjekt —
{propUnits.map(u => {u.label} )}
)}
setAutoCategorize(e.target.checked)} />
🤖 Buchungen automatisch kategorisieren (KI analysiert Empfänger + Verwendungszweck)
Wie Duplikate erkannt werden: Jede Buchung wird per Hash aus Datum + Betrag + Empfänger + Verwendungszweck identifiziert. Doppelt importierte Buchungen werden ignoriert (nicht ersetzt).
{err &&
⚠ {err}
}
)}
{stage === 'preview' && preview && (
{preview.total} Zeilen erkannt · Trennzeichen {preview.detectedDelimiter}
Spalten erkannt: Datum, Betrag{preview.columnMapping?.counterparty !== -1 ? ', Empfänger' : ''}{preview.columnMapping?.description !== -1 ? ', Verwendungszweck' : ''}
{preview.columnMapping?.iban !== -1 ? ', IBAN' : ''}
{preview.errors?.length > 0 && (
⚠ {preview.errors.length} Zeile(n) konnten nicht gelesen werden (Datum / Betrag fehlt)
)}
Vorschau (erste 5 Zeilen)
Datum Empfänger Verwendungszweck Betrag
{preview.preview?.map((p, i) => (
{p.date}
{p.counterparty || '—'}
{p.description || '—'}
= 0 ? 'var(--pos)' : 'var(--neg)' }}>
{p.amount >= 0 ? '+' : ''}{p.amount.toFixed(2)} €
))}
{err &&
⚠ {err}
}
)}
{stage === 'result' && result && (
Neu importiert
{result.inserted}
Duplikate
{result.duplicates}
Fehler
{result.errors?.length || 0}
{result.categorizeJobId && (
{catProgress?.finished
? `🤖 ✓ Kategorisierung abgeschlossen — ${catProgress.categorized} von ${catProgress.total} kategorisiert`
: `🤖 KI kategorisiert Buchungen…`}
{catProgress ? `${catProgress.done} / ${catProgress.total}` : `… / ${result.categorizeTotal}`}
{!catProgress?.finished && (
Du kannst das Fenster schließen — die KI läuft im Hintergrund weiter, die Kategorien erscheinen automatisch im Dashboard.
)}
{catProgress?.error &&
⚠ {catProgress.error}
}
)}
✓ Import abgeschlossen. Buchungen findest du im jeweiligen Objekt unter Einnahmen / Ausgaben.
{result.errors?.length > 0 && (
Fehlerdetails
{result.errors.slice(0, 10).map((e, i) => Zeile {e.row}: {e.reason} ({String(e.value).slice(0, 40)}) )}
)}
)}
);
}
// ── AssetForm: Anlagengut für AfA-Verzeichnis ──────────────────────
const ASSET_TYPES = [
{ value: 'gebäude', label: 'Gebäude (Hauptanlage)', defaultRate: 2.0 },
{ value: 'modernisierung', label: 'Modernisierung / Erhaltung', defaultRate: 10.0 },
{ value: 'inventar', label: 'Inventar / GWG', defaultRate: 33.33 },
];
function AssetForm({ open, onClose, store, properties, initial, defaultPropertyId }) {
const [busy, setBusy] = React.useState(false);
const [err, setErr] = React.useState(null);
const initF = (i) => ({
propertyId: i?.propertyId || defaultPropertyId || '',
type: i?.type || 'modernisierung',
label: i?.label || '',
acquisitionDate: i?.acquisitionDate || new Date().toISOString().slice(0, 10),
acquisitionCost: i?.acquisitionCost || '',
afaRate: i?.afaRate || 2.0,
usefulLifeYears: i?.usefulLifeYears || '',
notes: i?.notes || '',
});
const [form, setForm] = React.useState(() => initF(initial));
React.useEffect(() => { if (open) { setForm(initF(initial)); setErr(null); } }, [open, initial, defaultPropertyId]);
const update = (k, v) => setForm(f => ({ ...f, [k]: v }));
// Auto-Suggestion für Gebäude-AfA: pre-1925 = 2.5, post-1925 = 2.0
React.useEffect(() => {
if (form.type === 'gebäude' && form.propertyId) {
const prop = (properties || []).find(p => p.id === form.propertyId);
if (prop?.yearBuilt) {
const suggestedRate = prop.yearBuilt < 1925 ? 2.5 : 2.0;
if (Math.abs(form.afaRate - suggestedRate) > 0.01) update('afaRate', suggestedRate);
}
}
}, [form.type, form.propertyId]);
const submit = async (e) => {
e.preventDefault();
if (!form.label.trim() || !form.acquisitionDate || !form.acquisitionCost) return;
setBusy(true); setErr(null);
try {
const body = {
propertyId: form.propertyId || null,
type: form.type,
label: form.label.trim(),
acquisitionDate: form.acquisitionDate,
acquisitionCost: Number(form.acquisitionCost),
afaRate: Number(form.afaRate),
usefulLifeYears: form.usefulLifeYears ? Number(form.usefulLifeYears) : null,
notes: form.notes.trim() || null,
};
if (initial?.id) await store.updateAsset(initial.id, body);
else await store.addAsset(body);
onClose();
} catch (e) { setErr(e.message); } finally { setBusy(false); }
};
// Vorschau: jährliche AfA + Nutzungsdauer
const yearlyAfa = form.afaRate > 0 && form.acquisitionCost ? Number(form.acquisitionCost) * (Number(form.afaRate) / 100) : 0;
const yearsToFullDepreciation = form.afaRate > 0 ? Math.ceil(100 / Number(form.afaRate)) : 0;
return (
Abbrechen
{busy ? 'Speichert…' : (initial ? 'Speichern' : 'Anlegen')}
>
}>
update('propertyId', e.target.value)}>
— kein Objekt —
{(properties || []).map(p => {p.name} )}
{ update('type', e.target.value); const t = ASSET_TYPES.find(t => t.value === e.target.value); if (t) update('afaRate', t.defaultRate); }}>
{ASSET_TYPES.map(t => {t.label} )}
update('label', e.target.value)} placeholder={form.type === 'gebäude' ? 'z.B. Gebäudeanteil Lindenstraße 42' : form.type === 'modernisierung' ? 'z.B. Bad-Renovierung Wohnung 4' : 'z.B. Einbauküche Wohnung 1'} autoFocus />
update('acquisitionDate', e.target.value)} />
update('acquisitionCost', e.target.value)} step="0.01" placeholder="0,00" />
update('afaRate', e.target.value)} step="0.1" min="0" max="100" />
{yearlyAfa > 0 && (
📐 AfA-Vorschau
{yearlyAfa.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} jährlich,
vollständig abgeschrieben nach {yearsToFullDepreciation} Jahren
)}
update('notes', e.target.value)} />
{err && {err}
}
);
}
// ── TaskForm: einfache To-do mit Property/Unit-Bezug ──────────────
function TaskForm({ open, onClose, store, properties, initial }) {
const [busy, setBusy] = React.useState(false);
const [err, setErr] = React.useState(null);
const initF = (i) => ({
title: i?.title || '',
description: i?.description || '',
propertyId: i?.propertyId || '',
unitId: i?.unitId || '',
dueDate: i?.dueDate || '',
priority: i?.priority || 'mittel',
});
const [form, setForm] = React.useState(() => initF(initial));
React.useEffect(() => { if (open) { setForm(initF(initial)); setErr(null); } }, [open, initial]);
const update = (k, v) => setForm(f => ({ ...f, [k]: v }));
const propUnits = (properties || []).find(p => p.id === form.propertyId)?.units || [];
const submit = async (e) => {
e.preventDefault();
if (!form.title.trim()) return;
setBusy(true); setErr(null);
try {
const body = {
title: form.title.trim(),
description: form.description.trim() || null,
propertyId: form.propertyId || null,
unitId: form.unitId || null,
dueDate: form.dueDate || null,
priority: form.priority,
};
if (initial?.id) await store.updateTask(initial.id, body);
else await store.addTask(body);
onClose();
} catch (e) { setErr(e.message); } finally { setBusy(false); }
};
return (
Abbrechen
{busy ? 'Speichert…' : (initial ? 'Speichern' : 'Anlegen')}
>
}>
update('title', e.target.value)} placeholder="z.B. Mietvertrag verlängern" autoFocus />
update('description', e.target.value)} />
update('dueDate', e.target.value)} />
update('priority', e.target.value)}>
Niedrig
Mittel
Hoch
{ update('propertyId', e.target.value); update('unitId', ''); }}>
— kein Objekt —
{(properties || []).map(p => {p.name} )}
{form.propertyId && propUnits.length > 0 && (
update('unitId', e.target.value)}>
— Gesamtobjekt —
{propUnits.map(u => {u.label} )}
)}
{err && {err}
}
);
}
// Generic confirm dialog
function ConfirmDialog({ open, onClose, onConfirm, title, message, confirmLabel = 'Löschen', danger = true }) {
const [busy, setBusy] = React.useState(false);
if (!open) return null;
const submit = async () => {
setBusy(true);
try { await onConfirm(); onClose(); } catch (e) { alert(e.message); }
finally { setBusy(false); }
};
return (
Abbrechen
{busy ? 'Lädt…' : confirmLabel}
>
}>
{message}
);
}
Object.assign(window, {
Modal, FormField, ConfirmDialog,
PropertyForm, UnitForm, TenantForm, TransactionForm,
ContactForm, MaintenanceForm, ReminderForm, CsvImportModal,
AssetForm, TaskForm, ASSET_TYPES,
PROPERTY_TYPES, UNIT_STATUSES, TX_CATEGORIES_INCOME, TX_CATEGORIES_EXPENSE,
CONTACT_CATEGORIES, MAINT_STATUSES, REMINDER_CATEGORIES, REMINDER_PRIORITIES,
});