v1.10+v1.11: Bestandsverwaltung (Häuser & Wohnungen) + KI-Antworten via n8n/Ollama
This commit is contained in:
@@ -35,6 +35,10 @@ services:
|
|||||||
DATABASE_URL: postgres://mp:${POSTGRES_PASSWORD}@mp-db:5432/mieterportal
|
DATABASE_URL: postgres://mp:${POSTGRES_PASSWORD}@mp-db:5432/mieterportal
|
||||||
MAIL_API_URL: http://mp-mail-api:8080
|
MAIL_API_URL: http://mp-mail-api:8080
|
||||||
MAIL_API_TOKEN: ${MAIL_API_TOKEN}
|
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
|
PORT: 3000
|
||||||
depends_on:
|
depends_on:
|
||||||
- mp-db
|
- mp-db
|
||||||
|
|||||||
@@ -189,6 +189,37 @@ tar -czf mp-dist.tar.gz -C dist .
|
|||||||
|
|
||||||
## 8. Verlauf / offene Punkte
|
## 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.
|
**Erledigt 04.10. (v1.9):** Zähler-Modul + Registrierungs-Mail + getrennter Name.
|
||||||
Neue Tabelle `meters` (Zählernummer, Art Strom/Wasser/Gas/Wärme/Sonstiges,
|
Neue Tabelle `meters` (Zählernummer, Art Strom/Wasser/Gas/Wärme/Sonstiges,
|
||||||
Stand, Stand-Datum, Wohneinheit nullable, Haus, Pflege-Zeitstempel) mit
|
Stand, Stand-Datum, Wohneinheit nullable, Haus, Pflege-Zeitstempel) mit
|
||||||
|
|||||||
@@ -127,6 +127,16 @@ CREATE TABLE IF NOT EXISTS meters (
|
|||||||
geaendert_am TEXT NOT NULL -- letzte Pflege durch die Verwaltung
|
geaendert_am TEXT NOT NULL -- letzte Pflege durch die Verwaltung
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_meters_unit ON meters(unit_id);
|
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() {
|
async function init() {
|
||||||
|
|||||||
+222
-2
@@ -9,17 +9,41 @@
|
|||||||
const express = require('express')
|
const express = require('express')
|
||||||
const crypto = require('crypto')
|
const crypto = require('crypto')
|
||||||
const { pool, init, sessionsAufraeumen } = require('./db')
|
const { pool, init, sessionsAufraeumen } = require('./db')
|
||||||
const { seed, seedeZaehler } = require('./seed')
|
const { seed, seedeZaehler, seedeHaeuser } = require('./seed')
|
||||||
const mail = require('./mail')
|
const mail = require('./mail')
|
||||||
|
|
||||||
const PORT = Number(process.env.PORT || 3000)
|
const PORT = Number(process.env.PORT || 3000)
|
||||||
const SESSION_DAUER_MS = 8 * 60 * 60 * 1000
|
const SESSION_DAUER_MS = 8 * 60 * 60 * 1000
|
||||||
const COOKIE_NAME = 'mp_session'
|
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()
|
const app = express()
|
||||||
app.use(express.json({ limit: '256kb' }))
|
app.use(express.json({ limit: '256kb' }))
|
||||||
app.set('trust proxy', true)
|
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) ──────────────────
|
// ── Cookie-Helfer (bewusst ohne Dependency) ──────────────────
|
||||||
function cookieLesen(req, name) {
|
function cookieLesen(req, name) {
|
||||||
const raw = req.headers.cookie || ''
|
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,
|
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,
|
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 deutschesDatum = () => new Date().toLocaleDateString('de-DE')
|
||||||
const deutschesDatumZeit = () => new Date().toLocaleString('de-DE')
|
const deutschesDatumZeit = () => new Date().toLocaleString('de-DE')
|
||||||
@@ -218,7 +247,7 @@ app.get('/api/bootstrap', auth, async (req, res, next) => {
|
|||||||
try {
|
try {
|
||||||
const istAdmin = req.user.rolle === 'verwaltung'
|
const istAdmin = req.user.rolle === 'verwaltung'
|
||||||
const einheit = req.user.einheit_id
|
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([
|
await Promise.all([
|
||||||
pool.query('SELECT * FROM units ORDER BY id'),
|
pool.query('SELECT * FROM units ORDER BY id'),
|
||||||
istAdmin
|
istAdmin
|
||||||
@@ -246,6 +275,9 @@ app.get('/api/bootstrap', auth, async (req, res, next) => {
|
|||||||
istAdmin
|
istAdmin
|
||||||
? pool.query('SELECT * FROM meters ORDER BY building, nummer')
|
? pool.query('SELECT * FROM meters ORDER BY building, nummer')
|
||||||
: pool.query('SELECT * FROM meters WHERE unit_id = $1 ORDER BY nummer', [einheit]),
|
: 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
|
// Threads mit ihren Nachrichten zusammenführen
|
||||||
const nachrichtenProThread = {}
|
const nachrichtenProThread = {}
|
||||||
@@ -266,6 +298,7 @@ app.get('/api/bootstrap', auth, async (req, res, next) => {
|
|||||||
benutzer: users.rows.map(oeffentlicherBenutzer),
|
benutzer: users.rows.map(oeffentlicherBenutzer),
|
||||||
codes: codes.rows.map(mapCode),
|
codes: codes.rows.map(mapCode),
|
||||||
meters: meters.rows.map(mapMeter),
|
meters: meters.rows.map(mapMeter),
|
||||||
|
buildings: buildings.rows.map(mapBuilding),
|
||||||
})
|
})
|
||||||
} catch (e) { next(e) }
|
} 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`),
|
aktionenHtml: mail.buttonHtml('Zum Mieterportal', `${process.env.PORTAL_URL || 'https://mieterportal.hocks.eu'}/schadensmeldungen`),
|
||||||
})
|
})
|
||||||
mail.benachrichtigeVerwaltung(betreff, text, html)
|
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
|
// Eingangsbestätigung an den Mieter (bei internen Meldungen der
|
||||||
// Verwaltung entfällt sie, da diese keine Mieter-E-Mail auslösen soll)
|
// Verwaltung entfällt sie, da diese keine Mieter-E-Mail auslösen soll)
|
||||||
if (vonRolle === 'tenant') {
|
if (vonRolle === 'tenant') {
|
||||||
@@ -435,6 +482,137 @@ app.patch('/api/meters/:id', auth, adminOnly, async (req, res, next) => {
|
|||||||
res.json({ meter: mapMeter(aktualisiert[0]) })
|
res.json({ meter: mapMeter(aktualisiert[0]) })
|
||||||
} catch (e) { next(e) }
|
} 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) ────────────────────────────────────
|
// ── Nachrichten (Threads) ────────────────────────────────────
|
||||||
app.post('/api/threads', auth, async (req, res, next) => {
|
app.post('/api/threads', auth, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
@@ -614,6 +792,46 @@ app.post('/api/codes', auth, adminOnly, async (req, res, next) => {
|
|||||||
} catch (e) { next(e) }
|
} 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 ─────────────────────────────────────────
|
// ── Fehlerbehandlung ─────────────────────────────────────────
|
||||||
app.use((err, _req, res, _next) => {
|
app.use((err, _req, res, _next) => {
|
||||||
console.error('[api]', err)
|
console.error('[api]', err)
|
||||||
@@ -643,6 +861,8 @@ async function start() {
|
|||||||
if (geseedet) console.log('[api] Demo-Daten eingespielt (50 Wohnungen, 552 Buchungen, …)')
|
if (geseedet) console.log('[api] Demo-Daten eingespielt (50 Wohnungen, 552 Buchungen, …)')
|
||||||
const zaehlerGeseedet = await seedeZaehler()
|
const zaehlerGeseedet = await seedeZaehler()
|
||||||
if (zaehlerGeseedet) console.log('[api] Zähler-Demo-Daten eingespielt')
|
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()
|
setInterval(sessionsAufraeumen, 60 * 60 * 1000).unref()
|
||||||
app.listen(PORT, () => console.log(`[api] mp-api lauscht auf :${PORT}`))
|
app.listen(PORT, () => console.log(`[api] mp-api lauscht auf :${PORT}`))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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' },
|
{ 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 }
|
||||||
|
|||||||
+18
-1
@@ -5,7 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const { pool } = require('./db')
|
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')
|
const generated = require('../seed/generated.json')
|
||||||
|
|
||||||
async function seed() {
|
async function seed() {
|
||||||
@@ -124,3 +124,20 @@ async function seedeZaehler() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { seed, 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 }
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import Tickets from './pages/Tickets'
|
|||||||
import Documents from './pages/Documents'
|
import Documents from './pages/Documents'
|
||||||
import Messages from './pages/Messages'
|
import Messages from './pages/Messages'
|
||||||
import Zaehler from './pages/Zaehler'
|
import Zaehler from './pages/Zaehler'
|
||||||
|
import Bestand from './pages/Bestand'
|
||||||
import BenutzerVerwaltung from './pages/BenutzerVerwaltung'
|
import BenutzerVerwaltung from './pages/BenutzerVerwaltung'
|
||||||
|
|
||||||
/** Fängt Render-Fehler ab – statt schwarzem Bildschirm: Hinweis + Reload. */
|
/** Fängt Render-Fehler ab – statt schwarzem Bildschirm: Hinweis + Reload. */
|
||||||
@@ -82,6 +83,7 @@ export default function App() {
|
|||||||
<Route path="/mietkonto" element={<Payments />} />
|
<Route path="/mietkonto" element={<Payments />} />
|
||||||
<Route path="/schadensmeldungen" element={<Tickets />} />
|
<Route path="/schadensmeldungen" element={<Tickets />} />
|
||||||
<Route path="/zaehler" element={<Zaehler />} />
|
<Route path="/zaehler" element={<Zaehler />} />
|
||||||
|
<Route path="/bestand" element={<Bestand />} />
|
||||||
<Route path="/dokumente" element={<Documents />} />
|
<Route path="/dokumente" element={<Documents />} />
|
||||||
<Route path="/nachrichten" element={<Messages />} />
|
<Route path="/nachrichten" element={<Messages />} />
|
||||||
<Route path="/benutzer" element={<BenutzerVerwaltung />} />
|
<Route path="/benutzer" element={<BenutzerVerwaltung />} />
|
||||||
|
|||||||
@@ -37,7 +37,9 @@ export default function AppLayout() {
|
|||||||
|
|
||||||
const alleNavItems =
|
const alleNavItems =
|
||||||
role === 'admin'
|
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
|
: navItems
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -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<string | null>(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 (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
{bearbeiten ? (
|
||||||
|
<Button variant="outline" size="sm" className="gap-1.5">
|
||||||
|
<Pencil className="h-3.5 w-3.5" /> Bearbeiten
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button className="gap-2">
|
||||||
|
<Plus className="h-4 w-4" /> Neues Haus
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{bearbeiten ? `Haus „${haus!.name}" bearbeiten` : 'Neues Haus anlegen'}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="h-name">Hausname</Label>
|
||||||
|
<Input id="h-name" placeholder="z. B. Lindenallee 8" value={name} onChange={(e) => setName(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="h-adr">Adresse</Label>
|
||||||
|
<Input id="h-adr" placeholder="Straße Nr., PLZ Ort" value={adresse} onChange={(e) => setAdresse(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="h-bau">Baujahr</Label>
|
||||||
|
<Input id="h-bau" inputMode="numeric" placeholder="z. B. 1998" value={baujahr} onChange={(e) => setBaujahr(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="h-stock">Stockwerke</Label>
|
||||||
|
<Input id="h-stock" inputMode="numeric" placeholder="z. B. 3" value={stockwerke} onChange={(e) => setStockwerke(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="h-hoehe">Etagenhöhe (m)</Label>
|
||||||
|
<Input id="h-hoehe" inputMode="decimal" placeholder="2,65" value={etagenhoehe} onChange={(e) => setEtagenhoehe(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="h-whg">Wohneinheiten</Label>
|
||||||
|
<Input id="h-whg" inputMode="numeric" placeholder="z. B. 12" value={wohneinheiten} onChange={(e) => setWohneinheiten(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="h-qm">Quadratmeter (m²)</Label>
|
||||||
|
<Input id="h-qm" inputMode="decimal" placeholder="z. B. 980" value={quadratmeter} onChange={(e) => setQuadratmeter(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{fehler && (
|
||||||
|
<div className="rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700 dark:bg-red-950 dark:text-red-300" role="alert">
|
||||||
|
{fehler}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setOpen(false)}>Abbrechen</Button>
|
||||||
|
<Button onClick={submit} disabled={sende || !name.trim() || !adresse.trim() || !baujahr || !stockwerke || !etagenhoehe || !wohneinheiten || !quadratmeter}>
|
||||||
|
{sende ? 'Wird gespeichert …' : bearbeiten ? 'Speichern' : 'Haus anlegen'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string | null>(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 (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
{bearbeiten ? (
|
||||||
|
<Button variant="outline" size="sm" className="gap-1.5">
|
||||||
|
<Pencil className="h-3.5 w-3.5" /> Bearbeiten
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button className="gap-2">
|
||||||
|
<Plus className="h-4 w-4" /> Neue Wohnung
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{bearbeiten ? `Wohnung ${wohnung!.building}, Whg. ${wohnung!.number} bearbeiten` : 'Neue Wohnung anlegen'}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Haus</Label>
|
||||||
|
<Select value={building} onValueChange={setBuilding}>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{gebaeude.map((g) => (
|
||||||
|
<SelectItem key={g} value={g}>{g}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="w-nr">Wohnungsnummer</Label>
|
||||||
|
<Input id="w-nr" placeholder="z. B. 12" value={number} onChange={(e) => setNumber(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="w-etage">Etage</Label>
|
||||||
|
<Input id="w-etage" inputMode="numeric" placeholder="0 = Erdgeschoss" value={floor} onChange={(e) => setFloor(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="w-qm">Wohnfläche (m²)</Label>
|
||||||
|
<Input id="w-qm" inputMode="decimal" placeholder="z. B. 68,5" value={size} onChange={(e) => setSize(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="w-kalt">Kaltmiete (€)</Label>
|
||||||
|
<Input id="w-kalt" inputMode="decimal" placeholder="z. B. 520" value={coldRent} onChange={(e) => setColdRent(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="w-warm">Warmmiete (€)</Label>
|
||||||
|
<Input id="w-warm" inputMode="decimal" placeholder="z. B. 690" value={warmRent} onChange={(e) => setWarmRent(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="w-mieter">Mieter (Name)</Label>
|
||||||
|
<Input id="w-mieter" placeholder="leer = frei" value={tenant} onChange={(e) => setTenant(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Status</Label>
|
||||||
|
<Select value={status} onValueChange={(v) => setStatus(v as 'vermietet' | 'frei')}>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="vermietet">vermietet</SelectItem>
|
||||||
|
<SelectItem value="frei">frei</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{fehler && (
|
||||||
|
<div className="rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700 dark:bg-red-950 dark:text-red-300" role="alert">
|
||||||
|
{fehler}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setOpen(false)}>Abbrechen</Button>
|
||||||
|
<Button onClick={submit} disabled={sende || !building || !number.trim() || !size || !coldRent || !warmRent}>
|
||||||
|
{sende ? 'Wird gespeichert …' : bearbeiten ? 'Speichern' : 'Wohnung anlegen'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, number> = {}
|
||||||
|
for (const u of units) z[u.building] = (z[u.building] || 0) + 1
|
||||||
|
return z
|
||||||
|
}, [units])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">Häuser & Wohnungen</h1>
|
||||||
|
<p className="text-sm text-slate-500">
|
||||||
|
Bestand pflegen – Häuser anlegen und Wohnungen verwalten
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<WohnungDialog gebaeude={gebaeudeNamen} />
|
||||||
|
<HausDialog />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Häuser ── */}
|
||||||
|
<section className="space-y-3">
|
||||||
|
<h2 className="text-lg font-semibold">Häuser</h2>
|
||||||
|
{buildings.length === 0 ? (
|
||||||
|
<div className="rounded-2xl border border-dashed bg-white p-10 text-center text-sm text-slate-500">
|
||||||
|
<Building2 className="mx-auto mb-3 h-8 w-8 text-slate-300" />
|
||||||
|
Noch keine Häuser angelegt.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{buildings.map((h) => (
|
||||||
|
<div key={h.name} className="rounded-2xl border bg-white p-5 shadow-sm">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="rounded-xl bg-slate-100 p-2">
|
||||||
|
<Building2 className="h-5 w-5 text-slate-600" />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<div className="font-semibold">{h.name}</div>
|
||||||
|
<div className="flex items-center gap-1 text-xs text-slate-500">
|
||||||
|
<MapPin className="h-3 w-3" /> {h.adresse}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<HausDialog haus={h} />
|
||||||
|
</div>
|
||||||
|
<dl className="mt-4 grid grid-cols-2 gap-x-4 gap-y-2 text-xs">
|
||||||
|
<div className="flex items-center gap-1.5 text-slate-500">
|
||||||
|
<CalendarDays className="h-3.5 w-3.5 shrink-0" /> Baujahr
|
||||||
|
</div>
|
||||||
|
<dd className="text-right font-medium text-slate-800">{h.baujahr}</dd>
|
||||||
|
<div className="flex items-center gap-1.5 text-slate-500">
|
||||||
|
<ArrowUpDown className="h-3.5 w-3.5 shrink-0" /> Stockwerke
|
||||||
|
</div>
|
||||||
|
<dd className="text-right font-medium text-slate-800">{h.stockwerke}</dd>
|
||||||
|
<div className="flex items-center gap-1.5 text-slate-500">
|
||||||
|
<Ruler className="h-3.5 w-3.5 shrink-0" /> Etagenhöhe
|
||||||
|
</div>
|
||||||
|
<dd className="text-right font-medium text-slate-800">{h.etagenhoehe.toLocaleString('de-DE')} m</dd>
|
||||||
|
<div className="flex items-center gap-1.5 text-slate-500">
|
||||||
|
<LayoutGrid className="h-3.5 w-3.5 shrink-0" /> Wohneinheiten
|
||||||
|
</div>
|
||||||
|
<dd className="text-right font-medium text-slate-800">
|
||||||
|
{h.wohneinheiten}
|
||||||
|
<span className="text-slate-400"> (angelegt: {anzahlJeHaus[h.name] ?? 0})</span>
|
||||||
|
</dd>
|
||||||
|
<div className="flex items-center gap-1.5 text-slate-500">
|
||||||
|
<Home className="h-3.5 w-3.5 shrink-0" /> Wohnfläche
|
||||||
|
</div>
|
||||||
|
<dd className="text-right font-medium text-slate-800">{h.quadratmeter.toLocaleString('de-DE')} m²</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── Wohnungen ── */}
|
||||||
|
<section className="space-y-3">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<h2 className="text-lg font-semibold">Wohnungen</h2>
|
||||||
|
<Select value={filterHaus} onValueChange={setFilterHaus}>
|
||||||
|
<SelectTrigger className="h-9 w-auto min-w-40"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="alle">Alle Häuser</SelectItem>
|
||||||
|
{gebaeudeNamen.map((g) => (
|
||||||
|
<SelectItem key={g} value={g}>{g}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
{sichtbareWohnungen.length === 0 ? (
|
||||||
|
<div className="rounded-2xl border border-dashed bg-white p-10 text-center text-sm text-slate-500">
|
||||||
|
<Home className="mx-auto mb-3 h-8 w-8 text-slate-300" />
|
||||||
|
Keine Wohnungen für diesen Filter.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{sichtbareWohnungen.map((u) => (
|
||||||
|
<div key={u.id} className="rounded-2xl border bg-white p-5 shadow-sm">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<div className="font-semibold">
|
||||||
|
{u.building}, Whg. {u.number}
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 flex items-center gap-1 text-xs text-slate-500">
|
||||||
|
<Building2 className="h-3 w-3" /> Etage {u.floor} · {u.size.toLocaleString('de-DE')} m²
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Badge
|
||||||
|
className={u.status === 'vermietet' ? 'bg-emerald-100 text-emerald-800' : 'bg-slate-100 text-slate-600'}
|
||||||
|
variant="secondary"
|
||||||
|
>
|
||||||
|
{u.status}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<dl className="mt-3 space-y-1.5 text-xs">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<dt className="text-slate-500">Kaltmiete</dt>
|
||||||
|
<dd className="font-medium text-slate-800">{fmtEUR.format(u.coldRent)}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<dt className="text-slate-500">Warmmiete</dt>
|
||||||
|
<dd className="font-medium text-slate-800">{fmtEUR.format(u.warmRent)}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<dt className="flex items-center gap-1 text-slate-500">
|
||||||
|
<Users className="h-3.5 w-3.5" /> Mieter
|
||||||
|
</dt>
|
||||||
|
<dd className="font-medium text-slate-800">{u.tenant ?? '–'}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
<div className="mt-4">
|
||||||
|
<WohnungDialog wohnung={u} gebaeude={gebaeudeNamen} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -31,6 +31,7 @@ import type {
|
|||||||
Announcement,
|
Announcement,
|
||||||
Meter,
|
Meter,
|
||||||
ZaehlerArt,
|
ZaehlerArt,
|
||||||
|
Building,
|
||||||
} from '../types'
|
} from '../types'
|
||||||
import { apiGet, apiPost, apiPatch, ApiFehler } from '../lib/api'
|
import { apiGet, apiPost, apiPatch, ApiFehler } from '../lib/api'
|
||||||
import { sha256, DEMO_PASSWORT } from '../lib/auth'
|
import { sha256, DEMO_PASSWORT } from '../lib/auth'
|
||||||
@@ -49,6 +50,7 @@ interface Bootstrap {
|
|||||||
benutzer: Benutzer[]
|
benutzer: Benutzer[]
|
||||||
codes: RegistrierungsCode[]
|
codes: RegistrierungsCode[]
|
||||||
meters: Meter[]
|
meters: Meter[]
|
||||||
|
buildings: Building[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const LEER: Bootstrap = {
|
const LEER: Bootstrap = {
|
||||||
@@ -61,6 +63,7 @@ const LEER: Bootstrap = {
|
|||||||
benutzer: [],
|
benutzer: [],
|
||||||
codes: [],
|
codes: [],
|
||||||
meters: [],
|
meters: [],
|
||||||
|
buildings: [],
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Store extends Bootstrap {
|
interface Store extends Bootstrap {
|
||||||
@@ -107,6 +110,20 @@ interface Store extends Bootstrap {
|
|||||||
id: string,
|
id: string,
|
||||||
z: Partial<{ nummer: string; art: ZaehlerArt; stand: number; unitId: string | null; building: string }>,
|
z: Partial<{ nummer: string; art: ZaehlerArt; stand: number; unitId: string | null; building: string }>,
|
||||||
) => Promise<string | null>
|
) => Promise<string | null>
|
||||||
|
// ── Bestand: Häuser & Wohnungen (Verwaltung) ────────────
|
||||||
|
addHaus: (h: Omit<Building, 'name'> & { name: string }) => Promise<string | null>
|
||||||
|
updateHaus: (name: string, h: Partial<Building>) => Promise<string | null>
|
||||||
|
addWohnung: (u: {
|
||||||
|
building: string
|
||||||
|
number: string
|
||||||
|
floor: number
|
||||||
|
size: number
|
||||||
|
coldRent: number
|
||||||
|
warmRent: number
|
||||||
|
tenant: string
|
||||||
|
status: 'vermietet' | 'frei'
|
||||||
|
}) => Promise<string | null>
|
||||||
|
updateWohnung: (id: string, u: Partial<Unit>) => Promise<string | null>
|
||||||
}
|
}
|
||||||
|
|
||||||
const StoreContext = createContext<Store | null>(null)
|
const StoreContext = createContext<Store | null>(null)
|
||||||
@@ -294,6 +311,43 @@ export function StoreProvider({ children }: { children: ReactNode }) {
|
|||||||
return fehlerNachricht(e)
|
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])
|
}), [daten, bereit, me, role, meineEinheit, ladeAlles])
|
||||||
|
|
||||||
return <StoreContext.Provider value={value}>{children}</StoreContext.Provider>
|
return <StoreContext.Provider value={value}>{children}</StoreContext.Provider>
|
||||||
|
|||||||
@@ -98,6 +98,16 @@ export interface Announcement {
|
|||||||
|
|
||||||
export type ZaehlerArt = 'Strom' | 'Wasser' | 'Gas' | 'Wärme' | 'Sonstiges'
|
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 {
|
export interface Meter {
|
||||||
id: string
|
id: string
|
||||||
unitId: string | null // null = Haus-/Allgemeinzähler
|
unitId: string | null // null = Haus-/Allgemeinzähler
|
||||||
|
|||||||
Reference in New Issue
Block a user