v1.36: Mieterverwaltung (Mieter 1:1 pro Wohnung, Kaution/Vertrag/Dokumente) + v1.37: KI-Assistent (N8N + Kimi, 6 Portal-Tools)

This commit is contained in:
2026-10-04 20:51:00 +02:00
parent 9e74813e22
commit 9d14d367ee
22 changed files with 1840 additions and 107 deletions
+4
View File
@@ -41,6 +41,10 @@ services:
KI_API_TOKEN: ${KI_API_TOKEN:-} KI_API_TOKEN: ${KI_API_TOKEN:-}
# Beleg-Import aus SparkReceipt (Shared Token für den Webhook) # Beleg-Import aus SparkReceipt (Shared Token für den Webhook)
SPARKRECEIPT_WEBHOOK_TOKEN: ${SPARKRECEIPT_WEBHOOK_TOKEN:-} 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) # PDF-Ablage im Dokumentenmodul (persistiertes Volume)
UPLOAD_DIR: /app/uploads UPLOAD_DIR: /app/uploads
PORT: 3000 PORT: 3000
+37
View File
@@ -189,6 +189,43 @@ tar -czf mp-dist.tar.gz -C dist .
## 8. Verlauf / offene Punkte ## 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 **Erledigt 04.10. (v1.35):** Mietvertragsmodul (Verwaltung, Navigation
„Mietverträge", Admin-only). Workflow: anlegen → Entwurf-PDF → Versand an „Mietverträge", Admin-only). Workflow: anlegen → Entwurf-PDF → Versand an
zukünftigen Mieter → Upload unterschriebenen Vertrag → manuell aktivieren zukünftigen Mieter → Upload unterschriebenen Vertrag → manuell aktivieren
+52
View File
@@ -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 <https://platform.moonshot.ai> (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=<langes-zufaelliges-geheimnis-1>
ASSIST_API_KEY=<langes-zufaelliges-geheimnis-2>
```
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.
+287
View File
@@ -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"
}
}
+13 -5
View File
@@ -66,19 +66,27 @@ async function abrechnungsDaten(pool, unitId, jahr) {
} }
const summe = [...jeArt.values()].reduce((s, p) => s + p.betrag, 0) 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 differenz = summe - vorauszahlung
const { rows: zaehler } = await pool.query( const { rows: zaehler } = await pool.query(
`SELECT nummer, art, stand, stand_am FROM meters WHERE unit_id = $1 ORDER BY art, nummer`, `SELECT nummer, art, stand, stand_am FROM meters WHERE unit_id = $1 ORDER BY art, nummer`,
[einheit.id], [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). */ /** Zeichnet das PDF und liefert den Dateinamen (im UPLOAD_DIR). */
async function bauePdf(daten) { 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 dateiName = `ab-${einheit.id}-${jahr}.pdf`
const vollerPfad = path.join(UPLOAD_DIR, dateiName) const vollerPfad = path.join(UPLOAD_DIR, dateiName)
@@ -111,7 +119,7 @@ async function bauePdf(daten) {
y += 16 y += 16
doc.text(`Objekt: ${einheit.building} · Wohnung ${einheit.number} · ${zahl(einheit.size)} m²`, 50, y) doc.text(`Objekt: ${einheit.building} · Wohnung ${einheit.number} · ${zahl(einheit.size)} m²`, 50, y)
y += 16 y += 16
doc.text(`Mieter: ${einheit.tenant || '–'}`, 50, y) doc.text(`Mieter: ${mieterName || '–'}`, 50, y)
y += 16 y += 16
doc.text(`Erstellt am ${dDatum()}`, 50, y) doc.text(`Erstellt am ${dDatum()}`, 50, y)
y += 26 y += 26
@@ -152,7 +160,7 @@ async function bauePdf(daten) {
y += 17 y += 17
} }
zeile('Summe umlagefähige Kosten', eur(summe), true) 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 y += 4
doc.moveTo(320, y - 4).lineTo(545, y - 4).lineWidth(0.5).strokeColor('#cbd5e1').stroke() doc.moveTo(320, y - 4).lineTo(545, y - 4).lineWidth(0.5).strokeColor('#cbd5e1').stroke()
const nachzahlung = differenz > 0 const nachzahlung = differenz > 0
+223
View File
@@ -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 }
+41 -4
View File
@@ -19,11 +19,18 @@ CREATE TABLE IF NOT EXISTS units (
number TEXT NOT NULL, number TEXT NOT NULL,
floor INT NOT NULL, floor INT NOT NULL,
size NUMERIC(8,2) NOT NULL, size NUMERIC(8,2) NOT NULL,
cold_rent NUMERIC(10,2) NOT NULL, cold_rent NUMERIC(10,2) NOT NULL DEFAULT 0, -- Altbestand, v1.36 in Tabelle mieter ausgelagert
warm_rent NUMERIC(10,2) NOT NULL, warm_rent NUMERIC(10,2) NOT NULL DEFAULT 0, -- dito
tenant TEXT, tenant TEXT, -- dito (Mietername, Altbestand)
status TEXT NOT NULL CHECK (status IN ('vermietet','frei')) 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 ( CREATE TABLE IF NOT EXISTS transactions (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
@@ -256,6 +263,36 @@ CREATE TABLE IF NOT EXISTS mietvertraege (
geaendert_am TEXT NOT NULL geaendert_am TEXT NOT NULL
); );
CREATE INDEX IF NOT EXISTS idx_mietvertraege_unit ON mietvertraege(unit_id); 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() { async function init() {
+268 -28
View File
@@ -17,6 +17,7 @@ const mail = require('./mail')
const { erzeugeAbrechnung } = require('./abrechnung') const { erzeugeAbrechnung } = require('./abrechnung')
const { erzeugeVertragsPdf, vermieterDaten } = require('./vertrag') const { erzeugeVertragsPdf, vermieterDaten } = require('./vertrag')
const zaehlerVerlauf = require('./zaehlerVerlauf') const zaehlerVerlauf = require('./zaehlerVerlauf')
const { assistRoutenRegistrieren } = require('./assist')
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
@@ -154,10 +155,22 @@ async function sessionAnlegen(res, userId, req) {
} }
// ── Mapping-Helfer (snake_case → camelCase) ────────────────── // ── Mapping-Helfer (snake_case → camelCase) ──────────────────
const UNIT_STATUS = ['vermietet', 'leerstand', 'unklar']
const mapUnit = (u) => ({ const mapUnit = (u) => ({
id: u.id, building: u.building, number: u.number, floor: u.floor, 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), size: Number(u.size), status: u.status, heizungsart: u.heizungsart ?? '',
tenant: u.tenant, 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) => ({ const mapTransaction = (t) => ({
id: t.id, unitId: t.unit_id, tenant: t.tenant, month: t.month, year: t.year, 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 { 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, buildings, costs, belege, mietvertraege] = const [units, transactions, documents, tickets, threads, messages, announcements, users, codes, meters, buildings, costs, belege, mietvertraege, mieter] =
await Promise.all([ await Promise.all([
pool.query('SELECT * FROM units ORDER BY id'), pool.query('SELECT * FROM units ORDER BY id'),
istAdmin istAdmin
@@ -391,6 +404,11 @@ app.get('/api/bootstrap', auth, async (req, res, next) => {
istAdmin istAdmin
? pool.query('SELECT * FROM mietvertraege ORDER BY erstellt_am DESC') ? pool.query('SELECT * FROM mietvertraege ORDER BY erstellt_am DESC')
: { rows: [] }, : { 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 // Threads mit ihren Nachrichten zusammenführen
const nachrichtenProThread = {} const nachrichtenProThread = {}
@@ -415,6 +433,7 @@ app.get('/api/bootstrap', auth, async (req, res, next) => {
kosten: costs.rows.map(mapKosten), kosten: costs.rows.map(mapKosten),
belege: belege.rows.map(mapBeleg), belege: belege.rows.map(mapBeleg),
mietvertraege: mietvertraege.rows.map(mapMietvertrag), mietvertraege: mietvertraege.rows.map(mapMietvertrag),
mieter: mieter.rows.map(mapMieter),
einstellungen: await vermieterDaten(pool), einstellungen: await vermieterDaten(pool),
}) })
} catch (e) { next(e) } } catch (e) { next(e) }
@@ -879,13 +898,22 @@ app.get('/api/documents/:id/download', auth, async (req, res, next) => {
} catch (e) { next(e) } } 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) => { app.get('/api/documents/:id/preview', auth, async (req, res, next) => {
try { try {
const gefunden = await dokumentMitDatei(req, res) const gefunden = await dokumentMitDatei(req, res)
if (!gefunden) return if (!gefunden) return
res.setHeader('Content-Disposition', `inline; filename*=UTF-8''${encodeURIComponent(gefunden.dokument.file_name || gefunden.dokument.title)}`) 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) res.sendFile(gefunden.pfad)
} catch (e) { next(e) } } catch (e) { next(e) }
}) })
@@ -1343,17 +1371,14 @@ app.post('/api/units', auth, adminOnly, async (req, res, next) => {
const warmRent = Number(u.warmRent) const warmRent = Number(u.warmRent)
if (!Number.isInteger(floor) || floor < -2 || floor > 50) return res.status(400).json({ fehler: 'Etage ungültig.' }) 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(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.' }) const status = UNIT_STATUS.includes(u.status) ? u.status : 'leerstand'
if (!Number.isFinite(warmRent) || warmRent < 0) return res.status(400).json({ fehler: 'Warmmiete ungültig.' })
const status = u.status === 'frei' ? 'frei' : 'vermietet'
if (!heizungsartValid(String(u.heizungsart || '').trim())) return res.status(400).json({ fehler: 'Ungültige Heizungsart.' }) if (!heizungsartValid(String(u.heizungsart || '').trim())) return res.status(400).json({ fehler: 'Ungültige Heizungsart.' })
const heizung = String(u.heizungsart || '').trim() const heizung = String(u.heizungsart || '').trim()
const id = `u-${Date.now()}` const id = `u-${Date.now()}`
const { rows } = await pool.query( const { rows } = await pool.query(
`INSERT INTO units (id, building, number, floor, size, cold_rent, warm_rent, tenant, status, heizungsart) `INSERT INTO units (id, building, number, floor, size, status, heizungsart)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) RETURNING *`, VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING *`,
[id, building, String(u.number).trim(), floor, size, coldRent, warmRent, [id, building, String(u.number).trim(), floor, size, status, heizung],
String(u.tenant || '').trim() || null, status, heizung],
) )
res.json({ unit: mapUnit(rows[0]) }) res.json({ unit: mapUnit(rows[0]) })
} catch (e) { next(e) } } 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.' }) if (!String(u.number || '').trim()) return res.status(400).json({ fehler: 'Wohnungsnummer fehlt.' })
const floor = Number(u.floor) const floor = Number(u.floor)
const size = Number(u.size) 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.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(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.' }) const status = UNIT_STATUS.includes(u.status) ? u.status : 'leerstand'
if (!Number.isFinite(warmRent) || warmRent < 0) return res.status(400).json({ fehler: 'Warmmiete ungültig.' })
const status = u.status === 'frei' ? 'frei' : 'vermietet'
if (!heizungsartValid(String(u.heizungsart || '').trim())) return res.status(400).json({ fehler: 'Ungültige Heizungsart.' }) if (!heizungsartValid(String(u.heizungsart || '').trim())) return res.status(400).json({ fehler: 'Ungültige Heizungsart.' })
const heizung = String(u.heizungsart || '').trim() const heizung = String(u.heizungsart || '').trim()
const { rows: neu } = await pool.query( const { rows: neu } = await pool.query(
`UPDATE units SET building = $1, number = $2, floor = $3, size = $4, cold_rent = $5, `UPDATE units SET building = $1, number = $2, floor = $3, size = $4,
warm_rent = $6, tenant = $7, status = $8, heizungsart = $9 WHERE id = $10 RETURNING *`, status = $5, heizungsart = $6 WHERE id = $7 RETURNING *`,
[building, String(u.number).trim(), floor, size, coldRent, warmRent, [building, String(u.number).trim(), floor, size, status, heizung, req.params.id],
String(u.tenant || '').trim() || null, status, heizung, req.params.id],
) )
res.json({ unit: mapUnit(neu[0]) }) res.json({ unit: mapUnit(neu[0]) })
} catch (e) { next(e) } } catch (e) { next(e) }
@@ -1407,6 +1427,7 @@ app.delete('/api/units/:id', auth, adminOnly, async (req, res, next) => {
['registration_codes', 'einheit_id', 'Registrierungscodes'], ['registration_codes', 'einheit_id', 'Registrierungscodes'],
['belege', 'unit_id', 'Belege'], ['belege', 'unit_id', 'Belege'],
['mietvertraege', 'unit_id', 'Mietverträge'], ['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]) 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}`) 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) } } 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: <mieterId>-<kategorie>.<endung> */
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: <mieterId>-<kategorie>.<endung>
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) ──────────────────────────────────── // ── Nachrichten (Threads) ────────────────────────────────────
app.post('/api/threads', auth, async (req, res, next) => { app.post('/api/threads', auth, async (req, res, next) => {
try { try {
@@ -1737,8 +1926,14 @@ async function mietvertragGueltig(b, res) {
const { rows: u } = await pool.query('SELECT * FROM units WHERE id = $1', [unitId]) 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 } if (!u.length) { res.status(400).json({ fehler: 'Wohnung unbekannt.' }); return null }
const einheit = u[0] const einheit = u[0]
const kaltmiete = b.kaltmiete !== undefined && b.kaltmiete !== '' ? Number(b.kaltmiete) : Number(einheit.cold_rent) // Miet-Voreinstellungen aus dem Mieterobjekt (seit v1.36; z. B. Nachmietung)
const nebenkosten = b.nebenkosten !== undefined && b.nebenkosten !== '' ? Number(b.nebenkosten) : Number(einheit.warm_rent) - Number(einheit.cold_rent) 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(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 } if (!Number.isFinite(nebenkosten) || nebenkosten < 0) { res.status(400).json({ fehler: 'Nebenkosten ungültig.' }); return null }
const kaution = Number(b.kaution ?? 0) 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, [benutzerId, benutzername, vertrag.mieter_name, SHA256_0815,
vertrag.mieter_email, einheit.id, deutschesDatum(), vertrag.mieter_telefon || null], vertrag.mieter_email, einheit.id, deutschesDatum(), vertrag.mieter_telefon || null],
) )
// Wohnung als vermietet führen // Mieterobjekt anlegen/aktualisieren + Wohnung als vermietet führen (v1.36)
await pool.query( const nameTeile = vertrag.mieter_name.trim().split(/\s+/)
`UPDATE units SET tenant = $1, status = 'vermietet' WHERE id = $2`, const vorname = nameTeile.length > 1 ? nameTeile.slice(0, -1).join(' ') : ''
[vertrag.mieter_name, einheit.id], 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( const { rows } = await pool.query(
`UPDATE mietvertraege SET status = 'aktiv', benutzer_id = $1, geaendert_am = $2 WHERE id = $3 RETURNING *`, `UPDATE mietvertraege SET status = 'aktiv', benutzer_id = $1, geaendert_am = $2 WHERE id = $3 RETURNING *`,
[benutzerId, deutschesDatum(), req.params.id], [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`) 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() { async function warteAufDatenbank() {
let letzterFehler = null let letzterFehler = null
for (let versuch = 1; versuch <= 30; versuch++) { for (let versuch = 1; versuch <= 30; versuch++) {
@@ -2060,6 +2298,8 @@ async function start() {
const kostenGeseedet = await seedeKosten() const kostenGeseedet = await seedeKosten()
if (kostenGeseedet) console.log('[api] Kosten-Demo-Daten eingespielt') if (kostenGeseedet) console.log('[api] Kosten-Demo-Daten eingespielt')
await haeuserFelderMigrieren() await haeuserFelderMigrieren()
await mieterAusUnitsMigrieren()
assistRoutenRegistrieren(app, { auth, adminOnly })
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}`))
} }
+20 -6
View File
@@ -17,11 +17,24 @@ async function seed() {
await client.query('BEGIN') await client.query('BEGIN')
for (const u of generated.units) { 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( await client.query(
`INSERT INTO units (id, building, number, floor, size, cold_rent, warm_rent, tenant, status) `INSERT INTO units (id, building, number, floor, size, status) VALUES ($1,$2,$3,$4,$5,$6)`,
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`, [u.id, u.building, u.number, u.floor, u.size, status],
[u.id, u.building, u.number, u.floor, u.size, u.coldRent, u.warmRent, u.tenant, u.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) { for (const t of generated.transactions) {
@@ -32,9 +45,10 @@ async function seed() {
) )
} }
// Mieter-Namen in Tickets/Threads aus der units-Tabelle nachziehen // Mieter-Namen in Tickets/Threads aus den Seed-Daten nachziehen
const { rows: unitRows } = await client.query('SELECT id, tenant FROM units') const mieterVon = Object.fromEntries(
const mieterVon = Object.fromEntries(unitRows.map((u) => [u.id, u.tenant])) generated.units.filter((u) => u.tenant).map((u) => [u.id, u.tenant]),
)
for (const t of tickets) { for (const t of tickets) {
await client.query( await client.query(
+4
View File
@@ -12,6 +12,8 @@ import Zaehler from './pages/Zaehler'
import Kosten from './pages/Kosten' import Kosten from './pages/Kosten'
import Bestand from './pages/Bestand' import Bestand from './pages/Bestand'
import Mietvertraege from './pages/Mietvertraege' import Mietvertraege from './pages/Mietvertraege'
import MieterVerwaltung from './pages/MieterVerwaltung'
import Assistent from './pages/Assistent'
import BenutzerVerwaltung from './pages/BenutzerVerwaltung' import BenutzerVerwaltung from './pages/BenutzerVerwaltung'
import Profil from './pages/Profil' import Profil from './pages/Profil'
import Anleitung from './pages/Anleitung' import Anleitung from './pages/Anleitung'
@@ -90,6 +92,8 @@ export default function App() {
<Route path="/kosten" element={<Kosten />} /> <Route path="/kosten" element={<Kosten />} />
<Route path="/bestand" element={<Bestand />} /> <Route path="/bestand" element={<Bestand />} />
<Route path="/mietvertraege" element={<Mietvertraege />} /> <Route path="/mietvertraege" element={<Mietvertraege />} />
<Route path="/mieter" element={<MieterVerwaltung />} />
<Route path="/assistent" element={<Assistent />} />
<Route path="/dokumente" element={<Documents />} /> <Route path="/dokumente" element={<Documents />} />
<Route path="/nachrichten" element={<Messages />} /> <Route path="/nachrichten" element={<Messages />} />
<Route path="/anleitung" element={<Anleitung />} /> <Route path="/anleitung" element={<Anleitung />} />
+5 -1
View File
@@ -13,6 +13,8 @@ import {
UserRound, UserRound,
CircleHelp, CircleHelp,
FileSignature, FileSignature,
Users,
Sparkles,
} from 'lucide-react' } from 'lucide-react'
import { useStore } from '../store/store' import { useStore } from '../store/store'
import ThemeToggle from './ThemeToggle' import ThemeToggle from './ThemeToggle'
@@ -46,8 +48,10 @@ export default function AppLayout() {
role === 'admin' role === 'admin'
? [...navItems, ? [...navItems,
{ to: '/bestand', label: 'Häuser & Wohnungen', icon: Building2 }, { to: '/bestand', label: 'Häuser & Wohnungen', icon: Building2 },
{ to: '/mieter', label: 'Mieterverwaltung', icon: Users },
{ to: '/mietvertraege', label: 'Mietverträge', icon: FileSignature }, { 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 : navItems
return ( return (
+41 -1
View File
@@ -165,7 +165,29 @@ export const anleitungAbschnitte: AnleitungAbschnitt[] = [
}, },
{ {
titel: 'Wohnungen zuordnen', 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', gruppe: 'verwaltung',
titel: 'Dokumente & Zählerstände', titel: 'Dokumente & Zählerstände',
+3
View File
@@ -15,6 +15,7 @@ import {
Wallet, Wallet,
Wrench, Wrench,
FileSignature, FileSignature,
Sparkles,
} from 'lucide-react' } from 'lucide-react'
import { useStore } from '../store/store' import { useStore } from '../store/store'
import { import {
@@ -36,6 +37,8 @@ const Icons: Record<string, typeof Info> = {
'Mein Profil': UserRound, 'Mein Profil': UserRound,
Benutzerverwaltung: Users, Benutzerverwaltung: Users,
'Häuser & Wohnungen': Building2, 'Häuser & Wohnungen': Building2,
Mieterverwaltung: Users,
'KI-Assistent': Sparkles,
'Mietverträge': FileSignature, 'Mietverträge': FileSignature,
'Steuer & Kosten': Calculator, 'Steuer & Kosten': Calculator,
'Schadensmeldungen & Kommunikation': MessagesSquare, 'Schadensmeldungen & Kommunikation': MessagesSquare,
+168
View File
@@ -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<Nachricht[]>([])
const [eingabe, setEingabe] = useState('')
const [sende, setSende] = useState(false)
const [fehler, setFehler] = useState<string | null>(null)
const endeRef = useRef<HTMLDivElement>(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 (
<div className="rounded-2xl border border-dashed bg-white p-10 text-center text-sm text-slate-500 dark:text-slate-400">
Der KI-Assistent steht nur der Verwaltung zur Verfügung.
</div>
)
}
return (
<div className="flex h-[calc(100vh-12rem)] flex-col space-y-4">
<div className="flex flex-wrap items-end justify-between gap-3">
<div>
<h1 className="flex items-center gap-2 text-2xl font-bold tracking-tight">
<Sparkles className="h-6 w-6 text-teal-600 dark:text-teal-400" /> KI-Assistent
</h1>
<p className="text-sm text-slate-500 dark:text-slate-400">
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.
</p>
</div>
{nachrichten.length > 0 && (
<Button variant="outline" size="sm" className="gap-1.5" onClick={() => setNachrichten([])}>
<Eraser className="h-3.5 w-3.5" /> Verlauf leeren
</Button>
)}
</div>
{/* Nachrichtenbereich */}
<div className="flex-1 space-y-3 overflow-y-auto rounded-2xl border bg-white p-4 dark:border-slate-700 dark:bg-slate-900">
{nachrichten.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center text-center">
<Bot className="mb-3 h-10 w-10 text-slate-300 dark:text-slate-600" />
<p className="max-w-md text-sm text-slate-500 dark:text-slate-400">
Stellen Sie eine Frage zu Ihrem Bestand – zum Beispiel:
</p>
<div className="mt-3 flex flex-wrap justify-center gap-2">
{BEISPIELE.map((b) => (
<button
key={b}
type="button"
onClick={() => senden(b)}
className="rounded-full border px-3 py-1.5 text-xs text-slate-600 transition-colors hover:border-teal-500 hover:text-teal-700 dark:border-slate-700 dark:text-slate-300 dark:hover:border-teal-500 dark:hover:text-teal-400"
>
{b}
</button>
))}
</div>
</div>
) : (
nachrichten.map((m, i) => (
<div key={i} className={`flex gap-2.5 ${m.rolle === 'user' ? 'justify-end' : ''}`}>
{m.rolle === 'assistant' && (
<span className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-teal-50 dark:bg-teal-950">
<Bot className="h-4 w-4 text-teal-700 dark:text-teal-300" />
</span>
)}
<div
className={`max-w-[85%] whitespace-pre-wrap rounded-2xl px-3.5 py-2.5 text-sm leading-relaxed sm:max-w-[75%] ${
m.rolle === 'user'
? 'bg-teal-600 text-white'
: 'bg-slate-100 text-slate-800 dark:bg-slate-800 dark:text-slate-100'
}`}
>
{m.text}
</div>
{m.rolle === 'user' && (
<span className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-slate-100 dark:bg-slate-800">
<UserRound className="h-4 w-4 text-slate-500 dark:text-slate-400" />
</span>
)}
</div>
))
)}
{sende && (
<div className="flex items-center gap-2 text-sm text-slate-400 dark:text-slate-500">
<Bot className="h-4 w-4 animate-pulse" /> Kimi denkt nach …
</div>
)}
{fehler && (
<div className="flex items-start gap-2 rounded-xl bg-red-50 px-3.5 py-2.5 text-sm text-red-700 dark:bg-red-950 dark:text-red-300" role="alert">
<CircleAlert className="mt-0.5 h-4 w-4 shrink-0" /> {fehler}
</div>
)}
<div ref={endeRef} />
</div>
{/* Eingabe */}
<form
className="flex items-end gap-2"
onSubmit={(e) => {
e.preventDefault()
senden(eingabe)
}}
>
<textarea
value={eingabe}
onChange={(e) => setEingabe(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
senden(eingabe)
}
}}
placeholder={'Frage eingeben, z. B. „Welche Wohnungen stehen leer?" …'}
rows={2}
className="max-h-32 min-h-11 flex-1 resize-none rounded-xl border border-input bg-background px-3 py-2.5 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring dark:bg-slate-900"
aria-label="Frage an den Assistenten"
/>
<Button type="submit" className="gap-1.5" disabled={sende || !eingabe.trim()}>
<Send className="h-4 w-4" /> Senden
</Button>
</form>
</div>
)
}
+1 -1
View File
@@ -301,7 +301,7 @@ export default function BenutzerVerwaltung() {
aria-label="Wohnung für Code" aria-label="Wohnung für Code"
> >
<option value="">Wohnung wählen …</option> <option value="">Wohnung wählen …</option>
{units.filter((u) => u.status === 'frei').map((u) => ( {units.filter((u) => u.status === 'leerstand' || u.status === 'unklar').map((u) => (
<option key={u.id} value={u.id}>{u.building}, Whg. {u.number}</option> <option key={u.id} value={u.id}>{u.building}, Whg. {u.number}</option>
))} ))}
{units.filter((u) => u.status === 'vermietet').map((u) => ( {units.filter((u) => u.status === 'vermietet').map((u) => (
+35 -27
View File
@@ -22,7 +22,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select' } from '@/components/ui/select'
import { useStore, fmtEUR } from '../store/store' import { useStore } from '../store/store'
import type { Building, HausForm, Unit } from '../types' import type { Building, HausForm, Unit } from '../types'
import { HEIZUNGSARTEN } from '../types' import { HEIZUNGSARTEN } from '../types'
@@ -203,10 +203,7 @@ function WohnungDialog({ wohnung, gebaeude, festesHaus }: { wohnung?: Unit; geba
const [number, setNumber] = useState(wohnung?.number ?? '') const [number, setNumber] = useState(wohnung?.number ?? '')
const [floor, setFloor] = useState(String(wohnung?.floor ?? '0')) const [floor, setFloor] = useState(String(wohnung?.floor ?? '0'))
const [size, setSize] = useState(wohnung ? String(wohnung.size).replace('.', ',') : '') const [size, setSize] = useState(wohnung ? String(wohnung.size).replace('.', ',') : '')
const [coldRent, setColdRent] = useState(wohnung ? String(wohnung.coldRent).replace('.', ',') : '') const [status, setStatus] = useState<Unit['status']>(wohnung?.status ?? 'leerstand')
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 [heizungsart, setHeizungsart] = useState(wohnung?.heizungsart ?? '') const [heizungsart, setHeizungsart] = useState(wohnung?.heizungsart ?? '')
const [sende, setSende] = useState(false) const [sende, setSende] = useState(false)
const [fehler, setFehler] = useState<string | null>(null) const [fehler, setFehler] = useState<string | null>(null)
@@ -229,9 +226,6 @@ function WohnungDialog({ wohnung, gebaeude, festesHaus }: { wohnung?: Unit; geba
number: number.trim(), number: number.trim(),
floor: zahl(floor), floor: zahl(floor),
size: zahl(size), size: zahl(size),
coldRent: zahl(coldRent),
warmRent: zahl(warmRent),
tenant: tenant.trim(),
status, status,
heizungsart, heizungsart,
} }
@@ -299,21 +293,20 @@ function WohnungDialog({ wohnung, gebaeude, festesHaus }: { wohnung?: Unit; geba
</p> </p>
</div> </div>
</Abschnitt> </Abschnitt>
<Abschnitt titel="Miete"> <Abschnitt titel="Status">
<Feld id="w-kalt" label="Kaltmiete (€)" inputMode="decimal" placeholder="z. B. 520" value={coldRent} onChange={(e) => setColdRent(e.target.value)} />
<Feld id="w-warm" label="Warmmiete (€)" inputMode="decimal" placeholder="z. B. 690" value={warmRent} onChange={(e) => setWarmRent(e.target.value)} />
</Abschnitt>
<Abschnitt titel="Mieter">
<Feld id="w-mieter" label="Mieter (Name)" hint="Leer lassen, solange die Wohnung frei ist" placeholder="z. B. Anna Berger" value={tenant} onChange={(e) => setTenant(e.target.value)} />
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label>Status</Label> <Label>Status</Label>
<Select value={status} onValueChange={(v) => setStatus(v as 'vermietet' | 'frei')}> <Select value={status} onValueChange={(v) => setStatus(v as Unit['status'])}>
<SelectTrigger><SelectValue /></SelectTrigger> <SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="vermietet">vermietet</SelectItem> <SelectItem value="vermietet">Vermietet</SelectItem>
<SelectItem value="frei">frei</SelectItem> <SelectItem value="leerstand">Leerstand</SelectItem>
<SelectItem value="unklar">Unklar</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<p className="text-xs text-slate-400 dark:text-slate-500">
Vermietet/Leerstand wird bei Anlage oder Löschung eines Mieters in der Mieterverwaltung automatisch gesetzt.
</p>
</div> </div>
</Abschnitt> </Abschnitt>
{fehler && ( {fehler && (
@@ -324,7 +317,7 @@ function WohnungDialog({ wohnung, gebaeude, festesHaus }: { wohnung?: Unit; geba
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>Abbrechen</Button> <Button variant="outline" onClick={() => setOpen(false)}>Abbrechen</Button>
<Button onClick={submit} disabled={sende || !building || !number.trim() || !size || !coldRent || !warmRent}> <Button onClick={submit} disabled={sende || !building || !number.trim() || !size}>
{sende ? 'Wird gespeichert …' : bearbeiten ? 'Speichern' : 'Wohnung anlegen'} {sende ? 'Wird gespeichert …' : bearbeiten ? 'Speichern' : 'Wohnung anlegen'}
</Button> </Button>
</DialogFooter> </DialogFooter>
@@ -340,6 +333,21 @@ function sortiereWohnungen(liste: Unit[]): Unit[] {
) )
} }
/** Farben des Wohnungsstatus-Badges. */
function statusBadgeKlasse(status: Unit['status']) {
if (status === 'vermietet')
return 'bg-emerald-100 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-300'
if (status === 'unklar')
return 'bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300'
return 'bg-slate-200 text-slate-600 dark:bg-slate-800 dark:text-slate-300'
}
const STATUS_LABEL: Record<Unit['status'], string> = {
vermietet: 'Vermietet',
leerstand: 'Leerstand',
unklar: 'Unklar',
}
/** Wohnungszeile, in Karten- und Listenansicht gleich. */ /** Wohnungszeile, in Karten- und Listenansicht gleich. */
function WohnungsZeile({ u, gebaeude, festesHaus }: { u: Unit; gebaeude: string[]; festesHaus: string }) { function WohnungsZeile({ u, gebaeude, festesHaus }: { u: Unit; gebaeude: string[]; festesHaus: string }) {
const { deleteWohnung, role } = useStore() const { deleteWohnung, role } = useStore()
@@ -357,19 +365,19 @@ function WohnungsZeile({ u, gebaeude, festesHaus }: { u: Unit; gebaeude: string[
Etage {u.floor} · {u.size.toLocaleString('de-DE')} m² Etage {u.floor} · {u.size.toLocaleString('de-DE')} m²
</span> </span>
</div> </div>
<Badge <Badge className={statusBadgeKlasse(u.status)} variant="secondary">
className={u.status === 'vermietet' ? 'bg-emerald-100 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-300' : 'bg-slate-200 text-slate-600 dark:bg-slate-800 dark:text-slate-300'} {STATUS_LABEL[u.status]}
variant="secondary"
>
{u.status}
</Badge> </Badge>
</div> </div>
<div className="mt-1 flex items-center justify-between gap-2 text-xs text-slate-500 dark:text-slate-400"> <div className="mt-1 flex items-center justify-between gap-2 text-xs text-slate-500 dark:text-slate-400">
<span className="flex items-center gap-1 truncate"> <a
<Users className="h-3.5 w-3.5 shrink-0" /> {u.tenant ?? '–'} href="/mieter"
</span> className="flex items-center gap-1 truncate text-teal-700 hover:underline dark:text-teal-400"
title="Mieterverwaltung öffnen"
>
<Users className="h-3.5 w-3.5 shrink-0" /> Mieter verwalten
</a>
<span className="flex shrink-0 items-center gap-2"> <span className="flex shrink-0 items-center gap-2">
<span className="font-medium text-slate-700 dark:text-slate-200">{fmtEUR.format(u.warmRent)}</span>
<WohnungDialog wohnung={u} gebaeude={gebaeude} festesHaus={festesHaus} /> <WohnungDialog wohnung={u} gebaeude={gebaeude} festesHaus={festesHaus} />
{role === 'admin' && ( {role === 'admin' && (
<button <button
+13 -12
View File
@@ -59,11 +59,12 @@ function StatCard({
} }
function TenantDashboard() { function TenantDashboard() {
const { meineEinheit: demoUnit, me, transactions, tickets, threads, announcements } = useStore() const { meineEinheit: demoUnit, me, transactions, tickets, threads, announcements, mieter } = useStore()
const myTx = transactions.filter((t) => t.unitId === demoUnit.id) const myTx = transactions.filter((t) => t.unitId === demoUnit.id)
const openTx = myTx.filter((t) => t.status !== 'bezahlt') const openTx = myTx.filter((t) => t.status !== 'bezahlt')
const myTickets = tickets.filter((t) => t.unitId === demoUnit.id) const myTickets = tickets.filter((t) => t.unitId === demoUnit.id)
const unread = threads.filter((t) => t.unreadByTenant) const unread = threads.filter((t) => t.unreadByTenant)
const meinMieter = mieter.find((m) => m.unitId === demoUnit.id)
const chartData = monthShort.slice(0, 10).map((m, i) => ({ const chartData = monthShort.slice(0, 10).map((m, i) => ({
m, m,
@@ -89,8 +90,8 @@ function TenantDashboard() {
<StatCard <StatCard
icon={Wallet} icon={Wallet}
label="Monatliche Warmmiete" label="Monatliche Warmmiete"
value={fmtEUR.format(demoUnit.warmRent)} value={meinMieter ? fmtEUR.format(meinMieter.warmmiete) : '–'}
sub={`Kaltmiete ${fmtEUR.format(demoUnit.coldRent)} + NK`} sub={meinMieter ? `Kaltmiete ${fmtEUR.format(meinMieter.kaltmiete)} + NK` : 'Mieterdaten folgen'}
/> />
<StatCard <StatCard
icon={Euro} icon={Euro}
@@ -109,8 +110,8 @@ function TenantDashboard() {
<StatCard <StatCard
icon={TicketCheck} icon={TicketCheck}
label="Mietverhältnis seit" label="Mietverhältnis seit"
value="März 2024" value={meinMieter?.mietbeginnAm ? fmtDatum(meinMieter.mietbeginnAm) : '–'}
sub="Kaution: 1.500,00 € hinterlegt" sub={meinMieter ? `Kaution: ${fmtEUR.format(meinMieter.kautionHoehe)} hinterlegt` : 'Mieterdaten folgen'}
/> />
</div> </div>
@@ -216,10 +217,10 @@ function TenantDashboard() {
} }
function AdminDashboard() { function AdminDashboard() {
const { units, transactions, tickets, announcements, buildings: gebaeude } = useStore() const { units, transactions, tickets, announcements, buildings: gebaeude, mieter } = useStore()
const rented = units.filter((u) => u.status === 'vermietet') const rented = units.filter((u) => u.status === 'vermietet')
const occupancy = Math.round((rented.length / units.length) * 100) const occupancy = units.length ? Math.round((rented.length / units.length) * 100) : 0
const monthlyIncome = rented.reduce((s, u) => s + u.warmRent, 0) const monthlyIncome = mieter.reduce((s, m) => s + m.warmmiete, 0)
const openTickets = tickets.filter((t) => t.status === 'offen') const openTickets = tickets.filter((t) => t.status === 'offen')
const overdue = transactions.filter((t) => t.status === 'überfällig') const overdue = transactions.filter((t) => t.status === 'überfällig')
@@ -235,7 +236,7 @@ function AdminDashboard() {
return { return {
name: g.name, name: g.name,
Vermietet: us.filter((u) => u.status === 'vermietet').length, Vermietet: us.filter((u) => u.status === 'vermietet').length,
Frei: us.filter((u) => u.status === 'frei').length, Leerstand: us.filter((u) => u.status === 'leerstand').length,
} }
}) })
@@ -249,7 +250,7 @@ function AdminDashboard() {
</div> </div>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4"> <div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<StatCard icon={Building2} label="Wohneinheiten gesamt" value={String(units.length)} sub={`${units.filter((u) => u.status === 'frei').length} aktuell frei`} /> <StatCard icon={Building2} label="Wohneinheiten gesamt" value={String(units.length)} sub={`${units.filter((u) => u.status === 'leerstand').length} aktuell im Leerstand`} />
<StatCard icon={Users} label="Vermietungsquote" value={`${occupancy} %`} sub={`${rented.length} von ${units.length} Wohneinheiten`} /> <StatCard icon={Users} label="Vermietungsquote" value={`${occupancy} %`} sub={`${rented.length} von ${units.length} Wohneinheiten`} />
<StatCard icon={Euro} label="Monatliche Warmmiete (Soll)" value={fmtEUR.format(monthlyIncome)} sub="Summe aller vermieteten Einheiten" /> <StatCard icon={Euro} label="Monatliche Warmmiete (Soll)" value={fmtEUR.format(monthlyIncome)} sub="Summe aller vermieteten Einheiten" />
<StatCard <StatCard
@@ -293,13 +294,13 @@ function AdminDashboard() {
<YAxis type="category" dataKey="name" tick={{ fontSize: 11 }} stroke="#94a3b8" width={95} /> <YAxis type="category" dataKey="name" tick={{ fontSize: 11 }} stroke="#94a3b8" width={95} />
<Tooltip /> <Tooltip />
<Bar dataKey="Vermietet" stackId="a" fill="#0d9488" radius={[0, 0, 0, 0]} isAnimationActive={false} /> <Bar dataKey="Vermietet" stackId="a" fill="#0d9488" radius={[0, 0, 0, 0]} isAnimationActive={false} />
<Bar dataKey="Frei" stackId="a" fill="#f59e0b" radius={[0, 6, 6, 0]} isAnimationActive={false} /> <Bar dataKey="Leerstand" stackId="a" fill="#f59e0b" radius={[0, 6, 6, 0]} isAnimationActive={false} />
</BarChart> </BarChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
<div className="mt-2 flex gap-4 text-xs text-slate-500"> <div className="mt-2 flex gap-4 text-xs text-slate-500">
<span className="flex items-center gap-1.5"><span className="h-2.5 w-2.5 rounded-sm bg-teal-600" /> vermietet</span> <span className="flex items-center gap-1.5"><span className="h-2.5 w-2.5 rounded-sm bg-teal-600" /> vermietet</span>
<span className="flex items-center gap-1.5"><span className="h-2.5 w-2.5 rounded-sm bg-amber-500" /> frei</span> <span className="flex items-center gap-1.5"><span className="h-2.5 w-2.5 rounded-sm bg-amber-500" /> Leerstand</span>
</div> </div>
</div> </div>
</div> </div>
+517
View File
@@ -0,0 +1,517 @@
import { useMemo, useRef, useState } from 'react'
import {
BadgeEuro, CalendarRange, FileText, Pencil, Phone, Plus, Search, Trash2, Upload, Users,
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger,
} 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 { fmtDatum } from '../lib/format'
import PdfLightbox from '../components/PdfLightbox'
import type { DocumentItem, KautionArt, Mieter, Unit, Vertragsstatus } from '../types'
/** Komma oder Punkt als Dezimaltrenner akzeptieren. */
const zahl = (s: string) => Number(s.replace(',', '.'))
/** „04.10.2026" → „2026-10-04" (für <input type="date">); ISO bleibt. */
const zuIso = (wert: string) => {
const m = /^(\d{2})\.(\d{2})\.(\d{4})$/.exec(wert.trim())
return m ? `${m[3]}-${m[2]}-${m[1]}` : wert
}
const VERTRAGSSTATUS_LABEL: Record<Vertragsstatus, string> = {
aktiv: 'Aktiv',
befristet: 'Befristet',
gekuendigt: 'Gekündigt',
}
function vertragsBadgeKlasse(status: Vertragsstatus) {
if (status === 'aktiv') return 'bg-emerald-100 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-300'
if (status === 'befristet') return 'bg-sky-100 text-sky-800 dark:bg-sky-950 dark:text-sky-300'
return 'bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300'
}
const KAUTION_ART_LABEL: Record<KautionArt, string> = {
bar: 'Bar',
ueberwiesen: 'Überwiesen',
sonstiges: 'Andere',
}
/** Optischer Abschnitt im einspaltigen Formular. */
function Abschnitt({ titel, children }: { titel: string; children: React.ReactNode }) {
return (
<div className="space-y-3 rounded-xl border bg-slate-50 p-4 dark:border-slate-700 dark:bg-slate-800/50">
<h4 className="text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
{titel}
</h4>
{children}
</div>
)
}
function Feld({ id, label, hint, ...input }: {
id: string
label: string
hint?: string
} & React.InputHTMLAttributes<HTMLInputElement>) {
return (
<div className="space-y-1.5">
<Label htmlFor={id}>{label}</Label>
<Input id={id} {...input} />
{hint && <p className="text-xs text-slate-400 dark:text-slate-500">{hint}</p>}
</div>
)
}
/** Belegt eine Wohneinheit bereits von einem anderen Mieter? */
function einheitLabel(u: Unit) {
return `${u.building}, Whg. ${u.number}`
}
// ── Dokumente-Sektion im Mieter-Formular ────────────────────────────────
const DOKUMENT_KATEGORIEN = ['Personalausweis', 'SCHUFA-Auskunft', 'Bankauszug', 'Sonstiges'] as const
const dokumentSlug = (kategorie: string) => kategorie.toLowerCase().replace(/[^a-z0-9]+/g, '-')
/** Ein Kategorie-Slot: Upload-Button + Miniatur-Preview (öffnet Lightbox). */
function DokumentSlot({ mieter, kategorie }: { mieter: Mieter; kategorie: string }) {
const { documents, ladeMieterDokumentHoch } = useStore()
const fileInput = useRef<HTMLInputElement>(null)
const [sende, setSende] = useState(false)
const [fehler, setFehler] = useState<string | null>(null)
const dokumentId = `d-md-${mieter.id}-${dokumentSlug(kategorie)}`
const dokument = documents.find((d) => d.id === dokumentId) as DocumentItem | undefined
const hochladen = async (datei: File) => {
setSende(true)
setFehler(null)
const err = await ladeMieterDokumentHoch(mieter.id, kategorie, datei)
setSende(false)
if (err) return setFehler(err)
if (fileInput.current) fileInput.current.value = ''
}
return (
<div className="rounded-lg border bg-white p-3 dark:border-slate-700 dark:bg-slate-900">
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-200">{kategorie}</span>
<Button
variant="outline"
size="sm"
className="gap-1.5"
disabled={sende}
onClick={() => fileInput.current?.click()}
>
<Upload className="h-3.5 w-3.5" /> {dokument ? 'Ersetzen' : 'Hochladen'}
</Button>
<input
ref={fileInput}
type="file"
accept=".pdf,.jpg,.jpeg,.png,application/pdf,image/jpeg,image/png"
className="hidden"
onChange={(e) => {
const datei = e.target.files?.[0]
if (datei) hochladen(datei)
}}
/>
</div>
{dokument ? (
<PdfLightbox
dokument={dokument}
trigger={
<button type="button" className="mt-2 block w-full text-left" title="Vorschau öffnen">
{/* Mini-iframe rendert PDF und Bilder zuverlässig als Thumbnail */}
<iframe
src={`/api/documents/${dokument.id}/preview`}
title={`Vorschau: ${kategorie}`}
className="pointer-events-none h-28 w-full rounded-md border border-slate-200 bg-slate-100 dark:border-slate-700 dark:bg-slate-800"
/>
<span className="mt-1 flex items-center gap-1 text-xs text-teal-700 dark:text-teal-400">
<FileText className="h-3.5 w-3.5" />
{dokument.fileName ?? dokument.title} · {dokument.size}
</span>
</button>
}
/>
) : (
<p className="mt-2 text-xs text-slate-400 dark:text-slate-500">
Noch kein Dokument – Scan oder PDF hochladen (ersetzt vorhandene Datei dieser Kategorie).
</p>
)}
{fehler && <p className="mt-1 text-xs text-red-600 dark:text-red-400">{fehler}</p>}
</div>
)
}
// ── Neuanlage / Bearbeitung ─────────────────────────────────────────────
function MieterDialog({ mieter }: { mieter?: Mieter }) {
const { addMieter, updateMieter, units, mieter: alleMieter } = useStore()
const bearbeiten = Boolean(mieter)
const [open, setOpen] = useState(false)
const [unitId, setUnitId] = useState(mieter?.unitId ?? '')
const [name, setName] = useState(mieter?.name ?? '')
const [vorname, setVorname] = useState(mieter?.vorname ?? '')
const [telefon, setTelefon] = useState(mieter?.telefon ?? '')
const [email, setEmail] = useState(mieter?.email ?? '')
const [kaltmiete, setKaltmiete] = useState(mieter ? String(mieter.kaltmiete).replace('.', ',') : '')
const [warmmiete, setWarmmiete] = useState(mieter ? String(mieter.warmmiete).replace('.', ',') : '')
const [vertragsstatus, setVertragsstatus] = useState<Vertragsstatus>(mieter?.vertragsstatus ?? 'aktiv')
const [gekuendigtAm, setGekuendigtAm] = useState(zuIso(mieter?.gekuendigtAm ?? ''))
const [gekuendigtVon, setGekuendigtVon] = useState<'mieter' | 'vermieter' | ''>(mieter?.gekuendigtVon ?? '')
const [kautionHoehe, setKautionHoehe] = useState(mieter ? String(mieter.kautionHoehe).replace('.', ',') : '')
const [kautionArt, setKautionArt] = useState<KautionArt>(mieter?.kautionArt ?? 'ueberwiesen')
const [kautionSonstiges, setKautionSonstiges] = useState(mieter?.kautionSonstiges ?? '')
const [kautionszahlung, setKautionszahlung] = useState<'komplett' | 'raten'>(mieter?.kautionszahlung ?? 'komplett')
const [kautionRaten, setKautionRaten] = useState(mieter ? String(mieter.kautionRaten || '') : '')
const [mietbeginnAm, setMietbeginnAm] = useState(zuIso(mieter?.mietbeginnAm ?? ''))
const [mietendeAm, setMietendeAm] = useState(zuIso(mieter?.mietendeAm ?? ''))
const [sende, setSende] = useState(false)
const [fehler, setFehler] = useState<string | null>(null)
/** Freie Wohnungen + die eigene (beim Bearbeiten). */
const wohnungen = units.filter(
(u) => u.id === unitId || !alleMieter.some((m) => m.unitId === u.id),
)
const payload = () => ({
unitId,
name: name.trim(),
vorname: vorname.trim(),
telefon: telefon.trim(),
email: email.trim(),
kaltmiete: kaltmiete.trim() === '' ? 0 : zahl(kaltmiete),
warmmiete: warmmiete.trim() === '' ? 0 : zahl(warmmiete),
vertragsstatus,
gekuendigtAm: vertragsstatus === 'gekuendigt' ? gekuendigtAm : '',
gekuendigtVon: vertragsstatus === 'gekuendigt' ? gekuendigtVon : '',
kautionHoehe: kautionHoehe.trim() === '' ? 0 : zahl(kautionHoehe),
kautionArt,
kautionSonstiges: kautionArt === 'sonstiges' ? kautionSonstiges.trim() : '',
kautionszahlung,
kautionRaten: kautionszahlung === 'raten' && kautionRaten.trim() !== '' ? Math.round(zahl(kautionRaten)) : 0,
mietbeginnAm,
mietendeAm,
})
const submit = async () => {
if (sende) return
setSende(true)
setFehler(null)
const err = bearbeiten ? await updateMieter(mieter!.id, payload()) : await addMieter(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" /> Neuer Mieter
</Button>
)}
</DialogTrigger>
<DialogContent className="max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{bearbeiten ? `Mieter „${mieter!.vorname} ${mieter!.name}" bearbeiten` : 'Neuen Mieter anlegen'}
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<Abschnitt titel="Person">
<Feld id="m-name" label="Name" placeholder="z. B. Berger" value={name} onChange={(e) => setName(e.target.value)} />
<Feld id="m-vorname" label="Vorname" placeholder="z. B. Anna" value={vorname} onChange={(e) => setVorname(e.target.value)} />
<Feld id="m-tel" label="Telefon" placeholder="z. B. 0151 23456789" value={telefon} onChange={(e) => setTelefon(e.target.value)} />
<Feld id="m-mail" label="E-Mail" type="email" placeholder="z. B. a.berger@mail.de" value={email} onChange={(e) => setEmail(e.target.value)} />
</Abschnitt>
<Abschnitt titel="Wohnung">
<div className="space-y-1.5">
<Label>Wohneinheit</Label>
<Select value={unitId} onValueChange={setUnitId}>
<SelectTrigger><SelectValue placeholder="Wohnung wählen …" /></SelectTrigger>
<SelectContent>
{wohnungen.map((u) => (
<SelectItem key={u.id} value={u.id}>{einheitLabel(u)}</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-slate-400 dark:text-slate-500">
Pro Wohnung kann genau ein Mieter geführt werden. Beim Speichern wird die Wohnung auf „Vermietet" gesetzt.
</p>
</div>
</Abschnitt>
<Abschnitt titel="Miete">
<Feld id="m-kalt" label="Kaltmiete (€/Monat)" inputMode="decimal" placeholder="z. B. 520" value={kaltmiete} onChange={(e) => setKaltmiete(e.target.value)} />
<Feld id="m-warm" label="Warmmiete (€/Monat)" inputMode="decimal" placeholder="z. B. 690" value={warmmiete} onChange={(e) => setWarmmiete(e.target.value)} />
</Abschnitt>
<Abschnitt titel="Vertrag">
<div className="space-y-1.5">
<Label>Vertragsstatus</Label>
<Select value={vertragsstatus} onValueChange={(v) => setVertragsstatus(v as Vertragsstatus)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{(Object.keys(VERTRAGSSTATUS_LABEL) as Vertragsstatus[]).map((s) => (
<SelectItem key={s} value={s}>{VERTRAGSSTATUS_LABEL[s]}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{vertragsstatus === 'gekuendigt' && (
<>
<Feld id="m-gek-am" label="Gekündigt zum" type="date" value={gekuendigtAm} onChange={(e) => setGekuendigtAm(e.target.value)} />
<div className="space-y-1.5">
<Label>Gekündigt von</Label>
<Select value={gekuendigtVon || '__keine'} onValueChange={(v) => setGekuendigtVon(v === '__keine' ? '' : v as 'mieter' | 'vermieter')}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="__keine">Nicht angegeben</SelectItem>
<SelectItem value="mieter">Mieter</SelectItem>
<SelectItem value="vermieter">Vermieter</SelectItem>
</SelectContent>
</Select>
</div>
</>
)}
<Feld id="m-beginn" label="Mietbeginn" type="date" value={mietbeginnAm} onChange={(e) => setMietbeginnAm(e.target.value)} />
<Feld id="m-ende" label="Mietende" hint="Leer lassen bei unbefristetem Vertrag" type="date" value={mietendeAm} onChange={(e) => setMietendeAm(e.target.value)} />
</Abschnitt>
<Abschnitt titel="Kaution">
<Feld id="m-kaut-hoehe" label="Kaution Höhe (€)" inputMode="decimal" placeholder="z. B. 1.500" value={kautionHoehe} onChange={(e) => setKautionHoehe(e.target.value)} />
<div className="space-y-1.5">
<Label>Kaution Art</Label>
<Select value={kautionArt} onValueChange={(v) => setKautionArt(v as KautionArt)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="bar">Bar</SelectItem>
<SelectItem value="ueberwiesen">Überwiesen</SelectItem>
<SelectItem value="sonstiges">Andere</SelectItem>
</SelectContent>
</Select>
</div>
{kautionArt === 'sonstiges' && (
<Feld id="m-kaut-sonst" label="Andere Art (Freitext)" placeholder="z. B. Bürgschaft der Eltern" value={kautionSonstiges} onChange={(e) => setKautionSonstiges(e.target.value)} />
)}
<div className="space-y-1.5">
<Label>Kautionszahlung</Label>
<Select value={kautionszahlung} onValueChange={(v) => setKautionszahlung(v as 'komplett' | 'raten')}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="komplett">Komplett gezahlt</SelectItem>
<SelectItem value="raten">Ratenzahlung</SelectItem>
</SelectContent>
</Select>
</div>
{kautionszahlung === 'raten' && (
<Feld id="m-kaut-raten" label="Anzahl der Raten" inputMode="numeric" placeholder="z. B. 3" value={kautionRaten} onChange={(e) => setKautionRaten(e.target.value)} />
)}
</Abschnitt>
{bearbeiten && (
<Abschnitt titel="Dokumente">
<p className="text-xs text-slate-400 dark:text-slate-500">
Personalausweis, SCHUFA-Auskunft, Bankauszug u. a. – Klick auf die Vorschau öffnet Druck- und
E-Mail-Optionen. Pro Kategorie wird genau eine Datei (PDF oder Bild) geführt.
</p>
<div className="grid gap-3 sm:grid-cols-2">
{DOKUMENT_KATEGORIEN.map((k) => (
<DokumentSlot key={k} mieter={mieter!} kategorie={k} />
))}
</div>
</Abschnitt>
)}
{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 || !unitId || !name.trim()}>
{sende ? 'Wird gespeichert …' : bearbeiten ? 'Speichern' : 'Mieter anlegen'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
// ── Listenansicht ───────────────────────────────────────────────────────
export default function MieterVerwaltung() {
const { mieter, units, deleteMieter } = useStore()
const [suche, setSuche] = useState('')
const einheitVon = (unitId: string) => units.find((u) => u.id === unitId)
const gefiltert = useMemo(() => {
const q = suche.trim().toLowerCase()
const liste = [...mieter].sort(
(a, b) => einheitVon(a.unitId)?.building.localeCompare(einheitVon(b.unitId)?.building ?? '') ||
(einheitVon(a.unitId)?.number ?? '').localeCompare(einheitVon(b.unitId)?.number ?? '', 'de-DE', { numeric: true }),
)
if (!q) return liste
return liste.filter((m) => {
const u = einheitVon(m.unitId)
return [m.name, m.vorname, m.email, m.telefon, u?.building ?? '', u?.number ?? '']
.some((f) => f.toLowerCase().includes(q))
})
}, [mieter, units, suche])
const loesche = async (m: Mieter) => {
if (!window.confirm(`Mieter „${m.vorname} ${m.name}" wirklich löschen? Die Wohnung wird auf „Leerstand" gesetzt.`)) return
const fehler = await deleteMieter(m.id)
if (fehler) window.alert(fehler)
}
return (
<div className="space-y-6">
<div className="flex flex-wrap items-end justify-between gap-3">
<div>
<h1 className="text-2xl font-bold tracking-tight">Mieterverwaltung</h1>
<p className="text-sm text-slate-500 dark:text-slate-400">
Zu jeder Wohnung genau ein Mieterobjekt – Miete, Vertrag, Kaution und Dokumente werden hier gepflegt
</p>
</div>
<MieterDialog />
</div>
{mieter.length > 0 && (
<div className="flex flex-wrap items-center gap-2">
<div className="relative min-w-56 flex-1 sm:max-w-xs">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<Input
className="pl-9"
placeholder="Mieter oder Wohnung suchen …"
value={suche}
onChange={(e) => setSuche(e.target.value)}
aria-label="Mieter suchen"
/>
</div>
{suche && (
<span className="text-xs text-slate-500 dark:text-slate-400">
{gefiltert.length} von {mieter.length} Mietern
</span>
)}
</div>
)}
{mieter.length === 0 ? (
<div className="rounded-2xl border border-dashed bg-white p-10 text-center text-sm text-slate-500 dark:text-slate-400">
<Users className="mx-auto mb-3 h-8 w-8 text-slate-300 dark:text-slate-600" />
Noch keine Mieter angelegt – über „Neuer Mieter" die erste Wohneinheit belegen.
</div>
) : gefiltert.length === 0 ? (
<div className="rounded-2xl border border-dashed bg-white p-10 text-center text-sm text-slate-500 dark:text-slate-400">
<Search className="mx-auto mb-3 h-8 w-8 text-slate-300 dark:text-slate-600" />
Kein Mieter passt zur Suche „{suche}".
</div>
) : (
<div className="overflow-hidden rounded-2xl border bg-white dark:border-slate-700 dark:bg-slate-900">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-slate-50 text-left text-xs uppercase tracking-wide text-slate-500 dark:border-slate-700 dark:bg-slate-800/60 dark:text-slate-400">
<th className="px-3 py-2.5">Wohnung</th>
<th className="px-3 py-2.5">Mieter</th>
<th className="hidden px-3 py-2.5 lg:table-cell">Kontakt</th>
<th className="hidden px-3 py-2.5 md:table-cell">Miete</th>
<th className="hidden px-3 py-2.5 lg:table-cell">Kaution</th>
<th className="hidden px-3 py-2.5 xl:table-cell">Zeitraum</th>
<th className="px-3 py-2.5">Vertrag</th>
<th className="px-3 py-2.5 text-right">Aktion</th>
</tr>
</thead>
<tbody>
{gefiltert.map((m) => {
const u = einheitVon(m.unitId)
return (
<tr key={m.id} className="border-b last:border-0 hover:bg-slate-50 dark:border-slate-700 dark:hover:bg-slate-800/40">
<td className="px-3 py-2.5 font-medium text-slate-800 dark:text-slate-100">
{u ? einheitLabel(u) : '–'}
</td>
<td className="px-3 py-2.5">
{m.vorname} {m.name}
</td>
<td className="hidden px-3 py-2.5 lg:table-cell">
<div className="flex flex-col gap-0.5 text-xs text-slate-500 dark:text-slate-400">
{m.telefon && (
<span className="flex items-center gap-1"><Phone className="h-3 w-3" /> {m.telefon}</span>
)}
{m.email && <span>{m.email}</span>}
{!m.telefon && !m.email && '–'}
</div>
</td>
<td className="hidden px-3 py-2.5 md:table-cell">
<div className="text-slate-700 dark:text-slate-200">{fmtEUR.format(m.warmmiete)}</div>
<div className="text-xs text-slate-400 dark:text-slate-500">kalt {fmtEUR.format(m.kaltmiete)}</div>
</td>
<td className="hidden px-3 py-2.5 lg:table-cell">
{m.kautionHoehe > 0 ? (
<div className="flex items-center gap-1 text-slate-600 dark:text-slate-300">
<BadgeEuro className="h-3.5 w-3.5 text-slate-400" />
{fmtEUR.format(m.kautionHoehe)}
<span className="text-xs text-slate-400 dark:text-slate-500">
· {KAUTION_ART_LABEL[m.kautionArt]}
{m.kautionArt === 'sonstiges' && m.kautionSonstiges && ` (${m.kautionSonstiges})`}
{m.kautionszahlung === 'raten' && m.kautionRaten > 0 && ` · ${m.kautionRaten} Raten`}
</span>
</div>
) : (
<span className="text-slate-400 dark:text-slate-500">–</span>
)}
</td>
<td className="hidden px-3 py-2.5 xl:table-cell">
<div className="flex items-center gap-1 text-xs text-slate-500 dark:text-slate-400">
<CalendarRange className="h-3.5 w-3.5 text-slate-400" />
{fmtDatum(m.mietbeginnAm)} – {m.mietendeAm ? fmtDatum(m.mietendeAm) : 'unbefristet'}
</div>
</td>
<td className="px-3 py-2.5">
<Badge className={vertragsBadgeKlasse(m.vertragsstatus)} variant="secondary">
{VERTRAGSSTATUS_LABEL[m.vertragsstatus]}
</Badge>
{m.vertragsstatus === 'gekuendigt' && m.gekuendigtAm && (
<div className="mt-1 text-xs text-slate-400 dark:text-slate-500">
zum {fmtDatum(m.gekuendigtAm)}
{m.gekuendigtVon && ` von ${m.gekuendigtVon === 'mieter' ? 'Mieter' : 'Vermieter'}`}
</div>
)}
</td>
<td className="px-3 py-2.5 text-right">
<span className="inline-flex items-center gap-1">
<MieterDialog mieter={m} />
<button
type="button"
className="text-slate-400 transition-colors hover:text-red-600 dark:text-slate-500 dark:hover:text-red-400"
onClick={() => loesche(m)}
aria-label={`Mieter ${m.vorname} ${m.name} löschen`}
title="Mieter löschen"
>
<Trash2 className="h-4 w-4" />
</button>
</span>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
</div>
)
}
+6 -6
View File
@@ -147,7 +147,7 @@ function VermieterDialog() {
/** Neuanlage / Bearbeitung eines Mietvertrags. */ /** Neuanlage / Bearbeitung eines Mietvertrags. */
function MietvertragDialog({ vertrag, einheiten }: { vertrag?: Mietvertrag; einheiten: Unit[] }) { function MietvertragDialog({ vertrag, einheiten }: { vertrag?: Mietvertrag; einheiten: Unit[] }) {
const { addMietvertrag, updateMietvertrag } = useStore() const { addMietvertrag, updateMietvertrag, mieter } = useStore()
const bearbeiten = Boolean(vertrag) const bearbeiten = Boolean(vertrag)
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [unitId, setUnitId] = useState(vertrag?.unitId ?? einheiten[0]?.id ?? '') const [unitId, setUnitId] = useState(vertrag?.unitId ?? einheiten[0]?.id ?? '')
@@ -164,10 +164,10 @@ function MietvertragDialog({ vertrag, einheiten }: { vertrag?: Mietvertrag; einh
const einheitWechseln = (id: string) => { const einheitWechseln = (id: string) => {
setUnitId(id) setUnitId(id)
const u = einheiten.find((x) => x.id === id) const m = mieter.find((x) => x.unitId === id)
if (u && !bearbeiten) { if (m && !bearbeiten) {
setKaltmiete(dezimal(Number(u.coldRent))) setKaltmiete(dezimal(Number(m.kaltmiete)))
setNebenkosten(dezimal(Number(u.warmRent) - Number(u.coldRent))) setNebenkosten(dezimal(Number(m.warmmiete) - Number(m.kaltmiete)))
} }
} }
@@ -219,7 +219,7 @@ function MietvertragDialog({ vertrag, einheiten }: { vertrag?: Mietvertrag; einh
<SelectContent> <SelectContent>
{einheiten.map((u) => ( {einheiten.map((u) => (
<SelectItem key={u.id} value={u.id}> <SelectItem key={u.id} value={u.id}>
{u.building}, Whg. {u.number} (Etage {u.floor}, {u.size} m²{u.status === 'frei' ? ', frei' : ''}) {u.building}, Whg. {u.number} (Etage {u.floor}, {u.size} m²{u.status === 'leerstand' ? ', Leerstand' : ''})
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
+11 -8
View File
@@ -28,7 +28,8 @@ const statusStyle: Record<string, string> = {
} }
function TenantPayments() { function TenantPayments() {
const { meineEinheit: demoUnit, transactions } = useStore() const { meineEinheit: demoUnit, transactions, mieter } = useStore()
const meinMieter = mieter.find((m) => m.unitId === demoUnit.id)
const myTx = useMemo( const myTx = useMemo(
() => () =>
transactions transactions
@@ -57,7 +58,7 @@ function TenantPayments() {
<div className="mb-2 flex items-center gap-2 text-sm text-slate-500"> <div className="mb-2 flex items-center gap-2 text-sm text-slate-500">
<CalendarDays className="h-4 w-4" /> Fällig am 03.10.2026 <CalendarDays className="h-4 w-4" /> Fällig am 03.10.2026
</div> </div>
<div className="text-xl font-bold">{fmtEUR.format(demoUnit.warmRent)}</div> <div className="text-xl font-bold">{meinMieter ? fmtEUR.format(meinMieter.warmmiete) : '–'}</div>
<div className="mt-0.5 text-xs text-slate-500">Warmmiete Oktober (überwiesen ausstehend)</div> <div className="mt-0.5 text-xs text-slate-500">Warmmiete Oktober (überwiesen ausstehend)</div>
</div> </div>
<div className="rounded-2xl border bg-white p-5 shadow-sm"> <div className="rounded-2xl border bg-white p-5 shadow-sm">
@@ -138,9 +139,11 @@ function TenantPayments() {
} }
function AdminPayments() { function AdminPayments() {
const { units, transactions, buildings } = useStore() const { units, transactions, buildings, mieter } = useStore()
const [building, setBuilding] = useState('alle') const [building, setBuilding] = useState('alle')
const mieterVon = (unitId: string) => mieter.find((m) => m.unitId === unitId)
const list = units const list = units
.filter((u) => u.status === 'vermietet') .filter((u) => u.status === 'vermietet')
.filter((u) => building === 'alle' || u.building === building) .filter((u) => building === 'alle' || u.building === building)
@@ -148,11 +151,11 @@ function AdminPayments() {
const tx = transactions.filter((t) => t.unitId === u.id) const tx = transactions.filter((t) => t.unitId === u.id)
const overdue = tx.filter((t) => t.status === 'überfällig') const overdue = tx.filter((t) => t.status === 'überfällig')
const lastPaid = tx.filter((t) => t.status === 'bezahlt').slice(-1)[0] const lastPaid = tx.filter((t) => t.status === 'bezahlt').slice(-1)[0]
return { unit: u, overdue, lastPaid } return { unit: u, mieter: mieterVon(u.id), overdue, lastPaid }
}) })
const totals = { const totals = {
soll: list.reduce((s, r) => s + r.unit.warmRent, 0), soll: list.reduce((s, r) => s + (r.mieter?.warmmiete ?? 0), 0),
rueckstand: list.reduce((s, r) => s + r.overdue.reduce((a, t) => a + t.amount, 0), 0), rueckstand: list.reduce((s, r) => s + r.overdue.reduce((a, t) => a + t.amount, 0), 0),
} }
@@ -203,13 +206,13 @@ function AdminPayments() {
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{list.map(({ unit, overdue, lastPaid }) => ( {list.map(({ unit, mieter: m, overdue, lastPaid }) => (
<TableRow key={unit.id}> <TableRow key={unit.id}>
<TableCell className="font-medium"> <TableCell className="font-medium">
{unit.building}, Whg. {unit.number} {unit.building}, Whg. {unit.number}
</TableCell> </TableCell>
<TableCell>{unit.tenant}</TableCell> <TableCell>{m ? `${m.vorname} ${m.name}` : '–'}</TableCell>
<TableCell>{fmtEUR.format(unit.warmRent)}</TableCell> <TableCell>{m ? fmtEUR.format(m.warmmiete) : '–'}</TableCell>
<TableCell>{lastPaid ? `${lastPaid.month} (${lastPaid.paidAt})` : '–'}</TableCell> <TableCell>{lastPaid ? `${lastPaid.month} (${lastPaid.paidAt})` : '–'}</TableCell>
<TableCell className={overdue.length ? 'font-semibold text-red-600' : 'text-slate-400'}> <TableCell className={overdue.length ? 'font-semibold text-red-600' : 'text-slate-400'}>
{overdue.length ? fmtEUR.format(overdue.reduce((a, t) => a + t.amount, 0)) : '–'} {overdue.length ? fmtEUR.format(overdue.reduce((a, t) => a + t.amount, 0)) : '–'}
+55 -4
View File
@@ -39,6 +39,7 @@ import type {
Mietvertrag, Mietvertrag,
MietvertragForm, MietvertragForm,
VermieterDaten, VermieterDaten,
Mieter,
} from '../types' } from '../types'
import { apiGet, apiPost, apiPatch, apiPut, apiDelete, apiUpload, ApiFehler } from '../lib/api' import { apiGet, apiPost, apiPatch, apiPut, apiDelete, apiUpload, ApiFehler } from '../lib/api'
import { sha256, DEMO_PASSWORT } from '../lib/auth' import { sha256, DEMO_PASSWORT } from '../lib/auth'
@@ -62,6 +63,7 @@ interface Bootstrap {
belege: Beleg[] belege: Beleg[]
mietvertraege: Mietvertrag[] mietvertraege: Mietvertrag[]
einstellungen: VermieterDaten einstellungen: VermieterDaten
mieter: Mieter[]
} }
const LEERE_EINSTELLUNGEN: VermieterDaten = { const LEERE_EINSTELLUNGEN: VermieterDaten = {
@@ -84,6 +86,7 @@ const LEER: Bootstrap = {
belege: [], belege: [],
mietvertraege: [], mietvertraege: [],
einstellungen: LEERE_EINSTELLUNGEN, einstellungen: LEERE_EINSTELLUNGEN,
mieter: [],
} }
interface Store extends Bootstrap { interface Store extends Bootstrap {
@@ -164,10 +167,7 @@ interface Store extends Bootstrap {
number: string number: string
floor: number floor: number
size: number size: number
coldRent: number status: Unit['status']
warmRent: number
tenant: string
status: 'vermietet' | 'frei'
heizungsart: string heizungsart: string
}) => Promise<string | null> }) => Promise<string | null>
updateWohnung: (id: string, u: Partial<Unit>) => Promise<string | null> updateWohnung: (id: string, u: Partial<Unit>) => Promise<string | null>
@@ -236,6 +236,12 @@ interface Store extends Bootstrap {
aktiviereMietvertrag: (id: string) => Promise<{ fehler: string | null; benutzername?: string }> aktiviereMietvertrag: (id: string) => Promise<{ fehler: string | null; benutzername?: string }>
/** Vermieterdaten speichern (Basis der Vertrags-PDFs). */ /** Vermieterdaten speichern (Basis der Vertrags-PDFs). */
speichereEinstellungen: (d: Partial<VermieterDaten>) => Promise<string | null> speichereEinstellungen: (d: Partial<VermieterDaten>) => Promise<string | null>
// ── Mieterverwaltung (v1.36) ─────────────────────────────
addMieter: (m: Omit<Mieter, 'id' | 'erstelltAm' | 'geaendertAm'>) => Promise<string | null>
updateMieter: (id: string, m: Partial<Omit<Mieter, 'id' | 'erstelltAm' | 'geaendertAm'>>) => Promise<string | null>
deleteMieter: (id: string) => Promise<string | null>
/** Mieterdokument (Personalausweis, SCHUFA …) hochladen – ersetzt den Slot je Kategorie. */
ladeMieterDokumentHoch: (mieterId: string, kategorie: string, datei: File) => Promise<string | null>
} }
const StoreContext = createContext<Store | null>(null) const StoreContext = createContext<Store | null>(null)
@@ -759,6 +765,51 @@ export function StoreProvider({ children }: { children: ReactNode }) {
return fehlerNachricht(e) return fehlerNachricht(e)
} }
}, },
// ── Mieterverwaltung (v1.36) ─────────────────────────────
async addMieter(m) {
try {
const { mieter } = await apiPost<{ mieter: Mieter }>('/api/mieter', m)
setDaten((d) => ({
...d, mieter: [...d.mieter, mieter].sort((a, b) => a.name.localeCompare(b.name)),
units: d.units.map((u) => (u.id === mieter.unitId ? { ...u, status: 'vermietet' } : u)),
}))
return null
} catch (e) {
return fehlerNachricht(e)
}
},
async updateMieter(id, m) {
try {
const { mieter } = await apiPatch<{ mieter: Mieter }>(`/api/mieter/${id}`, m)
setDaten((d) => ({ ...d, mieter: d.mieter.map((x) => (x.id === id ? mieter : x)) }))
return null
} catch (e) {
return fehlerNachricht(e)
}
},
async deleteMieter(id) {
try {
const alt = daten.mieter.find((x) => x.id === id)
await apiDelete(`/api/mieter/${id}`)
setDaten((d) => ({
...d,
mieter: d.mieter.filter((x) => x.id !== id),
units: alt ? d.units.map((u) => (u.id === alt.unitId ? { ...u, status: 'leerstand' } : u)) : d.units,
}))
return null
} catch (e) {
return fehlerNachricht(e)
}
},
async ladeMieterDokumentHoch(mieterId, kategorie, datei) {
try {
await apiUpload(`/api/mieter/${mieterId}/dokumente?kategorie=${encodeURIComponent(kategorie)}`, datei)
await ladeAlles() // Dokumentenliste aktualisieren
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>
+36 -4
View File
@@ -27,20 +27,52 @@ export interface RegistrierungsCode {
verwendetAm?: string verwendetAm?: string
} }
/** Wohnungsstatus (v1.36): vermietet, Leerstand oder unklar. */
export type UnitStatus = 'vermietet' | 'leerstand' | 'unklar'
export interface Unit { export interface Unit {
id: string id: string
building: string building: string
number: string number: string
floor: number floor: number
size: number // m² size: number // m²
coldRent: number // € status: UnitStatus
warmRent: number // €
tenant: string | null
status: 'vermietet' | 'frei'
/** Heizungsart der Wohnung (Dropdown, '' = nicht angegeben) */ /** Heizungsart der Wohnung (Dropdown, '' = nicht angegeben) */
heizungsart: string heizungsart: string
} }
/** Mieterobjekt (v1.36): 1:1 zur Wohnung – Miete, Kontakt, Vertrag,
* Kaution und Mietzeitraum leben hier (nicht mehr am Wohnungsobjekt). */
export type Vertragsstatus = 'aktiv' | 'befristet' | 'gekuendigt'
export type KautionArt = 'bar' | 'ueberwiesen' | 'sonstiges'
export interface Mieter {
id: string
unitId: string
name: string
vorname: string
telefon: string
email: string
kaltmiete: number // €/Monat
warmmiete: number // €/Monat
vertragsstatus: Vertragsstatus
/** „gekündigt zum" – nur bei vertragsstatus = gekuendigt */
gekuendigtAm: string
/** „gekündigt von": mieter | vermieter | '' */
gekuendigtVon: 'mieter' | 'vermieter' | ''
kautionHoehe: number // €
kautionArt: KautionArt
/** Freitext bei kautionArt = sonstiges („Andere") */
kautionSonstiges: string
kautionszahlung: 'komplett' | 'raten'
/** Anzahl der Raten (bei kautionszahlung = raten) */
kautionRaten: number
mietbeginnAm: string
mietendeAm: string // leer = unbefristet
erstelltAm: string
geaendertAm: string
}
export interface Transaction { export interface Transaction {
id: string id: string
unitId: string unitId: string