// storage.jsx — Cloud-Anbindung an hausgut-server (Hetzner Object Storage) // Stellt useAuth, useDocuments, uploadDocument, deleteDocument, downloadDocument // als globale Helfer bereit. Greift via fetch + Cookie-Auth auf das Backend zu. const API_BASE = (window.HAUSGUT_API_URL || 'http://localhost:8080').replace(/\/$/, ''); async function apiFetch(path, opts = {}) { const res = await fetch(`${API_BASE}${path}`, { credentials: 'include', ...opts }); if (res.status === 401) { window.dispatchEvent(new CustomEvent('hausgut:unauth')); throw new Error('unauthenticated'); } return res; } async function apiJson(path, opts = {}) { const res = await apiFetch(path, opts); if (!res.ok) { const text = await res.text().catch(() => ''); throw new Error(`API ${res.status}: ${text || res.statusText}`); } return res.json(); } // ── Auth ────────────────────────────────────────────────────────────── async function checkAuth() { try { const res = await fetch(`${API_BASE}/api/auth/me`, { credentials: 'include' }); if (!res.ok) return false; const data = await res.json(); return !!data.authenticated; } catch { return false; } } async function login(password) { const res = await fetch(`${API_BASE}/api/auth/login`, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password }), }); if (!res.ok) throw new Error(res.status === 401 ? 'invalid_password' : `login_failed_${res.status}`); return true; } async function logout() { await apiFetch('/api/auth/logout', { method: 'POST' }).catch(() => {}); } function useAuth() { const [state, setState] = React.useState({ loading: true, authenticated: false }); const refresh = React.useCallback(async () => { const authed = await checkAuth(); setState({ loading: false, authenticated: authed }); }, []); React.useEffect(() => { refresh(); const onUnauth = () => setState({ loading: false, authenticated: false }); window.addEventListener('hausgut:unauth', onUnauth); return () => window.removeEventListener('hausgut:unauth', onUnauth); }, [refresh]); return { ...state, login: async (pw) => { await login(pw); setState({ loading: false, authenticated: true }); }, logout: async () => { await logout(); setState({ loading: false, authenticated: false }); }, }; } // ── Documents ───────────────────────────────────────────────────────── async function listDocuments({ propertyId, unitId, scope } = {}) { const qs = new URLSearchParams(); if (propertyId) qs.set('propertyId', propertyId); if (unitId) qs.set('unitId', unitId); if (scope) qs.set('scope', scope); const suffix = qs.toString() ? `?${qs}` : ''; return apiJson(`/api/documents${suffix}`); } async function uploadDocument(file, meta = {}) { const fd = new FormData(); fd.append('file', file); if (meta.category) fd.append('category', meta.category); if (meta.propertyId) fd.append('propertyId', meta.propertyId); if (meta.unitId) fd.append('unitId', meta.unitId); if (meta.scope) fd.append('scope', meta.scope); if (meta.transactionId) fd.append('transactionId', meta.transactionId); const res = await apiFetch('/api/uploads', { method: 'POST', body: fd }); if (!res.ok) { const text = await res.text().catch(() => ''); throw new Error(`upload_failed: ${text || res.statusText}`); } return res.json(); } async function deleteDocument(id) { const res = await apiFetch(`/api/documents/${id}`, { method: 'DELETE' }); if (!res.ok) throw new Error(`delete_failed_${res.status}`); return true; } function downloadDocumentUrl(id) { return `${API_BASE}/api/documents/${id}/download`; } function viewDocumentUrl(id) { return `${API_BASE}/api/documents/${id}/view`; } // Link / unlink a document to/from a transaction async function linkDocumentToTransaction(documentId, transactionId) { const res = await apiFetch(`/api/documents/${documentId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ transactionId: transactionId || null }), }); if (!res.ok) throw new Error(`link_failed_${res.status}`); return res.json(); } // List documents linked to a transaction async function listDocumentsForTransaction(transactionId) { return apiJson(`/api/transactions/${transactionId}/documents`); } // Build URL for monthly Steuerberater-ZIP (browser navigates to download) function monthlyExportZipUrl({ year, month, propertyId }) { const qs = new URLSearchParams({ year: String(year), month: String(month) }); if (propertyId) qs.set('propertyId', propertyId); return `${API_BASE}/api/exports/monthly-zip?${qs}`; } function datevExportUrl({ year, propertyId }) { const qs = new URLSearchParams({ year: String(year) }); if (propertyId) qs.set('propertyId', propertyId); return `${API_BASE}/api/exports/datev-csv?${qs}`; } async function getAfaSummary({ year, propertyId } = {}) { const qs = new URLSearchParams(); if (year) qs.set('year', String(year)); if (propertyId) qs.set('propertyId', propertyId); return apiJson(`/api/afa-summary${qs.toString() ? '?' + qs : ''}`); } async function getAnlageV({ year, propertyId } = {}) { const qs = new URLSearchParams(); if (year) qs.set('year', String(year)); if (propertyId) qs.set('propertyId', propertyId); return apiJson(`/api/anlage-v${qs.toString() ? '?' + qs : ''}`); } async function getRentStatus({ year } = {}) { const qs = new URLSearchParams(); if (year) qs.set('year', String(year)); return apiJson(`/api/rent-status${qs.toString() ? '?' + qs : ''}`); } // ── Stammdaten CRUD (properties / units / tenants / transactions) ──── async function listResource(path) { const res = await apiFetch(path); if (!res.ok) throw new Error(`${path} (${res.status})`); return res.json(); } async function createResource(path, body) { const res = await apiFetch(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); if (!res.ok) { const t = await res.text().catch(() => ''); throw new Error(`${path} POST ${res.status}: ${t || res.statusText}`); } return res.json(); } async function updateResource(path, body) { const res = await apiFetch(path, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); if (!res.ok) { const t = await res.text().catch(() => ''); throw new Error(`${path} PUT ${res.status}: ${t || res.statusText}`); } return res.json(); } async function deleteResource(path) { const res = await apiFetch(path, { method: 'DELETE' }); if (!res.ok) throw new Error(`${path} DELETE ${res.status}`); return true; } // Single store hook that fetches all stammdaten + provides CRUD operations. // Replaces window.DEMO_DATA. Returns a `data`-shaped object compatible with // the existing pages (properties[].units[], tenants{}, transactions[]). function useDataStore() { const [state, setState] = React.useState({ loading: true, error: null, properties: [], unitsRaw: [], tenantsRaw: [], transactions: [], documents: [], contacts: [], maintenance: [], reminders: [], settings: {}, assets: [], tasks: [], }); // Only the very first load shows the full-screen "Lade Daten…" — every // subsequent reload (after add/update/delete) updates state silently in the // background. Without this, every save flashed the loading screen and felt // like a manual refresh. const reload = React.useCallback(async () => { setState(s => ({ ...s, error: null })); try { const [properties, units, tenants, transactions, contacts, maintenance, reminders, settings, assets, tasks] = await Promise.all([ listResource('/api/properties'), listResource('/api/units'), listResource('/api/tenants'), listResource('/api/transactions'), listResource('/api/contacts'), listResource('/api/maintenance'), listResource('/api/reminders'), listResource('/api/settings'), listResource('/api/assets'), listResource('/api/tasks'), ]); setState({ loading: false, error: null, properties, unitsRaw: units, tenantsRaw: tenants, transactions, documents: [], contacts, maintenance, reminders, settings, assets, tasks }); } catch (err) { setState(s => ({ ...s, loading: false, error: err.message })); } }, []); React.useEffect(() => { reload(); }, [reload]); // Re-shape into the same structure pages already expect: // properties[i].units[] (nested), tenants as { id: tenant } map const data = React.useMemo(() => { const tenantsMap = {}; state.tenantsRaw.forEach(t => { tenantsMap[t.id] = t; }); const propertiesNested = state.properties.map(p => ({ ...p, units: state.unitsRaw.filter(u => u.propertyId === p.id).map(u => ({ ...u, tenant: u.tenantId })), })); return { properties: propertiesNested, tenants: tenantsMap, transactions: state.transactions, documents: state.documents, contacts: state.contacts, maintenance: state.maintenance, reminders: state.reminders, settings: state.settings || {}, assets: state.assets || [], tasks: state.tasks || [], meterReadings: [], // not implemented yet }; }, [state]); return { loading: state.loading, error: state.error, data, reload, // Properties addProperty: async (body) => { const p = await createResource('/api/properties', body); await reload(); return p; }, updateProperty: async (id, body) => { const p = await updateResource(`/api/properties/${id}`, body); await reload(); return p; }, deleteProperty: async (id) => { await deleteResource(`/api/properties/${id}`); await reload(); }, // Units addUnit: async (body) => { const u = await createResource('/api/units', body); await reload(); return u; }, updateUnit: async (id, body) => { const u = await updateResource(`/api/units/${id}`, body); await reload(); return u; }, deleteUnit: async (id) => { await deleteResource(`/api/units/${id}`); await reload(); }, // Tenants addTenant: async (body) => { const t = await createResource('/api/tenants', body); await reload(); return t; }, updateTenant: async (id, body) => { const t = await updateResource(`/api/tenants/${id}`, body); await reload(); return t; }, deleteTenant: async (id) => { await deleteResource(`/api/tenants/${id}`); await reload(); }, // Transactions addTransaction: async (body) => { const t = await createResource('/api/transactions', body); await reload(); return t; }, updateTransaction: async (id, body) => { const t = await updateResource(`/api/transactions/${id}`, body); await reload(); return t; }, deleteTransaction: async (id) => { await deleteResource(`/api/transactions/${id}`); await reload(); }, // Contacts addContact: async (body) => { const c = await createResource('/api/contacts', body); await reload(); return c; }, updateContact: async (id, body) => { const c = await updateResource(`/api/contacts/${id}`, body); await reload(); return c; }, deleteContact: async (id) => { await deleteResource(`/api/contacts/${id}`); await reload(); }, // Maintenance addMaintenance: async (body) => { const m = await createResource('/api/maintenance', body); await reload(); return m; }, updateMaintenance: async (id, body) => { const m = await updateResource(`/api/maintenance/${id}`, body); await reload(); return m; }, deleteMaintenance: async (id) => { await deleteResource(`/api/maintenance/${id}`); await reload(); }, // Reminders addReminder: async (body) => { const r = await createResource('/api/reminders', body); await reload(); return r; }, updateReminder: async (id, body) => { const r = await updateResource(`/api/reminders/${id}`, body); await reload(); return r; }, deleteReminder: async (id) => { await deleteResource(`/api/reminders/${id}`); await reload(); }, // Settings updateSettings: async (body) => { const s = await updateResource('/api/settings', body); await reload(); return s; }, // Assets (AfA) addAsset: async (body) => { const a = await createResource('/api/assets', body); await reload(); return a; }, updateAsset: async (id, body) => { const a = await updateResource(`/api/assets/${id}`, body); await reload(); return a; }, deleteAsset: async (id) => { await deleteResource(`/api/assets/${id}`); await reload(); }, // Tasks addTask: async (body) => { const t = await createResource('/api/tasks', body); await reload(); return t; }, updateTask: async (id, body) => { const t = await updateResource(`/api/tasks/${id}`, body); await reload(); return t; }, deleteTask: async (id) => { await deleteResource(`/api/tasks/${id}`); await reload(); }, }; } // ── CSV Import for bank transactions ────────────────────────────────── async function importTransactionsCsv(file, { propertyId, unitId, dryRun, autoCategorize } = {}) { const fd = new FormData(); fd.append('file', file); if (propertyId) fd.append('propertyId', propertyId); if (unitId) fd.append('unitId', unitId); if (dryRun) fd.append('dryRun', 'true'); if (autoCategorize === false) fd.append('autoCategorize', 'false'); const res = await apiFetch('/api/transactions/import', { method: 'POST', body: fd }); if (!res.ok) { const text = await res.text().catch(() => ''); let err; try { err = JSON.parse(text); } catch { err = { error: 'import_failed', detail: text }; } throw Object.assign(new Error(err.detail || err.error || 'import_failed'), err); } return res.json(); } // Start an async categorize job. Returns { jobId, total, done, ... }. // Use getCategorizeStatus(jobId) to poll progress. async function categorizeTransactions(transactionIds) { const res = await apiFetch('/api/transactions/categorize', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ transactionIds: transactionIds || null }), }); if (!res.ok) { const text = await res.text().catch(() => ''); throw new Error(`categorize_failed: ${text || res.statusText}`); } return res.json(); } // ── KI-Berater: Insights + Chat ────────────────────────────────────── async function getAiInsights(force) { const res = await apiFetch('/api/ai/insights', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ force: !!force }), }); if (!res.ok) { const text = await res.text().catch(() => ''); let err; try { err = JSON.parse(text); } catch { err = { error: text || 'failed' }; } throw Object.assign(new Error(err.detail || err.error || 'insights_failed'), err); } return res.json(); } async function aiChat(message, history) { const res = await apiFetch('/api/ai/chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message, history }), }); if (!res.ok) { const text = await res.text().catch(() => ''); let err; try { err = JSON.parse(text); } catch { err = { error: text || 'failed' }; } throw Object.assign(new Error(err.detail || err.error || 'chat_failed'), err); } return res.json(); } // Get backend health (incl. ocr.enabled) — used to display KI connection status async function getHealth() { const res = await fetch(`${API_BASE}/api/health`); if (!res.ok) throw new Error('health_failed'); return res.json(); } async function getCategorizeStatus(jobId) { const res = await apiFetch(`/api/transactions/categorize/status/${jobId}`); if (!res.ok) { if (res.status === 404) return { error: 'job_not_found', finished: true }; throw new Error(`status_failed: ${res.statusText}`); } return res.json(); } // ── OCR: send file to /api/ocr → structured suggestion ──────────────── async function analyzeFile(file) { const fd = new FormData(); fd.append('file', file); const res = await apiFetch('/api/ocr', { method: 'POST', body: fd }); if (!res.ok) { const text = await res.text().catch(() => ''); throw new Error(`ocr_failed (${res.status}): ${text || res.statusText}`); } return res.json(); } // React hook: list + auto-refresh after upload/delete function useDocuments(filter = {}) { const filterKey = JSON.stringify(filter); const [state, setState] = React.useState({ loading: true, error: null, items: [] }); const reload = React.useCallback(async () => { setState(s => ({ ...s, loading: true, error: null })); try { const items = await listDocuments(filter); setState({ loading: false, error: null, items }); } catch (err) { setState({ loading: false, error: err.message, items: [] }); } // filterKey serializes the filter into a stable dep so the callback isn't recreated each render // eslint-disable-next-line react-hooks/exhaustive-deps }, [filterKey]); React.useEffect(() => { reload(); }, [reload]); return { ...state, reload, upload: async (file, meta) => { const doc = await uploadDocument(file, { ...filter, ...meta }); await reload(); return doc; }, remove: async (id) => { await deleteDocument(id); await reload(); }, }; } // ── Login screen ────────────────────────────────────────────────────── function LoginGate({ onSuccess }) { const [pw, setPw] = React.useState(''); const [err, setErr] = React.useState(null); const [busy, setBusy] = React.useState(false); const submit = async (e) => { e.preventDefault(); setBusy(true); setErr(null); try { await login(pw); onSuccess?.(); } catch (e) { setErr(e.message === 'invalid_password' ? 'Falsches Passwort.' : 'Anmeldung fehlgeschlagen. Backend erreichbar?'); } finally { setBusy(false); } }; return (
Hausgut
Anmelden, um Dokumente zu verwalten.
setPw(e.target.value)} placeholder="Passwort" style={{ width: '100%', padding: '10px 12px', border: '1px solid var(--line, #e6e3da)', borderRadius: 6, fontSize: 14, marginBottom: 12 }} /> {err &&
{err}
}
Backend: {API_BASE}
); } // ── Document categories & scopes ────────────────────────────────────── const DOC_CATEGORIES = [ 'Mietvertrag', 'Kündigung', 'Übergabeprotokoll', 'Rechnung', 'Beleg', 'Nebenkostenabrechnung', 'Versicherung', 'Eigentum / Grundbuch', 'Mahnung', 'Modernisierung', 'Sonstiges', ]; // ── Upload modal: ask for kategorie + scope + kommentar before upload ─ function UploadModal({ files, defaultMeta = {}, propertyContext, unitContext, onConfirm, onCancel, properties }) { const [category, setCategory] = React.useState(defaultMeta.category || ''); const [scope, setScope] = React.useState(defaultMeta.scope || (unitContext ? 'einheit' : 'allgemein')); // When no propertyContext is fixed AND a properties list is provided, // the modal shows a property/unit picker so the user assigns the upload. const showPicker = !propertyContext && Array.isArray(properties); const [pickerPropertyId, setPickerPropertyId] = React.useState(defaultMeta.propertyId || ''); const [pickerUnitId, setPickerUnitId] = React.useState(defaultMeta.unitId || ''); const pickerProp = (properties || []).find(p => p.id === pickerPropertyId); const pickerUnits = pickerProp?.units || []; const [comment, setComment] = React.useState(''); const [busy, setBusy] = React.useState(false); const [progress, setProgress] = React.useState({ done: 0, total: files.length }); const [ocrBusy, setOcrBusy] = React.useState(false); const [ocrResult, setOcrResult] = React.useState(null); const [ocrError, setOcrError] = React.useState(null); const runOcr = async () => { if (!files[0]) return; setOcrBusy(true); setOcrError(null); setOcrResult(null); try { const r = await analyzeFile(files[0]); setOcrResult(r); // Auto-apply suggestions (user can still adjust) if (r.category && DOC_CATEGORIES.includes(r.category)) setCategory(r.category); if (r.scope_suggestion === 'allgemein' || r.scope_suggestion === 'einheit') { // only apply scope='einheit' if the unit context allows it if (r.scope_suggestion === 'einheit' && !unitContext) setScope('allgemein'); else setScope(r.scope_suggestion); } const noteParts = []; if (r.summary) noteParts.push(r.summary); if (r.supplier) noteParts.push(`Aussteller: ${r.supplier}`); if (r.date) noteParts.push(`Datum: ${r.date}`); if (typeof r.amount_eur === 'number') noteParts.push(`Betrag: ${r.amount_eur.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}`); setComment(noteParts.join(' · ')); } catch (err) { setOcrError(err.message.includes('ocr_disabled') ? 'OpenAI-Key fehlt in der .env (OPENAI_API_KEY).' : err.message); } finally { setOcrBusy(false); } }; const submit = async (e) => { e?.preventDefault(); if (!category) return; setBusy(true); setProgress({ done: 0, total: files.length }); try { for (let i = 0; i < files.length; i++) { await uploadDocument(files[i], { ...defaultMeta, category, scope, // Picker values win over defaultMeta when picker is shown ...(showPicker ? { propertyId: pickerPropertyId || null, unitId: pickerUnitId || null } : {}), }); setProgress({ done: i + 1, total: files.length }); } onConfirm?.(); } catch (err) { alert('Upload fehlgeschlagen: ' + err.message); setBusy(false); } }; return (
e.stopPropagation()} onSubmit={submit}>

