From bedbda640b80785cb3ebef938fb33a88730084e0 Mon Sep 17 00:00:00 2001 From: ah-admin Date: Sun, 4 Oct 2026 11:52:29 +0200 Subject: [PATCH] =?UTF-8?q?v1.10+v1.11:=20Bestandsverwaltung=20(H=C3=A4use?= =?UTF-8?q?r=20&=20Wohnungen)=20+=20KI-Antworten=20via=20n8n/Ollama?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-compose.yml | 4 + kimi.md | 31 +++ server/src/db.js | 10 + server/src/index.js | 224 ++++++++++++++++++- server/src/seed-data.js | 8 +- server/src/seed.js | 19 +- src/App.tsx | 2 + src/components/AppLayout.tsx | 4 +- src/pages/Bestand.tsx | 413 +++++++++++++++++++++++++++++++++++ src/store/store.tsx | 54 +++++ src/types/index.ts | 10 + 11 files changed, 774 insertions(+), 5 deletions(-) create mode 100644 src/pages/Bestand.tsx diff --git a/docker-compose.yml b/docker-compose.yml index 1f9b833..39446f9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -35,6 +35,10 @@ services: DATABASE_URL: postgres://mp:${POSTGRES_PASSWORD}@mp-db:5432/mieterportal MAIL_API_URL: http://mp-mail-api:8080 MAIL_API_TOKEN: ${MAIL_API_TOKEN} + # KI-Antworten auf Schadensmeldungen über n8n (Webhook-URL auf dem VPS) + N8N_TICKET_WEBHOOK: ${N8N_TICKET_WEBHOOK:-} + # Token, mit dem n8n die fertige KI-Antwort zurück an die API postet + KI_API_TOKEN: ${KI_API_TOKEN:-} PORT: 3000 depends_on: - mp-db diff --git a/kimi.md b/kimi.md index c10ee89..e116939 100644 --- a/kimi.md +++ b/kimi.md @@ -189,6 +189,37 @@ tar -czf mp-dist.tar.gz -C dist . ## 8. Verlauf / offene Punkte +**Erledigt 04.10. (v1.11):** KI-Antworten auf Schadenmeldungen via n8n/Ollama. +Kette: Mieter-Ticket → mp-api `kiBenachrichtigen()` (POST an n8n, fire-and- +forget) → n8n-Workflow `mpKiAntwort0001` (Webhook POST `mp-schadensmeldung`, +responseMode onReceived; Knoten: Webhook → Code „KI-Antwort generieren +(Ollama)" → Code „Antwort ins Portal posten") → Ollama +`http://192.168.188.100:11434/api/generate` Modell `qwen2.5:7b` → POST +`https://mieterportal.hocks.eu/api/ki-antwort` (KI_API_TOKEN-Bearer) → +Nachricht als „Hinterstein Immobilien (KI-Assistent)" im Ticket-Thread + Mail +an Mieter. Konfig in /opt/mieterportal/portal.env: KI_API_TOKEN + +N8N_TICKET_WEBHOOK. n8n 2.41-Hürden beim direkten DB-Import: publizierte +Knoten in `workflow_history` (versionId = activeVersionId, authors='{}' +NOT NULL), `shared_workflow`-Zeile (projectId M7v7qD1yefYEQ9Dn, role +workflow:owner), Webhook-Knoten braucht node-level `webhookId`, fehlgeschlagener +`workflow_publication_outbox`-Eintrag blockiert Reaktivierung (Zeile löschen + +`docker restart n8n`). E2E verifiziert: KI-Antwort im Thread + Mail sauber. + +**Erledigt 04.10. (v1.10):** Bestandsverwaltung. Neue Tabelle `buildings` +(PK name; Pflicht: Adresse, Baujahr, Stockwerke, Etagenhöhe, Wohneinheiten, +Quadratmeter), idempotent vorbefüllt mit den 3 Demo-Häusern (`seedeHaeuser()`). +Wohnungen bleiben über `units.building` (Text = Hausname) verknüpft; Haus- +Umbenennung zieht units + meters in einer Transaktion mit. API (adminOnly): +POST/PATCH /api/buildings, POST/PATCH /api/units mit Validierung. Frontend: +neue Admin-Seite /bestand („Häuser & Wohnungen", Nav hinter Zählerstände) mit +Haus-Karten (inkl. „angelegt: X von Y Wohneinheiten") und gefilterter +Wohnungsliste, je Anlegen/Bearbeiten-Dialog. Robustheit: uncaughtException/ +unhandledRejection-Handler in index.js – defekte JSON-Bodies von außen haben +den mp-api-Prozess gekillt (502er), jetzt 500 + Dienst läuft weiter. +E2E verifiziert: anlegen/editieren/umbenennen (Kaskade), Validierung 400, +Mieter 403. Achtung Testfall: curl verbindet --data-binary + -d mit „&" +→ invalider JSON → 500. + **Erledigt 04.10. (v1.9):** Zähler-Modul + Registrierungs-Mail + getrennter Name. Neue Tabelle `meters` (Zählernummer, Art Strom/Wasser/Gas/Wärme/Sonstiges, Stand, Stand-Datum, Wohneinheit nullable, Haus, Pflege-Zeitstempel) mit diff --git a/server/src/db.js b/server/src/db.js index b6ef406..2b634dc 100644 --- a/server/src/db.js +++ b/server/src/db.js @@ -127,6 +127,16 @@ CREATE TABLE IF NOT EXISTS meters ( geaendert_am TEXT NOT NULL -- letzte Pflege durch die Verwaltung ); CREATE INDEX IF NOT EXISTS idx_meters_unit ON meters(unit_id); + +CREATE TABLE IF NOT EXISTS buildings ( + name TEXT PRIMARY KEY, -- eindeutiger Hausname (z. B. "Hauptstraße 12") + adresse TEXT NOT NULL, + baujahr INT NOT NULL, + stockwerke INT NOT NULL, + etagenhoehe NUMERIC(4,2) NOT NULL, -- Deckenhöhe in m + wohneinheiten INT NOT NULL, -- Anzahl Wohneinheiten (Soll) + quadratmeter NUMERIC(9,1) NOT NULL -- Gesamtwohnfläche in m² +); ` async function init() { diff --git a/server/src/index.js b/server/src/index.js index cc9ca57..3bdd48b 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -9,17 +9,41 @@ const express = require('express') const crypto = require('crypto') const { pool, init, sessionsAufraeumen } = require('./db') -const { seed, seedeZaehler } = require('./seed') +const { seed, seedeZaehler, seedeHaeuser } = require('./seed') const mail = require('./mail') const PORT = Number(process.env.PORT || 3000) const SESSION_DAUER_MS = 8 * 60 * 60 * 1000 const COOKIE_NAME = 'mp_session' +// KI-Antworten auf Schadensmeldungen über n8n (leer = deaktiviert) +const N8N_TICKET_WEBHOOK = process.env.N8N_TICKET_WEBHOOK || '' +const KI_API_TOKEN = process.env.KI_API_TOKEN || '' const app = express() app.use(express.json({ limit: '256kb' })) app.set('trust proxy', true) +// Prozess nie durch einen einzelnen Fehler killen lassen (z. B. defekter +// JSON-Body von außen); Fehler werden geloggt, der Dienst läuft weiter. +process.on('uncaughtException', (e) => console.error('[api] uncaughtException:', e)) +process.on('unhandledRejection', (e) => console.error('[api] unhandledRejection:', e)) + +/** Feuert die KI-Antwort-Kette an n8n (fire-and-forget; die fertige Antwort + * kommt asynchron über POST /api/ki-antwort zurück ins Portal). */ +async function kiBenachrichtigen(daten) { + if (!N8N_TICKET_WEBHOOK) return + try { + await fetch(N8N_TICKET_WEBHOOK, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(daten), + signal: AbortSignal.timeout(5000), + }) + } catch (e) { + console.warn('[ki] Webhook-Aufruf fehlgeschlagen:', e.message) + } +} + // ── Cookie-Helfer (bewusst ohne Dependency) ────────────────── function cookieLesen(req, name) { const raw = req.headers.cookie || '' @@ -115,6 +139,11 @@ const mapMeter = (m) => ({ id: m.id, unitId: m.unit_id, building: m.building, nummer: m.nummer, art: m.art, stand: Number(m.stand), standAm: m.stand_am, geaendertAm: m.geaendert_am, }) +const mapBuilding = (b) => ({ + name: b.name, adresse: b.adresse, baujahr: b.baujahr, stockwerke: b.stockwerke, + etagenhoehe: Number(b.etagenhoehe), wohneinheiten: b.wohneinheiten, + quadratmeter: Number(b.quadratmeter), +}) const deutschesDatum = () => new Date().toLocaleDateString('de-DE') const deutschesDatumZeit = () => new Date().toLocaleString('de-DE') @@ -218,7 +247,7 @@ app.get('/api/bootstrap', auth, async (req, res, next) => { try { const istAdmin = req.user.rolle === 'verwaltung' const einheit = req.user.einheit_id - const [units, transactions, documents, tickets, threads, messages, announcements, users, codes, meters] = + const [units, transactions, documents, tickets, threads, messages, announcements, users, codes, meters, buildings] = await Promise.all([ pool.query('SELECT * FROM units ORDER BY id'), istAdmin @@ -246,6 +275,9 @@ app.get('/api/bootstrap', auth, async (req, res, next) => { istAdmin ? pool.query('SELECT * FROM meters ORDER BY building, nummer') : pool.query('SELECT * FROM meters WHERE unit_id = $1 ORDER BY nummer', [einheit]), + istAdmin + ? pool.query('SELECT * FROM buildings ORDER BY name') + : { rows: [] }, ]) // Threads mit ihren Nachrichten zusammenführen const nachrichtenProThread = {} @@ -266,6 +298,7 @@ app.get('/api/bootstrap', auth, async (req, res, next) => { benutzer: users.rows.map(oeffentlicherBenutzer), codes: codes.rows.map(mapCode), meters: meters.rows.map(mapMeter), + buildings: buildings.rows.map(mapBuilding), }) } catch (e) { next(e) } }) @@ -330,6 +363,20 @@ app.post('/api/tickets', auth, async (req, res, next) => { aktionenHtml: mail.buttonHtml('Zum Mieterportal', `${process.env.PORTAL_URL || 'https://mieterportal.hocks.eu'}/schadensmeldungen`), }) mail.benachrichtigeVerwaltung(betreff, text, html) + // KI-Antwort-Kette anstoßen (nur bei Mieter-Meldungen, nicht bei internen) + if (vonRolle === 'tenant') { + kiBenachrichtigen({ + ticketId: id, + threadId, + subject: threadBetreff, + title: String(title).trim(), + description: String(description || '–').trim(), + category: String(category || 'Sonstiges'), + priority: ['niedrig', 'mittel', 'hoch'].includes(priority) ? priority : 'mittel', + tenant: mieterName, + unit: label, + }) + } // Eingangsbestätigung an den Mieter (bei internen Meldungen der // Verwaltung entfällt sie, da diese keine Mieter-E-Mail auslösen soll) if (vonRolle === 'tenant') { @@ -435,6 +482,137 @@ app.patch('/api/meters/:id', auth, adminOnly, async (req, res, next) => { res.json({ meter: mapMeter(aktualisiert[0]) }) } catch (e) { next(e) } }) +// ── Bestand: Häuser & Wohnungen (Verwaltung) ──────────────── +function pruefeHaus(b) { + const fehler = [] + const baujahr = Number(b.baujahr) + const stockwerke = Number(b.stockwerke) + const etagenhoehe = Number(b.etagenhoehe) + const wohneinheiten = Number(b.wohneinheiten) + const quadratmeter = Number(b.quadratmeter) + if (!String(b.name || '').trim()) fehler.push('Hausname fehlt.') + if (!String(b.adresse || '').trim()) fehler.push('Adresse fehlt.') + if (!Number.isInteger(baujahr) || baujahr < 1500 || baujahr > new Date().getFullYear() + 2) fehler.push('Baujahr ungültig.') + if (!Number.isInteger(stockwerke) || stockwerke < 0 || stockwerke > 50) fehler.push('Stockwerke ungültig.') + if (!Number.isFinite(etagenhoehe) || etagenhoehe <= 0 || etagenhoehe > 10) fehler.push('Etagenhöhe ungültig.') + if (!Number.isInteger(wohneinheiten) || wohneinheiten < 0) fehler.push('Wohneinheiten ungültig.') + if (!Number.isFinite(quadratmeter) || quadratmeter <= 0) fehler.push('Quadratmeter ungültig.') + return fehler +} + +app.post('/api/buildings', auth, adminOnly, async (req, res, next) => { + try { + const b = req.body || {} + const fehler = pruefeHaus(b) + if (fehler.length) return res.status(400).json({ fehler: fehler.join(' ') }) + const name = String(b.name).trim() + const vorhanden = await pool.query('SELECT 1 FROM buildings WHERE name = $1', [name]) + if (vorhanden.rows.length) return res.status(400).json({ fehler: 'Dieses Haus existiert bereits.' }) + const { rows } = await pool.query( + `INSERT INTO buildings (name, adresse, baujahr, stockwerke, etagenhoehe, wohneinheiten, quadratmeter) + VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING *`, + [name, String(b.adresse).trim(), Number(b.baujahr), Number(b.stockwerke), + Number(b.etagenhoehe), Number(b.wohneinheiten), Number(b.quadratmeter)], + ) + res.json({ building: mapBuilding(rows[0]) }) + } catch (e) { next(e) } +}) + +app.patch('/api/buildings/:name', auth, adminOnly, async (req, res, next) => { + try { + const { rows } = await pool.query('SELECT * FROM buildings WHERE name = $1', [req.params.name]) + if (!rows.length) return res.status(404).json({ fehler: 'Haus nicht gefunden.' }) + const alt = mapBuilding(rows[0]) + const b = { ...alt, ...(req.body || {}) } + const fehler = pruefeHaus(b) + if (fehler.length) return res.status(400).json({ fehler: fehler.join(' ') }) + const neuName = String(b.name).trim() + if (neuName !== alt.name) { + const vorhanden = await pool.query('SELECT 1 FROM buildings WHERE name = $1', [neuName]) + if (vorhanden.rows.length) return res.status(400).json({ fehler: 'Dieser Hausname ist bereits vergeben.' }) + } + const client = await pool.connect() + try { + await client.query('BEGIN') + await client.query( + `UPDATE buildings SET name = $1, adresse = $2, baujahr = $3, stockwerke = $4, + etagenhoehe = $5, wohneinheiten = $6, quadratmeter = $7 WHERE name = $8`, + [neuName, String(b.adresse).trim(), Number(b.baujahr), Number(b.stockwerke), + Number(b.etagenhoehe), Number(b.wohneinheiten), Number(b.quadratmeter), req.params.name], + ) + // Haus umbenannt → Bezüge in Wohnungen und Zählern mitziehen + if (neuName !== alt.name) { + await client.query('UPDATE units SET building = $1 WHERE building = $2', [neuName, alt.name]) + await client.query('UPDATE meters SET building = $1 WHERE building = $2', [neuName, alt.name]) + } + await client.query('COMMIT') + } catch (e) { + await client.query('ROLLBACK') + throw e + } finally { + client.release() + } + const { rows: neu } = await pool.query('SELECT * FROM buildings WHERE name = $1', [neuName]) + res.json({ building: mapBuilding(neu[0]) }) + } catch (e) { next(e) } +}) + +app.post('/api/units', auth, adminOnly, async (req, res, next) => { + try { + const u = req.body || {} + const building = String(u.building || '').trim() + const { rows: h } = await pool.query('SELECT 1 FROM buildings WHERE name = $1', [building]) + if (!h.length) return res.status(400).json({ fehler: 'Haus unbekannt – bitte erst anlegen.' }) + if (!String(u.number || '').trim()) return res.status(400).json({ fehler: 'Wohnungsnummer fehlt.' }) + const floor = Number(u.floor) + const size = Number(u.size) + const coldRent = Number(u.coldRent) + const warmRent = Number(u.warmRent) + if (!Number.isInteger(floor) || floor < -2 || floor > 50) return res.status(400).json({ fehler: 'Etage ungültig.' }) + if (!Number.isFinite(size) || size <= 0 || size > 5000) return res.status(400).json({ fehler: 'Wohnfläche ungültig.' }) + if (!Number.isFinite(coldRent) || coldRent < 0) return res.status(400).json({ fehler: 'Kaltmiete ungültig.' }) + if (!Number.isFinite(warmRent) || warmRent < 0) return res.status(400).json({ fehler: 'Warmmiete ungültig.' }) + const status = u.status === 'frei' ? 'frei' : 'vermietet' + const id = `u-${Date.now()}` + const { rows } = await pool.query( + `INSERT INTO units (id, building, number, floor, size, cold_rent, warm_rent, tenant, status) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) RETURNING *`, + [id, building, String(u.number).trim(), floor, size, coldRent, warmRent, + String(u.tenant || '').trim() || null, status], + ) + res.json({ unit: mapUnit(rows[0]) }) + } catch (e) { next(e) } +}) + +app.patch('/api/units/:id', auth, adminOnly, async (req, res, next) => { + try { + const { rows } = await pool.query('SELECT * FROM units WHERE id = $1', [req.params.id]) + if (!rows.length) return res.status(404).json({ fehler: 'Wohnung nicht gefunden.' }) + const alt = mapUnit(rows[0]) + const u = { ...alt, ...(req.body || {}) } + const building = String(u.building || '').trim() + const { rows: h } = await pool.query('SELECT 1 FROM buildings WHERE name = $1', [building]) + if (!h.length) return res.status(400).json({ fehler: 'Haus unbekannt – bitte erst anlegen.' }) + if (!String(u.number || '').trim()) return res.status(400).json({ fehler: 'Wohnungsnummer fehlt.' }) + const floor = Number(u.floor) + const size = Number(u.size) + const coldRent = Number(u.coldRent) + const warmRent = Number(u.warmRent) + if (!Number.isInteger(floor) || floor < -2 || floor > 50) return res.status(400).json({ fehler: 'Etage ungültig.' }) + if (!Number.isFinite(size) || size <= 0 || size > 5000) return res.status(400).json({ fehler: 'Wohnfläche ungültig.' }) + if (!Number.isFinite(coldRent) || coldRent < 0) return res.status(400).json({ fehler: 'Kaltmiete ungültig.' }) + if (!Number.isFinite(warmRent) || warmRent < 0) return res.status(400).json({ fehler: 'Warmmiete ungültig.' }) + const status = u.status === 'frei' ? 'frei' : 'vermietet' + const { rows: neu } = await pool.query( + `UPDATE units SET building = $1, number = $2, floor = $3, size = $4, cold_rent = $5, + warm_rent = $6, tenant = $7, status = $8 WHERE id = $9 RETURNING *`, + [building, String(u.number).trim(), floor, size, coldRent, warmRent, + String(u.tenant || '').trim() || null, status, req.params.id], + ) + res.json({ unit: mapUnit(neu[0]) }) + } catch (e) { next(e) } +}) + // ── Nachrichten (Threads) ──────────────────────────────────── app.post('/api/threads', auth, async (req, res, next) => { try { @@ -614,6 +792,46 @@ app.post('/api/codes', auth, adminOnly, async (req, res, next) => { } catch (e) { next(e) } }) +// ── KI-Antworten (Rückkanal aus n8n) ───────────────────────── +app.post('/api/ki-antwort', async (req, res, next) => { + try { + if (!KI_API_TOKEN || req.headers['x-api-token'] !== KI_API_TOKEN) + return res.status(401).json({ fehler: 'Ungültiger Token.' }) + const { threadId, text } = req.body || {} + const antwort = String(text || '').trim() + if (!antwort) return res.status(400).json({ fehler: 'Antworttext fehlt.' }) + const { rows: threadRows } = await pool.query('SELECT * FROM threads WHERE id = $1', [String(threadId || '')]) + const th = threadRows[0] + if (!th) return res.status(404).json({ fehler: 'Unterhaltung nicht gefunden.' }) + const autor = 'Hinterstein Immobilien (KI-Assistent)' + await pool.query( + `INSERT INTO messages (id, thread_id, from_role, author, text, at) VALUES ($1,$2,'admin',$3,$4,$5)`, + [`${th.id}-ki-${Date.now()}`, th.id, autor, antwort, deutschesDatumZeit()], + ) + await pool.query( + 'UPDATE threads SET unread_by_admin = FALSE, unread_by_tenant = TRUE WHERE id = $1', [th.id], + ) + // Mieter per Mail benachrichtigen (wie eine Antwort der Verwaltung) + const betreff = `Antwort der Verwaltung: ${th.subject}` + const { rows: einheitRows } = await pool.query('SELECT * FROM units WHERE id = $1', [th.unit_id]) + const textVersion = `Neue Nachricht von ${autor} in der Unterhaltung „${th.subject}“:\n\n„${antwort}“` + const html = mail.baueHtmlMail({ + titel: 'Antwort der Verwaltung', + untertitel: th.subject, + einleitung: `Neue Nachricht von ${autor}:`, + zeilen: [ + ['Wohnung', mail.einheitLabel(mapUnit(einheitRows[0]))], + ['Unterhaltung', th.subject], + ], + zitat: antwort, + aktionenHtml: mail.buttonHtml('Zum Mieterportal', `${process.env.PORTAL_URL || 'https://mieterportal.hocks.eu'}/nachrichten`), + }) + mail.benachrichtigeMieter(th.unit_id, betreff, textVersion, html) + console.log(`[ki] Antwort eingespielt in Thread ${th.id}`) + res.json({ ok: true }) + } catch (e) { next(e) } +}) + // ── Fehlerbehandlung ───────────────────────────────────────── app.use((err, _req, res, _next) => { console.error('[api]', err) @@ -643,6 +861,8 @@ async function start() { if (geseedet) console.log('[api] Demo-Daten eingespielt (50 Wohnungen, 552 Buchungen, …)') const zaehlerGeseedet = await seedeZaehler() if (zaehlerGeseedet) console.log('[api] Zähler-Demo-Daten eingespielt') + const haeuserGeseedet = await seedeHaeuser() + if (haeuserGeseedet) console.log('[api] Haus-Demo-Daten eingespielt') setInterval(sessionsAufraeumen, 60 * 60 * 1000).unref() app.listen(PORT, () => console.log(`[api] mp-api lauscht auf :${PORT}`)) } diff --git a/server/src/seed-data.js b/server/src/seed-data.js index 14babe4..7cf6e26 100644 --- a/server/src/seed-data.js +++ b/server/src/seed-data.js @@ -168,4 +168,10 @@ const meters = [ { id: 'z-9', unit_id: null, building: 'Ringweg 3', nummer: 'ST-HAUS-0007', art: 'Strom', stand: 88420.3, stand_am: '15.09.2026', geaendert_am: '15.09.2026, 09:45' }, ] -module.exports = { users, registrationCodes, tickets, documents, threads, announcements, meters } +const buildings = [ + { name: 'Hauptstraße 12', adresse: 'Hauptstraße 12', baujahr: 1968, stockwerke: 4, etagenhoehe: 2.65, wohneinheiten: 20, quadratmeter: 1570.0 }, + { name: 'Ringweg 3', adresse: 'Ringweg 3', baujahr: 1955, stockwerke: 3, etagenhoehe: 2.80, wohneinheiten: 15, quadratmeter: 1110.0 }, + { name: 'Am Park 5', adresse: 'Am Park 5', baujahr: 2019, stockwerke: 3, etagenhoehe: 2.60, wohneinheiten: 15, quadratmeter: 1110.0 }, +] + +module.exports = { users, registrationCodes, tickets, documents, threads, announcements, meters, buildings } diff --git a/server/src/seed.js b/server/src/seed.js index a2f59eb..84bb716 100644 --- a/server/src/seed.js +++ b/server/src/seed.js @@ -5,7 +5,7 @@ */ const { pool } = require('./db') -const { users, registrationCodes, tickets, documents, threads, announcements, meters } = require('./seed-data') +const { users, registrationCodes, tickets, documents, threads, announcements, meters, buildings } = require('./seed-data') const generated = require('../seed/generated.json') async function seed() { @@ -124,3 +124,20 @@ async function seedeZaehler() { } module.exports = { seed, seedeZaehler } + +/** Haus-Demo-Daten: idempotent – füllt die buildings-Tabelle nur, wenn sie + * leer ist (auch nachträglich auf bestehenden Datenbanken, z. B. v1.10). */ +async function seedeHaeuser() { + const { rows } = await pool.query('SELECT COUNT(*)::int AS n FROM buildings') + if (rows[0].n > 0) return false + for (const h of buildings) { + await pool.query( + `INSERT INTO buildings (name, adresse, baujahr, stockwerke, etagenhoehe, wohneinheiten, quadratmeter) + VALUES ($1,$2,$3,$4,$5,$6,$7)`, + [h.name, h.adresse, h.baujahr, h.stockwerke, h.etagenhoehe, h.wohneinheiten, h.quadratmeter], + ) + } + return true +} + +module.exports = { seed, seedeZaehler, seedeHaeuser } diff --git a/src/App.tsx b/src/App.tsx index 56ae332..cbc01de 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -9,6 +9,7 @@ import Tickets from './pages/Tickets' import Documents from './pages/Documents' import Messages from './pages/Messages' import Zaehler from './pages/Zaehler' +import Bestand from './pages/Bestand' import BenutzerVerwaltung from './pages/BenutzerVerwaltung' /** Fängt Render-Fehler ab – statt schwarzem Bildschirm: Hinweis + Reload. */ @@ -82,6 +83,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/components/AppLayout.tsx b/src/components/AppLayout.tsx index 19ce239..c76762d 100644 --- a/src/components/AppLayout.tsx +++ b/src/components/AppLayout.tsx @@ -37,7 +37,9 @@ export default function AppLayout() { const alleNavItems = role === 'admin' - ? [...navItems, { to: '/benutzer', label: 'Benutzerverwaltung', icon: UserCog }] + ? [...navItems, + { to: '/bestand', label: 'Häuser & Wohnungen', icon: Building2 }, + { to: '/benutzer', label: 'Benutzerverwaltung', icon: UserCog }] : navItems return ( diff --git a/src/pages/Bestand.tsx b/src/pages/Bestand.tsx new file mode 100644 index 0000000..f01934f --- /dev/null +++ b/src/pages/Bestand.tsx @@ -0,0 +1,413 @@ +import { useMemo, useState } from 'react' +import { Building2, Home, MapPin, CalendarDays, ArrowUpDown, LayoutGrid, Ruler, Pencil, Plus, Users } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, + DialogFooter, +} from '@/components/ui/dialog' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { useStore, fmtEUR } from '../store/store' +import type { Building, Unit } from '../types' + +/** Komma oder Punkt als Dezimaltrenner akzeptieren. */ +const zahl = (s: string) => Number(s.replace(',', '.')) + +function HausDialog({ haus }: { haus?: Building }) { + const { addHaus, updateHaus } = useStore() + const bearbeiten = Boolean(haus) + const [open, setOpen] = useState(false) + const [name, setName] = useState(haus?.name ?? '') + const [adresse, setAdresse] = useState(haus?.adresse ?? '') + const [baujahr, setBaujahr] = useState(String(haus?.baujahr ?? '')) + const [stockwerke, setStockwerke] = useState(String(haus?.stockwerke ?? '')) + const [etagenhoehe, setEtagenhoehe] = useState(String(haus?.etagenhoehe ?? '').replace('.', ',')) + const [wohneinheiten, setWohneinheiten] = useState(String(haus?.wohneinheiten ?? '')) + const [quadratmeter, setQuadratmeter] = useState(String(haus?.quadratmeter ?? '').replace('.', ',')) + const [sende, setSende] = useState(false) + const [fehler, setFehler] = useState(null) + + const submit = async () => { + if (sende) return + setSende(true) + setFehler(null) + const payload = { + name: name.trim(), + adresse: adresse.trim(), + baujahr: zahl(baujahr), + stockwerke: zahl(stockwerke), + etagenhoehe: zahl(etagenhoehe), + wohneinheiten: zahl(wohneinheiten), + quadratmeter: zahl(quadratmeter), + } + const err = bearbeiten ? await updateHaus(haus!.name, payload) : await addHaus(payload) + setSende(false) + if (err) return setFehler(err) + setOpen(false) + } + + return ( + + + {bearbeiten ? ( + + ) : ( + + )} + + + + {bearbeiten ? `Haus „${haus!.name}" bearbeiten` : 'Neues Haus anlegen'} + +
+
+
+ + setName(e.target.value)} /> +
+
+ + setAdresse(e.target.value)} /> +
+
+
+
+ + setBaujahr(e.target.value)} /> +
+
+ + setStockwerke(e.target.value)} /> +
+
+
+
+ + setEtagenhoehe(e.target.value)} /> +
+
+ + setWohneinheiten(e.target.value)} /> +
+
+ + setQuadratmeter(e.target.value)} /> +
+
+ {fehler && ( +
+ {fehler} +
+ )} +
+ + + + +
+
+ ) +} + +function WohnungDialog({ wohnung, gebaeude }: { wohnung?: Unit; gebaeude: string[] }) { + const { addWohnung, updateWohnung } = useStore() + const bearbeiten = Boolean(wohnung) + const [open, setOpen] = useState(false) + const [building, setBuilding] = useState(wohnung?.building ?? gebaeude[0] ?? '') + const [number, setNumber] = useState(wohnung?.number ?? '') + const [floor, setFloor] = useState(String(wohnung?.floor ?? '0')) + const [size, setSize] = useState(wohnung ? String(wohnung.size).replace('.', ',') : '') + const [coldRent, setColdRent] = useState(wohnung ? String(wohnung.coldRent).replace('.', ',') : '') + const [warmRent, setWarmRent] = useState(wohnung ? String(wohnung.warmRent).replace('.', ',') : '') + const [tenant, setTenant] = useState(wohnung?.tenant ?? '') + const [status, setStatus] = useState<'vermietet' | 'frei'>(wohnung?.status ?? 'frei') + const [sende, setSende] = useState(false) + const [fehler, setFehler] = useState(null) + + const submit = async () => { + if (sende) return + setSende(true) + setFehler(null) + const payload = { + building, + number: number.trim(), + floor: zahl(floor), + size: zahl(size), + coldRent: zahl(coldRent), + warmRent: zahl(warmRent), + tenant: tenant.trim(), + status, + } + const err = bearbeiten ? await updateWohnung(wohnung!.id, payload) : await addWohnung(payload) + setSende(false) + if (err) return setFehler(err) + setOpen(false) + } + + return ( + + + {bearbeiten ? ( + + ) : ( + + )} + + + + {bearbeiten ? `Wohnung ${wohnung!.building}, Whg. ${wohnung!.number} bearbeiten` : 'Neue Wohnung anlegen'} + +
+
+
+ + +
+
+ + setNumber(e.target.value)} /> +
+
+
+
+ + setFloor(e.target.value)} /> +
+
+ + setSize(e.target.value)} /> +
+
+
+
+ + setColdRent(e.target.value)} /> +
+
+ + setWarmRent(e.target.value)} /> +
+
+
+
+ + setTenant(e.target.value)} /> +
+
+ + +
+
+ {fehler && ( +
+ {fehler} +
+ )} +
+ + + + +
+
+ ) +} + +export default function Bestand() { + const { buildings, units } = useStore() + const [filterHaus, setFilterHaus] = useState('alle') + + const gebaeudeNamen = useMemo( + () => buildings.map((b) => b.name).sort(), + [buildings], + ) + + const sichtbareWohnungen = useMemo( + () => + units + .filter((u) => filterHaus === 'alle' || u.building === filterHaus) + .sort((a, b) => a.building.localeCompare(b.building) || a.number.localeCompare(b.number, 'de-DE', { numeric: true })), + [units, filterHaus], + ) + + const anzahlJeHaus = useMemo(() => { + const z: Record = {} + for (const u of units) z[u.building] = (z[u.building] || 0) + 1 + return z + }, [units]) + + return ( +
+
+
+

Häuser & Wohnungen

+

+ Bestand pflegen – Häuser anlegen und Wohnungen verwalten +

+
+
+ + +
+
+ + {/* ── Häuser ── */} +
+

Häuser

+ {buildings.length === 0 ? ( +
+ + Noch keine Häuser angelegt. +
+ ) : ( +
+ {buildings.map((h) => ( +
+
+
+ + + +
+
{h.name}
+
+ {h.adresse} +
+
+
+ +
+
+
+ Baujahr +
+
{h.baujahr}
+
+ Stockwerke +
+
{h.stockwerke}
+
+ Etagenhöhe +
+
{h.etagenhoehe.toLocaleString('de-DE')} m
+
+ Wohneinheiten +
+
+ {h.wohneinheiten} + (angelegt: {anzahlJeHaus[h.name] ?? 0}) +
+
+ Wohnfläche +
+
{h.quadratmeter.toLocaleString('de-DE')} m²
+
+
+ ))} +
+ )} +
+ + {/* ── Wohnungen ── */} +
+
+

Wohnungen

+ +
+ {sichtbareWohnungen.length === 0 ? ( +
+ + Keine Wohnungen für diesen Filter. +
+ ) : ( +
+ {sichtbareWohnungen.map((u) => ( +
+
+
+
+ {u.building}, Whg. {u.number} +
+
+ Etage {u.floor} · {u.size.toLocaleString('de-DE')} m² +
+
+ + {u.status} + +
+
+
+
Kaltmiete
+
{fmtEUR.format(u.coldRent)}
+
+
+
Warmmiete
+
{fmtEUR.format(u.warmRent)}
+
+
+
+ Mieter +
+
{u.tenant ?? '–'}
+
+
+
+ +
+
+ ))} +
+ )} +
+
+ ) +} diff --git a/src/store/store.tsx b/src/store/store.tsx index 6b1857d..291d49c 100644 --- a/src/store/store.tsx +++ b/src/store/store.tsx @@ -31,6 +31,7 @@ import type { Announcement, Meter, ZaehlerArt, + Building, } from '../types' import { apiGet, apiPost, apiPatch, ApiFehler } from '../lib/api' import { sha256, DEMO_PASSWORT } from '../lib/auth' @@ -49,6 +50,7 @@ interface Bootstrap { benutzer: Benutzer[] codes: RegistrierungsCode[] meters: Meter[] + buildings: Building[] } const LEER: Bootstrap = { @@ -61,6 +63,7 @@ const LEER: Bootstrap = { benutzer: [], codes: [], meters: [], + buildings: [], } interface Store extends Bootstrap { @@ -107,6 +110,20 @@ interface Store extends Bootstrap { id: string, z: Partial<{ nummer: string; art: ZaehlerArt; stand: number; unitId: string | null; building: string }>, ) => Promise + // ── Bestand: Häuser & Wohnungen (Verwaltung) ──────────── + addHaus: (h: Omit & { name: string }) => Promise + updateHaus: (name: string, h: Partial) => Promise + addWohnung: (u: { + building: string + number: string + floor: number + size: number + coldRent: number + warmRent: number + tenant: string + status: 'vermietet' | 'frei' + }) => Promise + updateWohnung: (id: string, u: Partial) => Promise } const StoreContext = createContext(null) @@ -294,6 +311,43 @@ export function StoreProvider({ children }: { children: ReactNode }) { return fehlerNachricht(e) } }, + // ── Bestand: Häuser & Wohnungen ───────────────────────── + async addHaus(h) { + try { + const { building } = await apiPost<{ building: Building }>('/api/buildings', h) + setDaten((d) => ({ ...d, buildings: [...d.buildings, building].sort((a, b) => a.name.localeCompare(b.name)) })) + return null + } catch (e) { + return fehlerNachricht(e) + } + }, + async updateHaus(name, h) { + try { + await apiPatch<{ building: Building }>(`/api/buildings/${encodeURIComponent(name)}`, h) + await ladeAlles() // Umbenennung zieht Wohnungen/Zähler mit – komplette Neuladung + return null + } catch (e) { + return fehlerNachricht(e) + } + }, + async addWohnung(u) { + try { + const { unit } = await apiPost<{ unit: Unit }>('/api/units', u) + setDaten((d) => ({ ...d, units: [...d.units, unit] })) + return null + } catch (e) { + return fehlerNachricht(e) + } + }, + async updateWohnung(id, u) { + try { + const { unit } = await apiPatch<{ unit: Unit }>(`/api/units/${id}`, u) + setDaten((d) => ({ ...d, units: d.units.map((x) => (x.id === id ? unit : x)) })) + return null + } catch (e) { + return fehlerNachricht(e) + } + }, }), [daten, bereit, me, role, meineEinheit, ladeAlles]) return {children} diff --git a/src/types/index.ts b/src/types/index.ts index 4e34926..e007fa9 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -98,6 +98,16 @@ export interface Announcement { export type ZaehlerArt = 'Strom' | 'Wasser' | 'Gas' | 'Wärme' | 'Sonstiges' +export interface Building { + name: string + adresse: string + baujahr: number + stockwerke: number + etagenhoehe: number // Deckenhöhe in m + wohneinheiten: number + quadratmeter: number +} + export interface Meter { id: string unitId: string | null // null = Haus-/Allgemeinzähler