diff --git a/docker-compose.yml b/docker-compose.yml
index 2c09d89..3530c3b 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -41,6 +41,10 @@ services:
KI_API_TOKEN: ${KI_API_TOKEN:-}
# Beleg-Import aus SparkReceipt (Shared Token für den Webhook)
SPARKRECEIPT_WEBHOOK_TOKEN: ${SPARKRECEIPT_WEBHOOK_TOKEN:-}
+ # KI-Assistent (v1.37): N8N-Webhook + gemeinsame Geheimnisse
+ N8N_ASSIST_WEBHOOK: ${N8N_ASSIST_WEBHOOK:-}
+ ASSIST_SHARED_SECRET: ${ASSIST_SHARED_SECRET:-}
+ ASSIST_API_KEY: ${ASSIST_API_KEY:-}
# PDF-Ablage im Dokumentenmodul (persistiertes Volume)
UPLOAD_DIR: /app/uploads
PORT: 3000
diff --git a/kimi.md b/kimi.md
index 7bcd26f..612518d 100644
--- a/kimi.md
+++ b/kimi.md
@@ -189,6 +189,43 @@ tar -czf mp-dist.tar.gz -C dist .
## 8. Verlauf / offene Punkte
+**Erledigt 04.10. (v1.36):** Mieterverwaltung. Mieter ist ein eigenes Objekt
+1:1 zur Wohnung (Tabelle mieter: Name, Vorname, Telefon, E-Mail, Kalt-/Warmmiete,
+Vertragsstatus aktiv|befristet|gekuendigt inkl. „gekündigt zum/von", Kaution
+Höhe + Art bar|ueberwiesen|sonstiges (Freitext), Zahlung komplett|raten inkl.
+Ratenanzahl, Mietzeitraum Start/Ende). Units schlank: Kaltmiete/Warmmiete/Mieter
+aus API + Formularen entfernt (Spalten bleiben physisch), Status jetzt
+vermietet|leerstand|unklar (Migration: frei→leerstand, units.tenant→mieter-Rows,
+läuft bei Start automatisch). Server: Mieter-CRUD adminOnly (POST/PATCH/DELETE
+/api/mieter, Anlage/Löschung setzt Unit-Status), Dokument-Upload pro Mieter +
+Kategorie (POST /api/mieter/:id/dokumente?kategorie=, 1 Slot je Kategorie:
+Personalausweis, SCHUFA-Auskunft, Bankauszug, Sonstiges; PDF+JPG/PNG; Upload
+ersetzt, DELETE kaskadiert Dateien), Preview liefert Content-Type per Endung
+(Bilder rendern in Lightbox/Thumbnails). Bootstrap: Mieter (Admin alle, Mieter
+nur eigene Wohnung – PII-Schutz). Abrechnung + Mietvertrags-Aktivierung ziehen
+Mietdaten aus mieter-Tabelle. Frontend: Seite /mieter (Listenansicht + Suche,
+Vertrags-/Kaution-Badges), einspaltiger Dialog mit Dokumenten-Thumbnails
+(Mini-iframe → PdfLightbox), Bestand/Wohnungsformular schlank, Dashboard/
+Payments/BenutzerVerwaltung/Mietvertraege umgestellt. Deployt, E2E-getestet
+(CRUD, Sonderfelder, Upload PDF+PNG, Kaskaden, Migration 44 Mieter live).
+
+**Erledigt 04.10. (v1.37):** KI-Assistent (Verwaltung fragt, N8N + Kimi
+antworten). Server (server/src/assist.js): POST /api/assist (auth+adminOnly,
+leitet Frage+Verlauf an N8N_ASSIST_WEBHOOK weiter, x-assist-secret-Header,
+90 s Timeout, erwartet JSON {antwort}) + 6 Tool-Endpunkte
+/api/assist/tools/{leerstaende,mieteinnahmen,offene-schaeden,mieter-kontakt,
+zaehler-anomalien,kosten} geschützt per Header x-assist-key=ASSIST_API_KEY,
+kompakte JSON-Antworten direkt aus Postgres. Frontend: Seite /assistent
+(Chat-UI, Verlauf nur Session, Beispielfragen, Admin-only). Env
+(docker-compose + portal.env auf Server): N8N_ASSIST_WEBHOOK,
+ASSIST_SHARED_SECRET, ASSIST_API_KEY (letztere zwei auf Server gesetzt).
+N8N: importierbarer Workflow n8n/assist-workflow.json (Webhook → AI-Agent mit
+Kimi OpenAI-kompatibel https://api.moonshot.ai/v1, Modell kimi-k2.6 → 6
+HTTP-Tools → Respond {antwort}); Setup-Doku n8n/README-assistent.md.
+Deployt, E2E-getestet (Tools 401/200, /api/assist sauberer 503 ohne Webhook).
+**Offen:** Workflow in N8N importieren + Kimi-Credential + Header-Auth +
+Webhook-URL in portal.env eintragen (Schritte in n8n/README-assistent.md).
+
**Erledigt 04.10. (v1.35):** Mietvertragsmodul (Verwaltung, Navigation
„Mietverträge", Admin-only). Workflow: anlegen → Entwurf-PDF → Versand an
zukünftigen Mieter → Upload unterschriebenen Vertrag → manuell aktivieren
diff --git a/n8n/README-assistent.md b/n8n/README-assistent.md
new file mode 100644
index 0000000..f5b679f
--- /dev/null
+++ b/n8n/README-assistent.md
@@ -0,0 +1,52 @@
+# KI-Assistent (v1.37) – Einrichtung N8N + Kimi
+
+Der Assistent läuft so: **Portal-Chat → N8N-Webhook → Kimi (Moonshot API) mit 6 Portal-Tools → Antwort zurück ins Portal.**
+
+## 1. Voraussetzungen
+
+- N8N läuft auf dem VPS (Container `n8n`).
+- Kimi-API-Key von (Aufladung ab ~5 $; Verbrauch des Assistenten liegt typisch unter 5 €/Monat bei 20 Fragen/Tag, Modell `kimi-k2.6`: ~0,4 US-Cent pro Frage).
+
+## 2. N8N-Workflow importieren
+
+1. N8N öffnen → **Workflows → Import from File** → `assist-workflow.json` aus diesem Ordner.
+2. Nach dem Import drei Platzhalter anpassen:
+
+| Wo | Was |
+|---|---|
+| Webhook-Node → Credentials | **Header Auth** anlegen: Name `x-assist-secret`, Wert = `ASSIST_SHARED_SECRET` |
+| Kimi-Node → Credentials | **OpenAI API**-Credential anlegen: API Key = Moonshot-Key, Base URL `https://api.moonshot.ai/v1` |
+| Alle 6 Tool-Nodes | Im Header `x-assist-key` den Wert `ASSIST_API_KEY` durch das echte Geheimnis ersetzen |
+
+3. Workflow **aktivieren** (Toggle oben rechts). Die Webhook-URL steht dann im Webhook-Node, z. B.
+ `https://n8n.hocks.eu/webhook/mp-assist` – sie wird in Schritt 3 gebraucht.
+
+## 3. Portal-Env auf dem Server ergänzen
+
+In `/opt/mieterportal/portal.env` (Datei auf dem VPS):
+
+```bash
+N8N_ASSIST_WEBHOOK=https://n8n.hocks.eu/webhook/mp-assist
+ASSIST_SHARED_SECRET=
+ASSIST_API_KEY=
+```
+
+Danach: `cd /opt/mieterportal && docker compose up -d` (nimmt die neuen Env-Variablen mit).
+
+## 4. Test
+
+Im Portal als Verwaltung: **KI-Assistent** in der Navigation → „Welche Wohnungen stehen leer?“
+Der erste Aufruf dauert ggf. 10–30 s (Agent lädt Tools).
+
+## Fehlerbilder
+
+| Fehlermeldung im Chat | Ursache |
+|---|---|
+| „noch nicht konfiguriert (N8N_ASSIST_WEBHOOK fehlt)" | Env-Variable fehlt / Container nicht neu gestartet |
+| „Assistent antwortet nicht (N8N Status …)" | Webhook-URL falsch, Workflow nicht aktiv, oder Header-Secret passt nicht |
+| „Assistent hat keine Antwort geliefert" | N8N liefert kein JSON `{ "antwort": "…" }` – Respond-Node prüfen |
+| „braucht zu lange" | Kimi-Timeout > 90 s – Modell/Credential prüfen |
+
+## Datenschutz-Hinweis
+
+Das Tool `mieter_kontakt` liefert personenbezogene Daten an Kimi (Serverstandort Singapur, Training laut Anbieter-AGB möglich). Wer das vermeiden will, entfernt das Tool einfach aus dem AI-Agenten – alle anderen Tools liefern nur Bestands- und Zahlen-Daten.
diff --git a/n8n/assist-workflow.json b/n8n/assist-workflow.json
new file mode 100644
index 0000000..d19b901
--- /dev/null
+++ b/n8n/assist-workflow.json
@@ -0,0 +1,287 @@
+{
+ "name": "Mieterportal – KI-Assistent (Kimi)",
+ "nodes": [
+ {
+ "parameters": {
+ "httpMethod": "POST",
+ "path": "mp-assist",
+ "authentication": "headerAuth",
+ "respondWhen": "lastNode",
+ "options": {}
+ },
+ "id": "a1b2c3d4-0001-4000-8000-000000000001",
+ "name": "Webhook (Portal)",
+ "type": "n8n-nodes-base.webhook",
+ "typeVersion": 2,
+ "position": [240, 520],
+ "credentials": {
+ "httpHeaderAuth": {
+ "id": "ASSIST_HEADER_AUTH",
+ "name": "mp-assist-secret"
+ }
+ }
+ },
+ {
+ "parameters": {
+ "text": "={{ $json.frage }}",
+ "options": {
+ "systemMessage": "Du bist der KI-Assistent des Mieterportals „Hinterstein Immobilien\". Du antwortest ausschließlich der Verwaltung auf Deutsch, kurz und sachlich. Beantworte Fragen zum Wohnungsbestand, Mietern, Mieteinnahmen, Zählern, Kosten und Schadensmeldungen mit den bereitgestellten Tools. Rufe immer zuerst die passenden Tools ab, bevor du Zahlen nennst – halluziniere keine Daten. Wenn ein Tool keine Treffer liefert, sage das offen. Rechne mit den Tool-Ergebnissen weiter (Summen, Anteile), wenn es hilft. Personenbezogene Daten (Telefon, E-Mail) nur nennen, wenn explizit danach gefragt wird."
+ }
+ },
+ "id": "a1b2c3d4-0002-4000-8000-000000000002",
+ "name": "AI-Agent",
+ "type": "@n8n/n8n-nodes-langchain.agent",
+ "typeVersion": 1.7,
+ "position": [460, 520]
+ },
+ {
+ "parameters": {
+ "model": "kimi-k2.6",
+ "options": {
+ "baseURL": "https://api.moonshot.ai/v1"
+ }
+ },
+ "id": "a1b2c3d4-0003-4000-8000-000000000003",
+ "name": "Kimi (OpenAI-kompatibel)",
+ "type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
+ "typeVersion": 1,
+ "position": [460, 720],
+ "credentials": {
+ "openAiApi": {
+ "id": "MOONSHOT_API_KEY",
+ "name": "Moonshot (Kimi) API"
+ }
+ }
+ },
+ {
+ "parameters": {
+ "name": "leerstaende",
+ "description": "Liste der leerstehenden oder unklaren Wohneinheiten mit Haus, Nummer, Etage und Fläche. Keine Parameter nötig.",
+ "method": "GET",
+ "url": "https://mieterportal.hocks.eu/api/assist/tools/leerstaende",
+ "authentication": "none",
+ "sendHeaders": true,
+ "headerParameters": {
+ "parameters": [
+ { "name": "x-assist-key", "value": "ASSIST_API_KEY" }
+ ]
+ },
+ "sendQuery": false,
+ "options": {}
+ },
+ "id": "a1b2c3d4-0004-4000-8000-000000000004",
+ "name": "Tool: Leerstände",
+ "type": "@n8n/n8n-nodes-langchain.toolHttpRequest",
+ "typeVersion": 4.2,
+ "position": [700, 160]
+ },
+ {
+ "parameters": {
+ "name": "mieteinnahmen",
+ "description": "Monatliches Miet-Soll (Warm- und Kaltmieten aller Mieter), gesamt und je Haus. Optional Parameter: haus (Teil des Hausnamens, z. B. Straße).",
+ "method": "GET",
+ "url": "https://mieterportal.hocks.eu/api/assist/tools/mieteinnahmen",
+ "authentication": "none",
+ "sendHeaders": true,
+ "headerParameters": {
+ "parameters": [
+ { "name": "x-assist-key", "value": "ASSIST_API_KEY" }
+ ]
+ },
+ "sendQuery": true,
+ "queryParameters": {
+ "parameters": [
+ { "name": "haus", "value": "" }
+ ]
+ },
+ "options": {}
+ },
+ "id": "a1b2c3d4-0005-4000-8000-000000000005",
+ "name": "Tool: Mieteinnahmen",
+ "type": "@n8n/n8n-nodes-langchain.toolHttpRequest",
+ "typeVersion": 4.2,
+ "position": [700, 280]
+ },
+ {
+ "parameters": {
+ "name": "offene_schaeden",
+ "description": "Offene Schadensmeldungen mit Titel, Kategorie, Priorität, Status, Mieter und Wohnung, nach Dringlichkeit sortiert. Keine Parameter nötig.",
+ "method": "GET",
+ "url": "https://mieterportal.hocks.eu/api/assist/tools/offene-schaeden",
+ "authentication": "none",
+ "sendHeaders": true,
+ "headerParameters": {
+ "parameters": [
+ { "name": "x-assist-key", "value": "ASSIST_API_KEY" }
+ ]
+ },
+ "sendQuery": false,
+ "options": {}
+ },
+ "id": "a1b2c3d4-0006-4000-8000-000000000006",
+ "name": "Tool: Schäden",
+ "type": "@n8n/n8n-nodes-langchain.toolHttpRequest",
+ "typeVersion": 4.2,
+ "position": [700, 400]
+ },
+ {
+ "parameters": {
+ "name": "mieter_kontakt",
+ "description": "Kontaktdaten (Telefon, E-Mail) eines Mieters. Genau einen Parameter angeben: name (Nach- oder Vorname) oder wohnung (Hausadresse oder Wohnungsnummer).",
+ "method": "GET",
+ "url": "https://mieterportal.hocks.eu/api/assist/tools/mieter-kontakt",
+ "authentication": "none",
+ "sendHeaders": true,
+ "headerParameters": {
+ "parameters": [
+ { "name": "x-assist-key", "value": "ASSIST_API_KEY" }
+ ]
+ },
+ "sendQuery": true,
+ "queryParameters": {
+ "parameters": [
+ { "name": "name", "value": "" },
+ { "name": "wohnung", "value": "" }
+ ]
+ },
+ "options": {}
+ },
+ "id": "a1b2c3d4-0007-4000-8000-000000000007",
+ "name": "Tool: Mieterkontakt",
+ "type": "@n8n/n8n-nodes-langchain.toolHttpRequest",
+ "typeVersion": 4.2,
+ "position": [700, 520]
+ },
+ {
+ "parameters": {
+ "name": "zaehler_anomalien",
+ "description": "Zähler mit Auffälligkeiten im neuesten Verlaufseintrag (Rücklauf, Stillstand, Verbrauchsspitze etc.) mit Zählernummer, Art, Haus, Stand, Datum und Grund. Keine Parameter nötig.",
+ "method": "GET",
+ "url": "https://mieterportal.hocks.eu/api/assist/tools/zaehler-anomalien",
+ "authentication": "none",
+ "sendHeaders": true,
+ "headerParameters": {
+ "parameters": [
+ { "name": "x-assist-key", "value": "ASSIST_API_KEY" }
+ ]
+ },
+ "sendQuery": false,
+ "options": {}
+ },
+ "id": "a1b2c3d4-0008-4000-8000-000000000008",
+ "name": "Tool: Zähleranomalien",
+ "type": "@n8n/n8n-nodes-langchain.toolHttpRequest",
+ "typeVersion": 4.2,
+ "position": [700, 640]
+ },
+ {
+ "parameters": {
+ "name": "kosten",
+ "description": "Kosten eines Jahres, gesamt und je Haus/Kostenart, aufgeteilt in umlegbar und nicht umlegbar. Parameter: jahr (Pflicht, z. B. 2026), optional haus (Teil des Hausnamens).",
+ "method": "GET",
+ "url": "https://mieterportal.hocks.eu/api/assist/tools/kosten",
+ "authentication": "none",
+ "sendHeaders": true,
+ "headerParameters": {
+ "parameters": [
+ { "name": "x-assist-key", "value": "ASSIST_API_KEY" }
+ ]
+ },
+ "sendQuery": true,
+ "queryParameters": {
+ "parameters": [
+ { "name": "jahr", "value": "" },
+ { "name": "haus", "value": "" }
+ ]
+ },
+ "options": {}
+ },
+ "id": "a1b2c3d4-0009-4000-8000-000000000009",
+ "name": "Tool: Kosten",
+ "type": "@n8n/n8n-nodes-langchain.toolHttpRequest",
+ "typeVersion": 4.2,
+ "position": [700, 760]
+ },
+ {
+ "parameters": {
+ "respondWith": "json",
+ "responseBody": "={{ { antwort: $json.output } }}"
+ },
+ "id": "a1b2c3d4-0010-4000-8000-000000000010",
+ "name": "Antwort an Portal",
+ "type": "n8n-nodes-base.respondToWebhook",
+ "typeVersion": 1.1,
+ "position": [680, 900]
+ }
+ ],
+ "connections": {
+ "Webhook (Portal)": {
+ "main": [
+ [
+ {
+ "node": "AI-Agent",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ },
+ "AI-Agent": {
+ "ai_languageModel": [
+ [
+ {
+ "node": "Kimi (OpenAI-kompatibel)",
+ "type": "ai_languageModel",
+ "index": 0
+ }
+ ]
+ ],
+ "ai_tool": [
+ [
+ {
+ "node": "Tool: Leerstände",
+ "type": "ai_tool",
+ "index": 0
+ },
+ {
+ "node": "Tool: Mieteinnahmen",
+ "type": "ai_tool",
+ "index": 0
+ },
+ {
+ "node": "Tool: Schäden",
+ "type": "ai_tool",
+ "index": 0
+ },
+ {
+ "node": "Tool: Mieterkontakt",
+ "type": "ai_tool",
+ "index": 0
+ },
+ {
+ "node": "Tool: Zähleranomalien",
+ "type": "ai_tool",
+ "index": 0
+ },
+ {
+ "node": "Tool: Kosten",
+ "type": "ai_tool",
+ "index": 0
+ }
+ ]
+ ],
+ "main": [
+ [
+ {
+ "node": "Antwort an Portal",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ }
+ },
+ "settings": {
+ "executionOrder": "v1"
+ }
+}
diff --git a/server/src/abrechnung.js b/server/src/abrechnung.js
index eb8a4d6..63e88da 100644
--- a/server/src/abrechnung.js
+++ b/server/src/abrechnung.js
@@ -66,19 +66,27 @@ async function abrechnungsDaten(pool, unitId, jahr) {
}
const summe = [...jeArt.values()].reduce((s, p) => s + p.betrag, 0)
- const vorauszahlung = (Number(einheit.warm_rent) - Number(einheit.cold_rent)) * 12
+ // Vorauszahlung aus dem Mieterobjekt (seit v1.36, vorher am Wohnungsobjekt)
+ const { rows: mieterRows } = await pool.query(
+ 'SELECT name, vorname, kaltmiete, warmmiete FROM mieter WHERE unit_id = $1', [einheit.id],
+ )
+ const nkVorauszahlung = mieterRows.length
+ ? Number(mieterRows[0].warmmiete) - Number(mieterRows[0].kaltmiete)
+ : 0
+ const mieterName = mieterRows.length ? `${mieterRows[0].vorname} ${mieterRows[0].name}`.trim() : ''
+ const vorauszahlung = nkVorauszahlung * 12
const differenz = summe - vorauszahlung
const { rows: zaehler } = await pool.query(
`SELECT nummer, art, stand, stand_am FROM meters WHERE unit_id = $1 ORDER BY art, nummer`,
[einheit.id],
)
- return { einheit, jahr, positionen: [...jeArt.values()], summe, vorauszahlung, differenz, zaehler }
+ return { einheit, jahr, positionen: [...jeArt.values()], summe, vorauszahlung, differenz, zaehler, nkVorauszahlung, mieterName }
}
/** Zeichnet das PDF und liefert den Dateinamen (im UPLOAD_DIR). */
async function bauePdf(daten) {
- const { einheit, jahr, positionen, summe, vorauszahlung, differenz, zaehler } = daten
+ const { einheit, jahr, positionen, summe, vorauszahlung, differenz, zaehler, nkVorauszahlung, mieterName } = daten
const dateiName = `ab-${einheit.id}-${jahr}.pdf`
const vollerPfad = path.join(UPLOAD_DIR, dateiName)
@@ -111,7 +119,7 @@ async function bauePdf(daten) {
y += 16
doc.text(`Objekt: ${einheit.building} · Wohnung ${einheit.number} · ${zahl(einheit.size)} m²`, 50, y)
y += 16
- doc.text(`Mieter: ${einheit.tenant || '–'}`, 50, y)
+ doc.text(`Mieter: ${mieterName || '–'}`, 50, y)
y += 16
doc.text(`Erstellt am ${dDatum()}`, 50, y)
y += 26
@@ -152,7 +160,7 @@ async function bauePdf(daten) {
y += 17
}
zeile('Summe umlagefähige Kosten', eur(summe), true)
- zeile(`Nebenkostenvorauszahlung (${eur(Number(einheit.warm_rent) - Number(einheit.cold_rent))}/Monat × 12)`, '- ' + eur(vorauszahlung))
+ zeile(`Nebenkostenvorauszahlung (${eur(nkVorauszahlung)}/Monat × 12)`, '- ' + eur(vorauszahlung))
y += 4
doc.moveTo(320, y - 4).lineTo(545, y - 4).lineWidth(0.5).strokeColor('#cbd5e1').stroke()
const nachzahlung = differenz > 0
diff --git a/server/src/assist.js b/server/src/assist.js
new file mode 100644
index 0000000..f85f50d
--- /dev/null
+++ b/server/src/assist.js
@@ -0,0 +1,223 @@
+/** KI-Assistent (v1.37): Verwaltung fragt im Portal, N8N + Kimi antworten.
+ *
+ * Ablauf:
+ * Chat-UI → POST /api/assist (Session, adminOnly)
+ * → Portal leitet Frage + Verlauf an den N8N-Webhook weiter
+ * → N8N (AI-Agent) ruft die Tool-Endpunkte dieses Moduls ab
+ * → Antwort zurück an das Portal → Chat-UI
+ *
+ * Umgebungsvariablen:
+ * N8N_ASSIST_WEBHOOK Webhook-URL des N8N-Assist-Workflows (Portal → N8N)
+ * ASSIST_SHARED_SECRET gemeinsames Geheimnis (Header x-assist-secret, Portal → N8N)
+ * ASSIST_API_KEY gemeinsames Geheimnis (Header x-assist-key, N8N → Portal-Tools)
+ */
+
+const { pool } = require('./db')
+
+const N8N_ASSIST_WEBHOOK = process.env.N8N_ASSIST_WEBHOOK || ''
+const ASSIST_SHARED_SECRET = process.env.ASSIST_SHARED_SECRET || ''
+const ASSIST_API_KEY = process.env.ASSIST_API_KEY || ''
+
+/** Holt die aktuellsten Einträge je Zähler (DISTINCT ON, seq absteigend). */
+async function letzteEintraege(onlyAnomalien) {
+ const { rows } = await pool.query(
+ `SELECT DISTINCT ON (r.meter_id) r.meter_id, r.stand, r.stand_am, r.anomalie, r.anomalie_grund
+ FROM meter_readings r ORDER BY r.meter_id, r.seq DESC`,
+ )
+ return onlyAnomalien ? rows.filter((r) => r.anomalie) : rows
+}
+
+/** Registriert alle Assist-Routen auf der Express-App. */
+function assistRoutenRegistrieren(app, { auth, adminOnly }) {
+ // Middleware für die Tool-Endpunkte: N8N authentifiziert sich per Header.
+ const assistKey = (req, res, next) => {
+ if (!ASSIST_API_KEY) {
+ return res.status(503).json({ fehler: 'Assistent nicht konfiguriert (ASSIST_API_KEY fehlt).' })
+ }
+ if (req.headers['x-assist-key'] !== ASSIST_API_KEY) {
+ return res.status(401).json({ fehler: 'Ungültiger Assistent-Schlüssel.' })
+ }
+ next()
+ }
+
+ // ── Chat: Frage der Verwaltung an N8N weiterleiten ──────────
+ app.post('/api/assist', auth, adminOnly, async (req, res, next) => {
+ try {
+ if (!N8N_ASSIST_WEBHOOK) {
+ return res.status(503).json({
+ fehler: 'Der KI-Assistent ist noch nicht konfiguriert (N8N_ASSIST_WEBHOOK fehlt).',
+ })
+ }
+ const frage = String(req.body?.frage || '').trim()
+ if (!frage) return res.status(400).json({ fehler: 'Frage fehlt.' })
+ const verlauf = Array.isArray(req.body?.verlauf)
+ ? req.body.verlauf
+ .filter((m) => m && ['user', 'assistant'].includes(m.rolle) && String(m.text || '').trim())
+ .slice(-20)
+ .map((m) => ({ rolle: m.rolle, text: String(m.text).slice(0, 4000) }))
+ : []
+ const antwort = await fetch(N8N_ASSIST_WEBHOOK, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json; charset=utf-8',
+ ...(ASSIST_SHARED_SECRET ? { 'x-assist-secret': ASSIST_SHARED_SECRET } : {}),
+ },
+ body: JSON.stringify({
+ frage,
+ verlauf,
+ benutzer: req.user.name,
+ portal: process.env.PORTAL_URL || 'https://mieterportal.hocks.eu',
+ }),
+ signal: AbortSignal.timeout(90_000),
+ })
+ if (!antwort.ok) {
+ return res.status(502).json({ fehler: `Assistent antwortet nicht (N8N Status ${antwort.status}).` })
+ }
+ const inhaltTyp = antwort.headers.get('content-type') || ''
+ const daten = inhaltTyp.includes('application/json') ? await antwort.json() : {}
+ const text = String(daten.antwort ?? '').trim()
+ if (!text) return res.status(502).json({ fehler: 'Assistent hat keine Antwort geliefert.' })
+ res.json({ antwort: text })
+ } catch (e) {
+ if (e.name === 'TimeoutError' || e.name === 'AbortError') {
+ return res.status(504).json({ fehler: 'Assistent braucht zu lange – bitte erneut versuchen.' })
+ }
+ next(e)
+ }
+ })
+
+ // ── Tools: kompakte JSON-Antworten für den N8N-AI-Agent ─────
+ // Alle Endpunkte liefern bewusst wenige, flache Felder (Token sparen).
+
+ /** Leerstehende / unklare Wohneinheiten. */
+ app.get('/api/assist/tools/leerstaende', assistKey, async (_req, res, next) => {
+ try {
+ const { rows } = await pool.query(
+ `SELECT u.building, u.number, u.floor, u.size, u.status
+ FROM units u
+ WHERE u.status IN ('leerstand', 'unklar')
+ ORDER BY u.building, u.floor, u.number`,
+ )
+ res.json({ anzahl: rows.length, einheiten: rows })
+ } catch (e) { next(e) }
+ })
+
+ /** Monatliches Miet-Soll (Warmmieten aller Mieter), optional je Haus. */
+ app.get('/api/assist/tools/mieteinnahmen', assistKey, async (req, res, next) => {
+ try {
+ const haus = String(req.query.haus || '').trim()
+ const params = []
+ let bedingung = ''
+ if (haus) { params.push(`%${haus}%`); bedingung = `WHERE u.building ILIKE $1` }
+ const { rows } = await pool.query(
+ `SELECT u.building, COUNT(*)::int AS mieter, SUM(m.warmmiete)::numeric(12,2) AS warmmiete_monat,
+ SUM(m.kaltmiete)::numeric(12,2) AS kaltmiete_monat
+ FROM mieter m JOIN units u ON u.id = m.unit_id
+ ${bedingung}
+ GROUP BY u.building ORDER BY u.building`,
+ params,
+ )
+ res.json({
+ gesamt_warm_monat: rows.reduce((s, r) => s + Number(r.warmmiete_monat), 0).toFixed(2),
+ gesamt_kalt_monat: rows.reduce((s, r) => s + Number(r.kaltmiete_monat), 0).toFixed(2),
+ pro_haus: rows.map((r) => ({
+ haus: r.building, mieter: r.mieter,
+ warm_monat: Number(r.warmmiete_monat), kalt_monat: Number(r.kaltmiete_monat),
+ })),
+ })
+ } catch (e) { next(e) }
+ })
+
+ /** Offene Schadensmeldungen. */
+ app.get('/api/assist/tools/offene-schaeden', assistKey, async (_req, res, next) => {
+ try {
+ const { rows } = await pool.query(
+ `SELECT t.id, t.title, t.category, t.priority, t.status, t.created_at, t.tenant, u.building, u.number
+ FROM tickets t LEFT JOIN units u ON u.id = t.unit_id
+ WHERE t.status <> 'erledigt'
+ ORDER BY CASE t.priority WHEN 'hoch' THEN 0 WHEN 'mittel' THEN 1 ELSE 2 END, t.created_at`,
+ )
+ res.json({ anzahl: rows.length, schaeden: rows })
+ } catch (e) { next(e) }
+ })
+
+ /** Mieterkontakt nach Name oder Wohnung (personenbezogene Daten – bewusst begrenzt). */
+ app.get('/api/assist/tools/mieter-kontakt', assistKey, async (req, res, next) => {
+ try {
+ const name = String(req.query.name || '').trim()
+ const wohnung = String(req.query.wohnung || '').trim()
+ const params = []
+ const teile = []
+ if (name) { params.push(`%${name}%`); teile.push(`(m.name ILIKE $${params.length} OR m.vorname ILIKE $${params.length})`) }
+ if (wohnung) { params.push(`%${wohnung}%`); teile.push(`(u.building ILIKE $${params.length} OR u.number ILIKE $${params.length})`) }
+ if (!teile.length) return res.status(400).json({ fehler: 'Parameter "name" oder "wohnung" erforderlich.' })
+ const { rows } = await pool.query(
+ `SELECT m.vorname, m.name, m.telefon, m.email, u.building, u.number, m.vertragsstatus
+ FROM mieter m JOIN units u ON u.id = m.unit_id
+ WHERE ${teile.join(' AND ')}
+ ORDER BY m.name LIMIT 10`,
+ params,
+ )
+ res.json({ anzahl: rows.length, mieter: rows })
+ } catch (e) { next(e) }
+ })
+
+ /** Zähler mit Anomalie im jeweils neuesten Verlaufseintrag. */
+ app.get('/api/assist/tools/zaehler-anomalien', assistKey, async (_req, res, next) => {
+ try {
+ const anomalien = await letzteEintraege(true)
+ if (!anomalien.length) return res.json({ anzahl: 0, anomalien: [] })
+ const ids = anomalien.map((r) => r.meter_id)
+ const { rows: zaehler } = await pool.query(
+ `SELECT id, building, nummer, art, unit_id FROM meters WHERE id = ANY($1)`, [ids],
+ )
+ const map = new Map(zaehler.map((z) => [z.id, z]))
+ res.json({
+ anzahl: anomalien.length,
+ anomalien: anomalien
+ .map((r) => {
+ const z = map.get(r.meter_id)
+ return z && {
+ zaehlernummer: z.nummer, art: z.art, haus: z.building,
+ stand: Number(r.stand), stand_am: r.stand_am, grund: r.anomalie_grund,
+ }
+ })
+ .filter(Boolean),
+ })
+ } catch (e) { next(e) }
+ })
+
+ /** Kosten je Haus/Jahr, umlegbar vs. nicht umlegbar. */
+ app.get('/api/assist/tools/kosten', assistKey, async (req, res, next) => {
+ try {
+ const jahr = Number(req.query.jahr || new Date().getFullYear())
+ if (!Number.isInteger(jahr) || jahr < 2000 || jahr > 2100) {
+ return res.status(400).json({ fehler: 'Parameter "jahr" ungültig.' })
+ }
+ const haus = String(req.query.haus || '').trim()
+ const params = [jahr]
+ let bedingung = 'WHERE c.jahr = $1'
+ if (haus) { params.push(`%${haus}%`); bedingung += ` AND c.building ILIKE $${params.length}` }
+ const { rows } = await pool.query(
+ `SELECT c.building, c.kostenart, c.umlegbar, SUM(c.betrag)::numeric(12,2) AS betrag
+ FROM costs c ${bedingung}
+ GROUP BY c.building, c.kostenart, c.umlegbar
+ ORDER BY c.building, c.kostenart`,
+ params,
+ )
+ const umlegbar = rows.filter((r) => r.umlegbar).reduce((s, r) => s + Number(r.betrag), 0)
+ const nichtUmlegbar = rows.filter((r) => !r.umlegbar).reduce((s, r) => s + Number(r.betrag), 0)
+ res.json({
+ jahr,
+ gesamt_umlegbar: umlegbar.toFixed(2),
+ gesamt_nicht_umlegbar: nichtUmlegbar.toFixed(2),
+ positionen: rows.map((r) => ({
+ haus: r.building, kostenart: r.kostenart,
+ umlegbar: r.umlegbar, betrag: Number(r.betrag),
+ })),
+ })
+ } catch (e) { next(e) }
+ })
+}
+
+module.exports = { assistRoutenRegistrieren }
diff --git a/server/src/db.js b/server/src/db.js
index 835d34a..e0ea82a 100644
--- a/server/src/db.js
+++ b/server/src/db.js
@@ -19,11 +19,18 @@ CREATE TABLE IF NOT EXISTS units (
number TEXT NOT NULL,
floor INT NOT NULL,
size NUMERIC(8,2) NOT NULL,
- cold_rent NUMERIC(10,2) NOT NULL,
- warm_rent NUMERIC(10,2) NOT NULL,
- tenant TEXT,
- status TEXT NOT NULL CHECK (status IN ('vermietet','frei'))
+ cold_rent NUMERIC(10,2) NOT NULL DEFAULT 0, -- Altbestand, v1.36 in Tabelle mieter ausgelagert
+ warm_rent NUMERIC(10,2) NOT NULL DEFAULT 0, -- dito
+ tenant TEXT, -- dito (Mietername, Altbestand)
+ status TEXT NOT NULL CHECK (status IN ('vermietet','leerstand','unklar'))
);
+ALTER TABLE units ALTER COLUMN cold_rent DROP NOT NULL;
+ALTER TABLE units ALTER COLUMN warm_rent DROP NOT NULL;
+-- v1.36: Statuswerte erweitert (frei → leerstand, zusätzlich unklar)
+ALTER TABLE units DROP CONSTRAINT IF EXISTS units_status_check;
+ALTER TABLE units ADD CONSTRAINT units_status_check
+ CHECK (status IN ('vermietet','leerstand','unklar')) NOT VALID;
+UPDATE units SET status = 'leerstand' WHERE status = 'frei';
CREATE TABLE IF NOT EXISTS transactions (
id TEXT PRIMARY KEY,
@@ -256,6 +263,36 @@ CREATE TABLE IF NOT EXISTS mietvertraege (
geaendert_am TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_mietvertraege_unit ON mietvertraege(unit_id);
+
+-- Mieter (v1.36): 1:1 zum Wohnungsobjekt – Kalt-/Warmmiete, Kontakt, Vertrag,
+-- Kaution und Mietzeitraum leben hier, nicht mehr am Wohnungsobjekt.
+CREATE TABLE IF NOT EXISTS mieter (
+ id TEXT PRIMARY KEY,
+ unit_id TEXT NOT NULL UNIQUE REFERENCES units(id),
+ name TEXT NOT NULL,
+ vorname TEXT NOT NULL DEFAULT '',
+ telefon TEXT NOT NULL DEFAULT '',
+ email TEXT NOT NULL DEFAULT '',
+ kaltmiete NUMERIC(10,2) NOT NULL DEFAULT 0,
+ warmmiete NUMERIC(10,2) NOT NULL DEFAULT 0,
+ vertragsstatus TEXT NOT NULL DEFAULT 'aktiv'
+ CHECK (vertragsstatus IN ('aktiv','befristet','gekuendigt')),
+ gekuendigt_am TEXT NOT NULL DEFAULT '', -- „gekündigt zum"
+ gekuendigt_von TEXT NOT NULL DEFAULT '' -- „gekündigt von": mieter | vermieter
+ CHECK (gekuendigt_von IN ('','mieter','vermieter')),
+ kaution_hoehe NUMERIC(10,2) NOT NULL DEFAULT 0,
+ kaution_art TEXT NOT NULL DEFAULT 'ueberwiesen'
+ CHECK (kaution_art IN ('bar','ueberwiesen','sonstiges')),
+ kaution_sonstiges TEXT NOT NULL DEFAULT '', -- Freitext bei „Andere"
+ kautionszahlung TEXT NOT NULL DEFAULT 'komplett'
+ CHECK (kautionszahlung IN ('komplett','raten')),
+ kaution_raten INT NOT NULL DEFAULT 0, -- Anzahl der Raten
+ mietbeginn_am TEXT NOT NULL DEFAULT '',
+ mietende_am TEXT NOT NULL DEFAULT '',
+ erstellt_am TEXT NOT NULL,
+ geaendert_am TEXT NOT NULL
+);
+CREATE INDEX IF NOT EXISTS idx_mieter_unit ON mieter(unit_id);
`
async function init() {
diff --git a/server/src/index.js b/server/src/index.js
index b2e0ead..50061a8 100644
--- a/server/src/index.js
+++ b/server/src/index.js
@@ -17,6 +17,7 @@ const mail = require('./mail')
const { erzeugeAbrechnung } = require('./abrechnung')
const { erzeugeVertragsPdf, vermieterDaten } = require('./vertrag')
const zaehlerVerlauf = require('./zaehlerVerlauf')
+const { assistRoutenRegistrieren } = require('./assist')
const PORT = Number(process.env.PORT || 3000)
const SESSION_DAUER_MS = 8 * 60 * 60 * 1000
@@ -154,10 +155,22 @@ async function sessionAnlegen(res, userId, req) {
}
// ── Mapping-Helfer (snake_case → camelCase) ──────────────────
+const UNIT_STATUS = ['vermietet', 'leerstand', 'unklar']
const mapUnit = (u) => ({
id: u.id, building: u.building, number: u.number, floor: u.floor,
- size: Number(u.size), coldRent: Number(u.cold_rent), warmRent: Number(u.warm_rent),
- tenant: u.tenant, status: u.status, heizungsart: u.heizungsart ?? '',
+ size: Number(u.size), status: u.status, heizungsart: u.heizungsart ?? '',
+})
+const mapMieter = (m) => ({
+ id: m.id, unitId: m.unit_id, name: m.name, vorname: m.vorname,
+ telefon: m.telefon, email: m.email,
+ kaltmiete: Number(m.kaltmiete), warmmiete: Number(m.warmmiete),
+ vertragsstatus: m.vertragsstatus,
+ gekuendigtAm: m.gekuendigt_am, gekuendigtVon: m.gekuendigt_von,
+ kautionHoehe: Number(m.kaution_hoehe), kautionArt: m.kaution_art,
+ kautionSonstiges: m.kaution_sonstiges,
+ kautionszahlung: m.kautionszahlung, kautionRaten: m.kaution_raten,
+ mietbeginnAm: m.mietbeginn_am, mietendeAm: m.mietende_am,
+ erstelltAm: m.erstellt_am, geaendertAm: m.geaendert_am,
})
const mapTransaction = (t) => ({
id: t.id, unitId: t.unit_id, tenant: t.tenant, month: t.month, year: t.year,
@@ -351,7 +364,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, buildings, costs, belege, mietvertraege] =
+ const [units, transactions, documents, tickets, threads, messages, announcements, users, codes, meters, buildings, costs, belege, mietvertraege, mieter] =
await Promise.all([
pool.query('SELECT * FROM units ORDER BY id'),
istAdmin
@@ -391,6 +404,11 @@ app.get('/api/bootstrap', auth, async (req, res, next) => {
istAdmin
? pool.query('SELECT * FROM mietvertraege ORDER BY erstellt_am DESC')
: { rows: [] },
+ // Mieter-Stammdaten sind personenbezogen: Verwaltung sieht alle,
+ // angemeldete Mieter nur ihren eigenen Datensatz (z. B. Miete im Dashboard).
+ istAdmin
+ ? pool.query('SELECT * FROM mieter ORDER BY name, vorname')
+ : pool.query('SELECT * FROM mieter WHERE unit_id = $1', [einheit]),
])
// Threads mit ihren Nachrichten zusammenführen
const nachrichtenProThread = {}
@@ -415,6 +433,7 @@ app.get('/api/bootstrap', auth, async (req, res, next) => {
kosten: costs.rows.map(mapKosten),
belege: belege.rows.map(mapBeleg),
mietvertraege: mietvertraege.rows.map(mapMietvertrag),
+ mieter: mieter.rows.map(mapMieter),
einstellungen: await vermieterDaten(pool),
})
} catch (e) { next(e) }
@@ -879,13 +898,22 @@ app.get('/api/documents/:id/download', auth, async (req, res, next) => {
} catch (e) { next(e) }
})
-// PDF-Preview für die Lightbox: gleiche Rechte wie Download, aber inline (v1.31)
+/** Content-Type anhand der Dateiendung (PDF + Mieterdokument-Bilder). */
+function mimeTyp(dateiPfad) {
+ const ende = path.extname(dateiPfad || '').toLowerCase()
+ if (ende === '.png') return 'image/png'
+ if (ende === '.jpg' || ende === '.jpeg') return 'image/jpeg'
+ return 'application/pdf'
+}
+
+// Preview für die Lightbox: gleiche Rechte wie Download, aber inline (v1.31);
+// seit v1.36 auch Bilder (Mieterdokumente wie Personalausweis-Scans).
app.get('/api/documents/:id/preview', auth, async (req, res, next) => {
try {
const gefunden = await dokumentMitDatei(req, res)
if (!gefunden) return
res.setHeader('Content-Disposition', `inline; filename*=UTF-8''${encodeURIComponent(gefunden.dokument.file_name || gefunden.dokument.title)}`)
- res.setHeader('Content-Type', 'application/pdf')
+ res.setHeader('Content-Type', mimeTyp(gefunden.pfad))
res.sendFile(gefunden.pfad)
} catch (e) { next(e) }
})
@@ -1343,17 +1371,14 @@ app.post('/api/units', auth, adminOnly, async (req, res, next) => {
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 status = UNIT_STATUS.includes(u.status) ? u.status : 'leerstand'
if (!heizungsartValid(String(u.heizungsart || '').trim())) return res.status(400).json({ fehler: 'Ungültige Heizungsart.' })
const heizung = String(u.heizungsart || '').trim()
const id = `u-${Date.now()}`
const { rows } = await pool.query(
- `INSERT INTO units (id, building, number, floor, size, cold_rent, warm_rent, tenant, status, heizungsart)
- VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) RETURNING *`,
- [id, building, String(u.number).trim(), floor, size, coldRent, warmRent,
- String(u.tenant || '').trim() || null, status, heizung],
+ `INSERT INTO units (id, building, number, floor, size, status, heizungsart)
+ VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING *`,
+ [id, building, String(u.number).trim(), floor, size, status, heizung],
)
res.json({ unit: mapUnit(rows[0]) })
} catch (e) { next(e) }
@@ -1371,20 +1396,15 @@ app.patch('/api/units/:id', auth, adminOnly, async (req, res, next) => {
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 status = UNIT_STATUS.includes(u.status) ? u.status : 'leerstand'
if (!heizungsartValid(String(u.heizungsart || '').trim())) return res.status(400).json({ fehler: 'Ungültige Heizungsart.' })
const heizung = String(u.heizungsart || '').trim()
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, heizungsart = $9 WHERE id = $10 RETURNING *`,
- [building, String(u.number).trim(), floor, size, coldRent, warmRent,
- String(u.tenant || '').trim() || null, status, heizung, req.params.id],
+ `UPDATE units SET building = $1, number = $2, floor = $3, size = $4,
+ status = $5, heizungsart = $6 WHERE id = $7 RETURNING *`,
+ [building, String(u.number).trim(), floor, size, status, heizung, req.params.id],
)
res.json({ unit: mapUnit(neu[0]) })
} catch (e) { next(e) }
@@ -1407,6 +1427,7 @@ app.delete('/api/units/:id', auth, adminOnly, async (req, res, next) => {
['registration_codes', 'einheit_id', 'Registrierungscodes'],
['belege', 'unit_id', 'Belege'],
['mietvertraege', 'unit_id', 'Mietverträge'],
+ ['mieter', 'unit_id', 'Mieter'],
]) {
const { rows: r } = await pool.query(`SELECT COUNT(*)::int AS n FROM ${tabelle} WHERE ${spalte} = $1`, [req.params.id])
if (r[0].n > 0) blocker.push(`${r[0].n} ${label}`)
@@ -1419,6 +1440,174 @@ app.delete('/api/units/:id', auth, adminOnly, async (req, res, next) => {
} catch (e) { next(e) }
})
+// ── Mieter (v1.36) ───────────────────────────────────────────
+const VERTRAGSSTATUS = ['aktiv', 'befristet', 'gekuendigt']
+const KAUTION_ARTEN = ['bar', 'ueberwiesen', 'sonstiges']
+const KAUTIONSZAHLUNG = ['komplett', 'raten']
+const MIETER_DOKUMENT_KATEGORIEN = ['Personalausweis', 'SCHUFA-Auskunft', 'Bankauszug', 'Sonstiges']
+
+/** Uploads für Mieterdokumente: PDF + Bilder (Scan vom Handy).
+ * Dateiname pro Mieter + Kategorie eindeutig: -. */
+const uploadDokument = multer({
+ storage: multer.diskStorage({
+ destination: UPLOAD_DIR,
+ filename: (req, file, cb) => {
+ const kategorie = String(req.query.kategorie || 'sonstiges').toLowerCase().replace(/[^a-z0-9]+/g, '-')
+ const ende = path.extname(file.originalname || '').toLowerCase()
+ const endung = ['.pdf', '.jpg', '.jpeg', '.png'].includes(ende) ? ende : '.pdf'
+ cb(null, `${req.params.id}-${kategorie}${endung}`)
+ },
+ }),
+ limits: { fileSize: UPLOAD_MAX_BYTES, files: 1 },
+ fileFilter: (_req, file, cb) => {
+ const ok = ['application/pdf', 'image/jpeg', 'image/png'].includes(file.mimetype)
+ || /\.(pdf|jpe?g|png)$/i.test(file.originalname || '')
+ cb(ok ? null : new Error('Nur PDF- und Bilddateien (JPG/PNG) sind erlaubt.'), ok)
+ },
+})
+
+async function mieterGueltig(b, res) {
+ const unitId = String(b.unitId || '').trim()
+ const { rows: u } = await pool.query('SELECT * FROM units WHERE id = $1', [unitId])
+ if (!u.length) { res.status(400).json({ fehler: 'Wohnung unbekannt.' }); return null }
+ const name = String(b.name || '').trim()
+ if (!name) { res.status(400).json({ fehler: 'Name des Mieters fehlt.' }); return null }
+ const kaltmiete = Number(b.kaltmiete ?? 0)
+ const warmmiete = Number(b.warmmiete ?? 0)
+ const kautionHoehe = Number(b.kautionHoehe ?? 0)
+ if (!Number.isFinite(kaltmiete) || kaltmiete < 0) { res.status(400).json({ fehler: 'Kaltmiete ungültig.' }); return null }
+ if (!Number.isFinite(warmmiete) || warmmiete < 0) { res.status(400).json({ fehler: 'Warmmiete ungültig.' }); return null }
+ if (!Number.isFinite(kautionHoehe) || kautionHoehe < 0) { res.status(400).json({ fehler: 'Kautionshöhe ungültig.' }); return null }
+ const vertragsstatus = VERTRAGSSTATUS.includes(b.vertragsstatus) ? b.vertragsstatus : 'aktiv'
+ const gekuendigtVon = ['', 'mieter', 'vermieter'].includes(b.gekuendigtVon) ? b.gekuendigtVon : ''
+ const kautionArt = KAUTION_ARTEN.includes(b.kautionArt) ? b.kautionArt : 'ueberwiesen'
+ const kautionszahlung = KAUTIONSZAHLUNG.includes(b.kautionszahlung) ? b.kautionszahlung : 'komplett'
+ const kautionRaten = Number(b.kautionRaten ?? 0)
+ const datum = (wert) => datumDeutsch(wert) || String(wert || '').trim()
+ return {
+ unitId, einheit: u[0], name,
+ vorname: String(b.vorname || '').trim(),
+ telefon: String(b.telefon || '').trim(),
+ email: String(b.email || '').trim(),
+ kaltmiete, warmmiete, vertragsstatus,
+ gekuendigtAm: vertragsstatus === 'gekuendigt' ? datum(b.gekuendigtAm) : '',
+ gekuendigtVon: vertragsstatus === 'gekuendigt' ? gekuendigtVon : '',
+ kautionHoehe, kautionArt,
+ kautionSonstiges: kautionArt === 'sonstiges' ? String(b.kautionSonstiges || '').trim() : '',
+ kautionszahlung,
+ kautionRaten: kautionszahlung === 'raten' && Number.isInteger(kautionRaten) && kautionRaten > 0 ? kautionRaten : 0,
+ mietbeginnAm: datum(b.mietbeginnAm), mietendeAm: datum(b.mietendeAm),
+ }
+}
+
+app.post('/api/mieter', auth, adminOnly, async (req, res, next) => {
+ try {
+ const g = await mieterGueltig(req.body || {}, res)
+ if (!g) return
+ const { rows: belegt } = await pool.query('SELECT 1 FROM mieter WHERE unit_id = $1', [g.unitId])
+ if (belegt.length) return res.status(400).json({ fehler: 'Zu dieser Wohnung ist bereits ein Mieter angelegt.' })
+ const id = `mi-${Date.now()}`
+ const { rows } = await pool.query(
+ `INSERT INTO mieter (id, unit_id, name, vorname, telefon, email, kaltmiete, warmmiete,
+ vertragsstatus, gekuendigt_am, gekuendigt_von,
+ kaution_hoehe, kaution_art, kaution_sonstiges, kautionszahlung, kaution_raten,
+ mietbeginn_am, mietende_am, erstellt_am, geaendert_am)
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$19) RETURNING *`,
+ [id, g.unitId, g.name, g.vorname, g.telefon, g.email, g.kaltmiete, g.warmmiete,
+ g.vertragsstatus, g.gekuendigtAm, g.gekuendigtVon,
+ g.kautionHoehe, g.kautionArt, g.kautionSonstiges, g.kautionszahlung, g.kautionRaten,
+ g.mietbeginnAm, g.mietendeAm, deutschesDatum()],
+ )
+ // Wohnung folgt dem Mieter-Status
+ await pool.query('UPDATE units SET status = $1 WHERE id = $2', ['vermietet', g.unitId])
+ res.json({ mieter: mapMieter(rows[0]) })
+ } catch (e) { next(e) }
+})
+
+app.patch('/api/mieter/:id', auth, adminOnly, async (req, res, next) => {
+ try {
+ const { rows: alt } = await pool.query('SELECT * FROM mieter WHERE id = $1', [req.params.id])
+ if (!alt.length) return res.status(404).json({ fehler: 'Mieter nicht gefunden.' })
+ const g = await mieterGueltig({ ...mapMieter(alt[0]), ...(req.body || {}) }, res)
+ if (!g) return
+ const { rows: belegt } = await pool.query('SELECT 1 FROM mieter WHERE unit_id = $1 AND id <> $2', [g.unitId, req.params.id])
+ if (belegt.length) return res.status(400).json({ fehler: 'Die gewählte Wohnung ist bereits anderweitig vergeben.' })
+ const { rows } = await pool.query(
+ `UPDATE mieter SET unit_id=$1, name=$2, vorname=$3, telefon=$4, email=$5, kaltmiete=$6, warmmiete=$7,
+ vertragsstatus=$8, gekuendigt_am=$9, gekuendigt_von=$10,
+ kaution_hoehe=$11, kaution_art=$12, kaution_sonstiges=$13, kautionszahlung=$14, kaution_raten=$15,
+ mietbeginn_am=$16, mietende_am=$17, geaendert_am=$18
+ WHERE id=$19 RETURNING *`,
+ [g.unitId, g.name, g.vorname, g.telefon, g.email, g.kaltmiete, g.warmmiete,
+ g.vertragsstatus, g.gekuendigtAm, g.gekuendigtVon,
+ g.kautionHoehe, g.kautionArt, g.kautionSonstiges, g.kautionszahlung, g.kautionRaten,
+ g.mietbeginnAm, g.mietendeAm, deutschesDatum(), req.params.id],
+ )
+ res.json({ mieter: mapMieter(rows[0]) })
+ } catch (e) { next(e) }
+})
+
+app.delete('/api/mieter/:id', auth, adminOnly, async (req, res, next) => {
+ try {
+ const { rows } = await pool.query('DELETE FROM mieter WHERE id = $1 RETURNING unit_id', [req.params.id])
+ if (!rows.length) return res.status(404).json({ fehler: 'Mieter nicht gefunden.' })
+ await pool.query('UPDATE units SET status = $1 WHERE id = $2', ['leerstand', rows[0].unit_id])
+ // Mieterdokumente (Scans/Ausweise) mitlöschen: DB-Zeilen + Dateien
+ const { rows: dokus } = await pool.query(
+ `DELETE FROM documents WHERE id LIKE 'd-md-' || $1 || '-%' RETURNING file_path`, [req.params.id],
+ )
+ for (const d of dokus) {
+ if (d.file_path) {
+ try { fs.unlinkSync(path.join(UPLOAD_DIR, path.basename(String(d.file_path)))) } catch { /* Datei bereits weg */ }
+ }
+ }
+ res.json({ ok: true })
+ } catch (e) { next(e) }
+})
+
+// Mieterdokument hochladen (Personalausweis, SCHUFA, Bankauszug …) – pro
+// Mieter und Kategorie gibt es genau einen Slot; erneutes Hochladen ersetzt.
+app.post('/api/mieter/:id/dokumente', auth, adminOnly, async (req, res, next) => {
+ try {
+ const { rows: mieterRows } = await pool.query('SELECT * FROM mieter WHERE id = $1', [req.params.id])
+ if (!mieterRows.length) return res.status(404).json({ fehler: 'Mieter nicht gefunden.' })
+ const m = mieterRows[0]
+ const kategorie = String(req.query.kategorie || 'Sonstiges')
+ if (!MIETER_DOKUMENT_KATEGORIEN.includes(kategorie)) {
+ return res.status(400).json({ fehler: 'Ungültige Dokumentenkategorie.' })
+ }
+ uploadDokument.single('file')(req, res, async (fehler) => {
+ try {
+ if (fehler) return res.status(400).json({ fehler: 'Upload fehlgeschlagen – nur PDF und Bilder (JPG/PNG) bis 25 MB erlaubt.' })
+ if (!req.file) return res.status(400).json({ fehler: 'Keine Datei übertragen.' })
+ const groesseBytes = req.file.size
+ const groesse = groesseBytes > 1024
+ ? `${(groesseBytes / 1024).toLocaleString('de-DE', { maximumFractionDigits: 1 })} KB`
+ : `${groesseBytes} B`
+ const titel = `${kategorie} – ${[m.vorname, m.name].filter(Boolean).join(' ')}`
+ const dokumentId = `d-md-${m.id}-${kategorie.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`
+ const dateiName = req.file.filename // aus multer storage: -.
+ const originalName = String(req.file.originalname || dateiName)
+ const { rows: vorhanden } = await pool.query('SELECT id FROM documents WHERE id = $1', [dokumentId])
+ if (vorhanden.length) {
+ // Ersetzen: auch file_path tauschen (Endung kann von PDF zu Bild wechseln)
+ await pool.query(
+ `UPDATE documents SET title = $1, file_name = $2, size = $3, date = $4, file_path = $5 WHERE id = $6`,
+ [titel, originalName, groesse, deutschesDatum(), dateiName, dokumentId],
+ )
+ } else {
+ await pool.query(
+ `INSERT INTO documents (id, unit_id, title, category, size, date, file_name, file_path)
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8)`,
+ [dokumentId, m.unit_id, titel, kategorie, groesse, deutschesDatum(), originalName, dateiName],
+ )
+ }
+ res.json({ dokument: mapDocument((await pool.query('SELECT * FROM documents WHERE id = $1', [dokumentId])).rows[0]) })
+ } catch (e) { next(e) }
+ })
+ } catch (e) { next(e) }
+})
+
// ── Nachrichten (Threads) ────────────────────────────────────
app.post('/api/threads', auth, async (req, res, next) => {
try {
@@ -1737,8 +1926,14 @@ async function mietvertragGueltig(b, res) {
const { rows: u } = await pool.query('SELECT * FROM units WHERE id = $1', [unitId])
if (!u.length) { res.status(400).json({ fehler: 'Wohnung unbekannt.' }); return null }
const einheit = u[0]
- const kaltmiete = b.kaltmiete !== undefined && b.kaltmiete !== '' ? Number(b.kaltmiete) : Number(einheit.cold_rent)
- const nebenkosten = b.nebenkosten !== undefined && b.nebenkosten !== '' ? Number(b.nebenkosten) : Number(einheit.warm_rent) - Number(einheit.cold_rent)
+ // Miet-Voreinstellungen aus dem Mieterobjekt (seit v1.36; z. B. Nachmietung)
+ const { rows: mieterRows } = await pool.query(
+ 'SELECT kaltmiete, warmmiete FROM mieter WHERE unit_id = $1', [unitId],
+ )
+ const mietKalt = mieterRows.length ? Number(mieterRows[0].kaltmiete) : 0
+ const mietNk = mieterRows.length ? Number(mieterRows[0].warmmiete) - Number(mieterRows[0].kaltmiete) : 0
+ const kaltmiete = b.kaltmiete !== undefined && b.kaltmiete !== '' ? Number(b.kaltmiete) : mietKalt
+ const nebenkosten = b.nebenkosten !== undefined && b.nebenkosten !== '' ? Number(b.nebenkosten) : mietNk
if (!Number.isFinite(kaltmiete) || kaltmiete < 0) { res.status(400).json({ fehler: 'Kaltmiete ungültig.' }); return null }
if (!Number.isFinite(nebenkosten) || nebenkosten < 0) { res.status(400).json({ fehler: 'Nebenkosten ungültig.' }); return null }
const kaution = Number(b.kaution ?? 0)
@@ -1935,11 +2130,31 @@ app.post('/api/mietvertraege/:id/aktivieren', auth, adminOnly, async (req, res,
[benutzerId, benutzername, vertrag.mieter_name, SHA256_0815,
vertrag.mieter_email, einheit.id, deutschesDatum(), vertrag.mieter_telefon || null],
)
- // Wohnung als vermietet führen
- await pool.query(
- `UPDATE units SET tenant = $1, status = 'vermietet' WHERE id = $2`,
- [vertrag.mieter_name, einheit.id],
- )
+ // Mieterobjekt anlegen/aktualisieren + Wohnung als vermietet führen (v1.36)
+ const nameTeile = vertrag.mieter_name.trim().split(/\s+/)
+ const vorname = nameTeile.length > 1 ? nameTeile.slice(0, -1).join(' ') : ''
+ const nachname = nameTeile.length > 1 ? nameTeile[nameTeile.length - 1] : nameTeile[0]
+ const { rows: mieterVorhanden } = await pool.query('SELECT id FROM mieter WHERE unit_id = $1', [einheit.id])
+ if (mieterVorhanden.length) {
+ await pool.query(
+ `UPDATE mieter SET name=$1, vorname=$2, email=$3, telefon=$4, kaltmiete=$5, warmmiete=$6,
+ vertragsstatus='aktiv', mietbeginn_am=$7, geaendert_am=$8 WHERE unit_id=$9`,
+ [nachname, vorname, vertrag.mieter_email, vertrag.mieter_telefon,
+ vertrag.kaltmiete, Number(vertrag.kaltmiete) + Number(vertrag.nebenkosten),
+ vertrag.beginn_am, deutschesDatum(), einheit.id],
+ )
+ } else {
+ const mieterId = `mi-${Date.now()}`
+ await pool.query(
+ `INSERT INTO mieter (id, unit_id, name, vorname, telefon, email, kaltmiete, warmmiete,
+ vertragsstatus, mietbeginn_am, erstellt_am, geaendert_am)
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,'aktiv',$9,$10,$10)`,
+ [mieterId, einheit.id, nachname, vorname, vertrag.mieter_telefon, vertrag.mieter_email,
+ vertrag.kaltmiete, Number(vertrag.kaltmiete) + Number(vertrag.nebenkosten),
+ vertrag.beginn_am, deutschesDatum()],
+ )
+ }
+ await pool.query(`UPDATE units SET status = 'vermietet' WHERE id = $1`, [einheit.id])
const { rows } = await pool.query(
`UPDATE mietvertraege SET status = 'aktiv', benutzer_id = $1, geaendert_am = $2 WHERE id = $3 RETURNING *`,
[benutzerId, deutschesDatum(), req.params.id],
@@ -2032,6 +2247,29 @@ async function haeuserFelderMigrieren() {
if (rows.length) console.log(`[api] Häuser: ${rows.length} Adresse(n) in Einzelfelder zerlegt`)
}
+/** Einmal-Migration v1.36: Mieterdaten aus dem Wohnungsobjekt (Altbestand:
+ * units.tenant/cold_rent/warm_rent) in die neue Tabelle mieter überführen. */
+async function mieterAusUnitsMigrieren() {
+ const { rows } = await pool.query(
+ `SELECT u.* FROM units u
+ WHERE u.tenant IS NOT NULL AND u.tenant <> ''
+ AND NOT EXISTS (SELECT 1 FROM mieter m WHERE m.unit_id = u.id)`,
+ )
+ for (const u of rows) {
+ const teile = String(u.tenant).trim().split(/\s+/)
+ const vorname = teile.length > 1 ? teile.slice(0, -1).join(' ') : ''
+ const nachname = teile.length > 1 ? teile[teile.length - 1] : teile[0]
+ await pool.query(
+ `INSERT INTO mieter (id, unit_id, name, vorname, kaltmiete, warmmiete, vertragsstatus,
+ mietbeginn_am, erstellt_am, geaendert_am)
+ VALUES ($1,$2,$3,$4,$5,$6,'aktiv','',$7,$7)`,
+ [`mi-${Date.now()}-${Math.floor(Math.random() * 1e6)}`, u.id, nachname, vorname,
+ Number(u.cold_rent ?? 0), Number(u.warm_rent ?? 0), deutschesDatum()],
+ )
+ }
+ if (rows.length) console.log(`[api] Mieter: ${rows.length} Datensatz/Datensätze aus Wohnungen übernommen`)
+}
+
async function warteAufDatenbank() {
let letzterFehler = null
for (let versuch = 1; versuch <= 30; versuch++) {
@@ -2060,6 +2298,8 @@ async function start() {
const kostenGeseedet = await seedeKosten()
if (kostenGeseedet) console.log('[api] Kosten-Demo-Daten eingespielt')
await haeuserFelderMigrieren()
+ await mieterAusUnitsMigrieren()
+ assistRoutenRegistrieren(app, { auth, adminOnly })
setInterval(sessionsAufraeumen, 60 * 60 * 1000).unref()
app.listen(PORT, () => console.log(`[api] mp-api lauscht auf :${PORT}`))
}
diff --git a/server/src/seed.js b/server/src/seed.js
index e053b98..dad0c0a 100644
--- a/server/src/seed.js
+++ b/server/src/seed.js
@@ -17,11 +17,24 @@ async function seed() {
await client.query('BEGIN')
for (const u of generated.units) {
+ // v1.36: Wohnung schlank (kein Mieter/Miete mehr am Objekt); Mieter
+ // werden als eigene Datensätze (Tabelle mieter) angelegt.
+ const status = u.status === 'vermietet' ? 'vermietet' : 'leerstand'
await client.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)`,
- [u.id, u.building, u.number, u.floor, u.size, u.coldRent, u.warmRent, u.tenant, u.status],
+ `INSERT INTO units (id, building, number, floor, size, status) VALUES ($1,$2,$3,$4,$5,$6)`,
+ [u.id, u.building, u.number, u.floor, u.size, status],
)
+ if (u.tenant) {
+ const teile = String(u.tenant).trim().split(/\s+/)
+ const vorname = teile.length > 1 ? teile.slice(0, -1).join(' ') : ''
+ const nachname = teile.length > 1 ? teile[teile.length - 1] : teile[0]
+ await client.query(
+ `INSERT INTO mieter (id, unit_id, name, vorname, kaltmiete, warmmiete, vertragsstatus,
+ mietbeginn_am, erstellt_am, geaendert_am)
+ VALUES ($1,$2,$3,$4,$5,$6,'aktiv','',$7,$7)`,
+ [`mi-${u.id}`, u.id, nachname, vorname, Number(u.coldRent ?? 0), Number(u.warmRent ?? 0), new Date().toLocaleDateString('de-DE')],
+ )
+ }
}
for (const t of generated.transactions) {
@@ -32,9 +45,10 @@ async function seed() {
)
}
- // Mieter-Namen in Tickets/Threads aus der units-Tabelle nachziehen
- const { rows: unitRows } = await client.query('SELECT id, tenant FROM units')
- const mieterVon = Object.fromEntries(unitRows.map((u) => [u.id, u.tenant]))
+ // Mieter-Namen in Tickets/Threads aus den Seed-Daten nachziehen
+ const mieterVon = Object.fromEntries(
+ generated.units.filter((u) => u.tenant).map((u) => [u.id, u.tenant]),
+ )
for (const t of tickets) {
await client.query(
diff --git a/src/App.tsx b/src/App.tsx
index 12bd913..e9d32f4 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -12,6 +12,8 @@ import Zaehler from './pages/Zaehler'
import Kosten from './pages/Kosten'
import Bestand from './pages/Bestand'
import Mietvertraege from './pages/Mietvertraege'
+import MieterVerwaltung from './pages/MieterVerwaltung'
+import Assistent from './pages/Assistent'
import BenutzerVerwaltung from './pages/BenutzerVerwaltung'
import Profil from './pages/Profil'
import Anleitung from './pages/Anleitung'
@@ -90,6 +92,8 @@ export default function App() {
} />
} />
} />
+ } />
+ } />
} />
} />
} />
diff --git a/src/components/AppLayout.tsx b/src/components/AppLayout.tsx
index 22fe4f8..1cfd3b3 100644
--- a/src/components/AppLayout.tsx
+++ b/src/components/AppLayout.tsx
@@ -13,6 +13,8 @@ import {
UserRound,
CircleHelp,
FileSignature,
+ Users,
+ Sparkles,
} from 'lucide-react'
import { useStore } from '../store/store'
import ThemeToggle from './ThemeToggle'
@@ -46,8 +48,10 @@ export default function AppLayout() {
role === 'admin'
? [...navItems,
{ to: '/bestand', label: 'Häuser & Wohnungen', icon: Building2 },
+ { to: '/mieter', label: 'Mieterverwaltung', icon: Users },
{ to: '/mietvertraege', label: 'Mietverträge', icon: FileSignature },
- { to: '/benutzer', label: 'Benutzerverwaltung', icon: UserCog }]
+ { to: '/benutzer', label: 'Benutzerverwaltung', icon: UserCog },
+ { to: '/assistent', label: 'KI-Assistent', icon: Sparkles }]
: navItems
return (
diff --git a/src/data/anleitung.ts b/src/data/anleitung.ts
index 36257a5..21e07d2 100644
--- a/src/data/anleitung.ts
+++ b/src/data/anleitung.ts
@@ -165,7 +165,29 @@ export const anleitungAbschnitte: AnleitungAbschnitt[] = [
},
{
titel: 'Wohnungen zuordnen',
- text: 'Jede Wohnung gehört zu einem Haus: Etage, Wohnfläche, Kalt-/Warmmiete, Mieter, Status (vermietet/frei) und optional Heizungsart je Einheit pflegen – bei Neuanlage wird die Heizungsart automatisch aus dem Haus übernommen. Der Bestand erscheint anschließend im Dashboard.',
+ text: 'Jede Wohnung gehört zu einem Haus: Etage, Wohnfläche, Heizungsart und Status (Vermietet, Leerstand oder Unklar) je Einheit pflegen – bei Neuanlage wird die Heizungsart automatisch aus dem Haus übernommen und der Status auf Leerstand gesetzt. Miete und Mieter werden nicht mehr am Wohnungsobjekt gepflegt, sondern in der Mieterverwaltung.',
+ },
+ ],
+ },
+ {
+ gruppe: 'verwaltung',
+ titel: 'Mieterverwaltung',
+ punkte: [
+ {
+ titel: 'Mieter anlegen',
+ text: 'Über „Neuer Mieter" eine Wohneinheit belegen: Name, Vorname, Telefon und E-Mail, Kalt-/Warmmiete, Vertragsstatus, Mietzeitraum und Kaution (Höhe, Art Bar/Überwiesen/Andere, Zahlung komplett oder in Raten). Beim Speichern wird die Wohnung automatisch auf „Vermietet" gesetzt.',
+ },
+ {
+ titel: 'Kündigung pflegen',
+ text: 'Bei Vertragsstatus „Gekündigt" erscheinen die Felder „Gekündigt zum" (Datum) und „Gekündigt von" (Mieter oder Vermieter).',
+ },
+ {
+ titel: 'Dokumente hochladen',
+ text: 'Im Bearbeiten-Dialog je Kategorie (Personalausweis, SCHUFA-Auskunft, Bankauszug, Sonstiges) einen Scan oder ein PDF hochladen – die Miniaturvorschau öffnet per Klick die Lightbox mit Druck- und E-Mail-Option. Ein erneuter Upload ersetzt die Datei der Kategorie.',
+ },
+ {
+ titel: 'Mieter löschen',
+ text: 'In der Listenansicht löschbar; die Wohnung wird dabei automatisch wieder auf „Leerstand" gesetzt.',
},
],
},
@@ -227,6 +249,24 @@ export const anleitungAbschnitte: AnleitungAbschnitt[] = [
},
],
},
+ {
+ gruppe: 'verwaltung',
+ titel: 'KI-Assistent',
+ punkte: [
+ {
+ titel: 'Fragen stellen',
+ text: 'Im Modul „KI-Assistent" Fragen auf Deutsch stellen, z. B. „Welche Wohnungen stehen leer?" oder „Wie hoch ist das monatliche Miet-Soll?". Der Assistent beantwortet sie über Kimi mit Live-Zugriff auf Bestand, Mieter, Zähler, Kosten und Schäden.',
+ },
+ {
+ titel: 'Zugrunde liegende Daten prüfen',
+ text: 'Antworten werden aus den Portal-Daten erzeugt, können aber fehlerhaft sein. Kritische Zahlen und Personendaten bitte im jeweiligen Modul gegenprüfen.',
+ },
+ {
+ titel: 'Voraussetzungen & Kosten',
+ text: 'Der Assistent läuft über Ihren n8n-Container und die Kimi-API (Moonshot). Die Einrichtung ist in der Projektdatei n8n/README-assistent.md beschrieben; der typische Verbrauch liegt bei wenigen Euro pro Monat. Ohne konfigurierten Webhook meldet der Assistent, dass er noch nicht einsatzbereit ist.',
+ },
+ ],
+ },
{
gruppe: 'verwaltung',
titel: 'Dokumente & Zählerstände',
diff --git a/src/pages/Anleitung.tsx b/src/pages/Anleitung.tsx
index e5f8a1a..8e42da5 100644
--- a/src/pages/Anleitung.tsx
+++ b/src/pages/Anleitung.tsx
@@ -15,6 +15,7 @@ import {
Wallet,
Wrench,
FileSignature,
+ Sparkles,
} from 'lucide-react'
import { useStore } from '../store/store'
import {
@@ -36,6 +37,8 @@ const Icons: Record = {
'Mein Profil': UserRound,
Benutzerverwaltung: Users,
'Häuser & Wohnungen': Building2,
+ Mieterverwaltung: Users,
+ 'KI-Assistent': Sparkles,
'Mietverträge': FileSignature,
'Steuer & Kosten': Calculator,
'Schadensmeldungen & Kommunikation': MessagesSquare,
diff --git a/src/pages/Assistent.tsx b/src/pages/Assistent.tsx
new file mode 100644
index 0000000..ecc70e7
--- /dev/null
+++ b/src/pages/Assistent.tsx
@@ -0,0 +1,168 @@
+import { useRef, useState } from 'react'
+import { Bot, CircleAlert, Eraser, Send, Sparkles, UserRound } from 'lucide-react'
+import { Button } from '@/components/ui/button'
+import { apiPost } from '../lib/api'
+import { useStore } from '../store/store'
+
+interface Nachricht {
+ rolle: 'user' | 'assistant'
+ text: string
+}
+
+const BEISPIELE = [
+ 'Welche Wohnungen stehen leer?',
+ 'Wie hoch ist das monatliche Miet-Soll?',
+ 'Gibt es offene Schadensmeldungen?',
+ 'Bei welchen Zählern gab es Auffälligkeiten?',
+ 'Wie hoch waren die umlegbaren Kosten 2026?',
+]
+
+/** KI-Assistent (v1.37): Verwaltung fragt, N8N + Kimi antworten.
+ * Der Verlauf lebt nur in dieser Sitzung (keine Speicherung). */
+export default function Assistent() {
+ const { role } = useStore()
+ const [nachrichten, setNachrichten] = useState([])
+ const [eingabe, setEingabe] = useState('')
+ const [sende, setSende] = useState(false)
+ const [fehler, setFehler] = useState(null)
+ const endeRef = useRef(null)
+
+ const senden = async (frage: string) => {
+ const text = frage.trim()
+ if (!text || sende) return
+ setSende(true)
+ setFehler(null)
+ const verlauf = [...nachrichten, { rolle: 'user' as const, text }]
+ setNachrichten(verlauf)
+ setEingabe('')
+ try {
+ const { antwort } = await apiPost<{ antwort: string }>('/api/assist', {
+ frage: text,
+ verlauf: verlauf.slice(-20),
+ })
+ setNachrichten((x) => [...x, { rolle: 'assistant', text: antwort }])
+ } catch (e) {
+ setFehler(e instanceof Error ? e.message : 'Assistent antwortet nicht.')
+ // Nutzerfrage zurücknehmen, damit der Verlauf nicht kippt
+ setNachrichten((x) => x.slice(0, -1))
+ } finally {
+ setSende(false)
+ setTimeout(() => endeRef.current?.scrollIntoView({ behavior: 'smooth' }), 50)
+ }
+ }
+
+ if (role !== 'admin') {
+ return (
+
+ Der KI-Assistent steht nur der Verwaltung zur Verfügung.
+
+ )
+ }
+
+ return (
+
+
+
+
+ KI-Assistent
+
+
+ Fragen an das Portal – beantwortet von Kimi über N8N mit Live-Zugriff auf Bestand, Mieter,
+ Zähler, Kosten und Schäden. Antworten können fehlerhaft sein – kritische Daten bitte prüfen.
+
+
+ {nachrichten.length > 0 && (
+
+ )}
+
+
+ {/* Nachrichtenbereich */}
+
+ {nachrichten.length === 0 ? (
+
+
+
+ Stellen Sie eine Frage zu Ihrem Bestand – zum Beispiel:
+