{files.length} {files.length === 1 ? 'Datei' : 'Dateien'} hochladen

{!busy && }
{files.map((f, i) => (
📄 {f.name} {(f.size / 1024).toFixed(0)} KB
))}
{(propertyContext || unitContext) && (
{propertyContext && 📍 {propertyContext}} {unitContext && · {unitContext}}
)} {showPicker && (
)}
{!ocrResult && !ocrError && ( )} {ocrResult && (
✨ KI-Vorschlag ({Math.round(ocrResult.confidence * 100)} % sicher)
{ocrResult.summary}
)} {ocrError && (
⚠ {ocrError}
)}
setComment(e.target.value)} placeholder="z.B. Original liegt im Aktenordner 2026/Q2" disabled={busy} />
{busy ? (
Hochladen… {progress.done} / {progress.total}
) : ( <> )}
); } // ── Drop zone with click-to-pick + drag-and-drop, opens UploadModal ── function DropZone({ accept = '.pdf,.jpg,.jpeg,.png,.heic,.docx,.xlsx', meta = {}, propertyContext, unitContext, onUpload, label = 'Dateien hierher ziehen oder klicken zum Auswählen', subLabel = 'PDF, JPG, PNG, DOCX, XLSX', compact = false, properties }) { const inputRef = React.useRef(null); const [files, setFiles] = React.useState(null); const [hover, setHover] = React.useState(false); const onPick = (selected) => { const arr = Array.from(selected || []).filter(Boolean); if (arr.length) setFiles(arr); }; const onDrop = (e) => { e.preventDefault(); setHover(false); onPick(e.dataTransfer?.files); }; return ( <>
inputRef.current?.click()} onDragOver={e => { e.preventDefault(); setHover(true); }} onDragLeave={() => setHover(false)} onDrop={onDrop} >
{label}
{subLabel &&
{subLabel}
} onPick(e.target.files)} />
{files && ( { setFiles(null); onUpload?.(); if (inputRef.current) inputRef.current.value = ''; }} onCancel={() => { setFiles(null); if (inputRef.current) inputRef.current.value = ''; }} /> )} ); } // ── Reusable button that opens the same modal (for header/inline use) ── function UploadButton({ onUpload, accept = '.pdf,.jpg,.jpeg,.png,.heic,.docx,.xlsx', label = 'Hochladen', meta = {}, propertyContext, unitContext, className = 'btn', properties }) { const inputRef = React.useRef(null); const [files, setFiles] = React.useState(null); const onPick = (selected) => { const arr = Array.from(selected || []).filter(Boolean); if (arr.length) setFiles(arr); }; return ( <> onPick(e.target.files)} /> {files && ( { setFiles(null); onUpload?.(); if (inputRef.current) inputRef.current.value = ''; }} onCancel={() => { setFiles(null); if (inputRef.current) inputRef.current.value = ''; }} /> )} ); } Object.assign(window, { HAUSGUT_API_BASE: API_BASE, useAuth, useDocuments, uploadDocument, deleteDocument, listDocuments, downloadDocumentUrl, viewDocumentUrl, useDataStore, importTransactionsCsv, categorizeTransactions, getCategorizeStatus, getAiInsights, aiChat, getHealth, linkDocumentToTransaction, listDocumentsForTransaction, monthlyExportZipUrl, datevExportUrl, getAfaSummary, getAnlageV, getRentStatus, LoginGate, UploadButton, DropZone, UploadModal, DOC_CATEGORIES, });