From 9e74813e22b84a3df0e89fef77bc0278a7776356 Mon Sep 17 00:00:00 2001 From: ah-admin Date: Sun, 4 Oct 2026 19:39:22 +0200 Subject: [PATCH] v1.35: Mietvertragsmodul (Entwurf-PDF, Versand, Unterschrift, Mieteranlage) --- kimi.md | 33 +++ server/src/db.js | 29 ++ server/src/index.js | 295 ++++++++++++++++++- server/src/vertrag.js | 241 +++++++++++++++ src/App.tsx | 2 + src/components/AppLayout.tsx | 2 + src/data/anleitung.ts | 22 ++ src/lib/api.ts | 1 + src/pages/Anleitung.tsx | 2 + src/pages/Mietvertraege.tsx | 557 +++++++++++++++++++++++++++++++++++ src/store/store.tsx | 110 ++++++- src/types/index.ts | 42 +++ 12 files changed, 1334 insertions(+), 2 deletions(-) create mode 100644 server/src/vertrag.js create mode 100644 src/pages/Mietvertraege.tsx diff --git a/kimi.md b/kimi.md index 28c15fe..7bcd26f 100644 --- a/kimi.md +++ b/kimi.md @@ -189,6 +189,39 @@ tar -czf mp-dist.tar.gz -C dist . ## 8. Verlauf / offene Punkte +**Erledigt 04.10. (v1.35):** Mietvertragsmodul (Verwaltung, Navigation +„Mietverträge", Admin-only). Workflow: anlegen → Entwurf-PDF → Versand an +zukünftigen Mieter → Upload unterschriebenen Vertrag → manuell aktivieren +(Mieterkonto wird angelegt, Wohnung als vermietet geführt, Zugangsdaten +per E-Mail). DB (db.js): einstellungen (Schlüssel-Wert, Vermieterdaten: +vermieter_name/strasse/hausnummer/plz/ort/telefon/email/bankname/iban) + +mietvertraege (Status entwurf|versendet|unterschrieben|aktiv, Dokument- +und Benutzerbezüge als TEXT ohne harten FK, unit_id mit FK). Server: +server/src/vertrag.js (pdfkit-Mustervertrag: Parteien, §1 Mietobjekt aus +Bestand, §2 Mietzeit, §3 Miete/Nebenkosten/Zahlung, §4 Kaution ≤3 Nettokalt- +mieten, §5 Gebrauch/Instandhaltung/Schönheitsreparaturen/Rauchwarnmelder, +§6 Schlussbestimmungen, Unterschriftsfelder; erzeugeVertragsPdf legt/ersetzt +Dokument „Mietvertrag" der Wohnung). Endpunkte (alle adminOnly): CRUD +/api/mietvertraege[n], POST /:id/entwurf, /:id/versenden (PDF-Anhang via +mail.sendeMail + CC Verwaltung, Status→versendet), /:id/unterschrift +(multer single('file'), Dokument d-mvu-, Status→unterschrieben), +/:id/aktivieren (Benutzername aus Vor-/Nachname [Umlaute→ae/oe/ue, unique +mit Zähler], Passwort sha256("0815"), users+einheit_id+telefon anlegen, +units.tenant/status setzen, Mail mit Zugangsdaten, Status→aktiv), +GET/PUT /api/einstellungen (Whitelist vermieter_*). Bootstrap liefert +mietvertraege + einstellungen (via vermieterDaten, kurze Keys). +DELETE /api/documents löst Verweise in mietvertraege; Wohnungs-Blockerliste +um mietvertraege erweitert. Frontend: types (Mietvertrag/MietvertragForm/ +VermieterDaten), store (Bootstrap-Felder + 8 Aktionen, apiPut neu in +lib/api.ts), Seite src/pages/Mietvertraege.tsx (Listenansicht Standard + +Suche, Vermieterdaten-Dialog, Vertrags-Dialog mit Miet-Vorbefüllung aus +der Wohnung, Aktionen: Entwurf/PdfLightbox, Senden, Upload, Aktivieren +mit Confirm + Benutzername-Alert, Löschen; Warnbanner bei unvollständigen +Vermieterdaten), Anleitung neuer Abschnitt „Mietverträge". +E2E: Vertrag anlegen→Entwurf-PDF (Vorschau lädt)→versenden (CC-Verwaltung +in Mail-API-Log)→Unterschrift-Upload (Test-PDF)→aktivieren (Benutzer +vorhanden, Wohnung vermietet, Login mit 0815 möglich)→Aufräumen. + **Erledigt 04.10. (v1.34):** SparkReceipt-Zuordnung nutzt Mietobjekt-Adresse. Problem: auf eingehenden Rechnungen steht als Adresse der Rechnungsempfänger (Rott 250 = eigene Verwaltungsadresse, als Haus gepflegt) – die sekundäre diff --git a/server/src/db.js b/server/src/db.js index 8d66869..835d34a 100644 --- a/server/src/db.js +++ b/server/src/db.js @@ -227,6 +227,35 @@ ALTER TABLE belege ADD COLUMN IF NOT EXISTS unit_id TEXT REFERENCES units(id); ALTER TABLE belege ADD COLUMN IF NOT EXISTS gebaude TEXT; ALTER TABLE belege ADD COLUMN IF NOT EXISTS zuordnung TEXT; -- zaehlernummer | adresse | manuell ALTER TABLE belege ADD COLUMN IF NOT EXISTS rohtext TEXT; -- Volltext des Events für die Zuordnung + +-- Portal-/Vermieterdaten für Mietvertrags-PDFs (v1.35, Schlüssel-Wert) +CREATE TABLE IF NOT EXISTS einstellungen ( + schluessel TEXT PRIMARY KEY, + wert TEXT NOT NULL DEFAULT '' +); +-- Mietverträge (v1.35): Entwurf → versendet → unterschrieben → aktiv. +-- Dokument- und Benutzerbezüge bewusst ohne harten FK, damit das Löschen +-- von Dokumenten/Benutzern nicht an der Vertragshistorie scheitert. +CREATE TABLE IF NOT EXISTS mietvertraege ( + id TEXT PRIMARY KEY, + unit_id TEXT NOT NULL REFERENCES units(id), + mieter_name TEXT NOT NULL, + mieter_email TEXT NOT NULL DEFAULT '', + mieter_telefon TEXT NOT NULL DEFAULT '', + beginn_am TEXT NOT NULL, + ende_am TEXT NOT NULL DEFAULT '', -- '' = unbefristet + kaltmiete NUMERIC(10,2) NOT NULL, + nebenkosten NUMERIC(10,2) NOT NULL DEFAULT 0, + kaution NUMERIC(10,2) NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'entwurf' + CHECK (status IN ('entwurf','versendet','unterschrieben','aktiv')), + dokument_id TEXT NOT NULL DEFAULT '', -- Entwurf als PDF (Dokumentenmodul) + unterschriften_dokument_id TEXT NOT NULL DEFAULT '', -- unterschriebener Vertrag (Scan) + benutzer_id TEXT NOT NULL DEFAULT '', -- angelegtes Mieterkonto + erstellt_am TEXT NOT NULL, + geaendert_am TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_mietvertraege_unit ON mietvertraege(unit_id); ` async function init() { diff --git a/server/src/index.js b/server/src/index.js index e8fff61..b2e0ead 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -15,6 +15,7 @@ const { pool, init, sessionsAufraeumen } = require('./db') const { seed, seedeZaehler, seedeHaeuser, seedeKosten } = require('./seed') const mail = require('./mail') const { erzeugeAbrechnung } = require('./abrechnung') +const { erzeugeVertragsPdf, vermieterDaten } = require('./vertrag') const zaehlerVerlauf = require('./zaehlerVerlauf') const PORT = Number(process.env.PORT || 3000) @@ -172,6 +173,14 @@ const mapDocument = (d) => ({ fileName: d.file_name ?? null, hasFile: Boolean(d.file_path), }) const mapMessage = (m) => ({ id: m.id, from: m.from_role, author: m.author, text: m.text, at: m.at }) +const mapMietvertrag = (v) => ({ + id: v.id, unitId: v.unit_id, mieterName: v.mieter_name, mieterEmail: v.mieter_email, + mieterTelefon: v.mieter_telefon, beginnAm: v.beginn_am, endeAm: v.ende_am, + kaltmiete: Number(v.kaltmiete), nebenkosten: Number(v.nebenkosten), kaution: Number(v.kaution), + status: v.status, dokumentId: v.dokument_id || null, + unterschriftenDokumentId: v.unterschriften_dokument_id || null, + benutzerId: v.benutzer_id || null, erstelltAm: v.erstellt_am, geaendertAm: v.geaendert_am, +}) const mapThread = (t) => ({ id: t.id, unitId: t.unit_id, tenant: t.tenant, subject: t.subject, unreadByAdmin: t.unread_by_admin, unreadByTenant: t.unread_by_tenant, @@ -342,7 +351,7 @@ app.get('/api/bootstrap', auth, async (req, res, next) => { try { const istAdmin = req.user.rolle === 'verwaltung' const einheit = req.user.einheit_id - const [units, transactions, documents, tickets, threads, messages, announcements, users, codes, meters, buildings, costs, belege] = + const [units, transactions, documents, tickets, threads, messages, announcements, users, codes, meters, buildings, costs, belege, mietvertraege] = await Promise.all([ pool.query('SELECT * FROM units ORDER BY id'), istAdmin @@ -379,6 +388,9 @@ app.get('/api/bootstrap', auth, async (req, res, next) => { istAdmin ? pool.query('SELECT * FROM belege ORDER BY erfasst_am DESC') : { rows: [] }, + istAdmin + ? pool.query('SELECT * FROM mietvertraege ORDER BY erstellt_am DESC') + : { rows: [] }, ]) // Threads mit ihren Nachrichten zusammenführen const nachrichtenProThread = {} @@ -402,6 +414,8 @@ app.get('/api/bootstrap', auth, async (req, res, next) => { buildings: buildings.rows.map(mapBuilding), kosten: costs.rows.map(mapKosten), belege: belege.rows.map(mapBeleg), + mietvertraege: mietvertraege.rows.map(mapMietvertrag), + einstellungen: await vermieterDaten(pool), }) } catch (e) { next(e) } }) @@ -802,6 +816,12 @@ app.delete('/api/documents/:id', auth, adminOnly, async (req, res, next) => { try { const { rows } = await pool.query('DELETE FROM documents WHERE id = $1 RETURNING file_path', [req.params.id]) if (!rows.length) return res.status(404).json({ fehler: 'Dokument nicht gefunden.' }) + // Verweise aus dem Mietvertragsmodul lösen (Entwurf / Unterschrifts-PDF) + await pool.query( + `UPDATE mietvertraege SET dokument_id = '' WHERE dokument_id = $1; + UPDATE mietvertraege SET unterschriften_dokument_id = '' WHERE unterschriften_dokument_id = $1`, + [req.params.id], + ) if (rows[0].file_path) { // Datei best-effort mitlöschen (Fehler hier dürfen den Request nicht killen) await fs.promises.unlink(path.join(UPLOAD_DIR, rows[0].file_path)).catch(() => {}) @@ -1386,6 +1406,7 @@ app.delete('/api/units/:id', auth, adminOnly, async (req, res, next) => { ['threads', 'unit_id', 'Unterhaltungen'], ['registration_codes', 'einheit_id', 'Registrierungscodes'], ['belege', 'unit_id', 'Belege'], + ['mietvertraege', 'unit_id', 'Mietverträge'], ]) { 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}`) @@ -1705,6 +1726,278 @@ app.post('/api/abrechnungen', auth, adminOnly, async (req, res, next) => { } }) +// ── Mietverträge (v1.35) ───────────────────────────────────── +const SHA256_0815 = 'c57a5df4f97ed6914116e5fd1348406064834f22503d890eb571af442ac4b4c3' // sha256("0815") + +/** Pflichtfelren prüfen; liefert normierte Werte oder null (+Response bereits gesendet). */ +async function mietvertragGueltig(b, res) { + const mieterName = String(b.mieterName || '').trim() + if (!mieterName) { res.status(400).json({ fehler: 'Name des zukünftigen Mieters fehlt.' }); return null } + 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 einheit = u[0] + const kaltmiete = b.kaltmiete !== undefined && b.kaltmiete !== '' ? Number(b.kaltmiete) : Number(einheit.cold_rent) + const nebenkosten = b.nebenkosten !== undefined && b.nebenkosten !== '' ? Number(b.nebenkosten) : Number(einheit.warm_rent) - Number(einheit.cold_rent) + if (!Number.isFinite(kaltmiete) || kaltmiete < 0) { res.status(400).json({ fehler: 'Kaltmiete ungültig.' }); return null } + if (!Number.isFinite(nebenkosten) || nebenkosten < 0) { res.status(400).json({ fehler: 'Nebenkosten ungültig.' }); return null } + const kaution = Number(b.kaution ?? 0) + if (!Number.isFinite(kaution) || kaution < 0) { res.status(400).json({ fehler: 'Kaution ungültig.' }); return null } + const beginnAm = datumDeutsch(b.beginnAm) || String(b.beginnAm || '').trim() + if (!beginnAm) { res.status(400).json({ fehler: 'Mietbeginn fehlt.' }); return null } + const endeAm = datumDeutsch(b.endeAm) || String(b.endeAm || '').trim() + const mieterEmail = String(b.mieterEmail || '').trim() + const mieterTelefon = String(b.mieterTelefon || '').trim() + return { unitId, einheit, mieterName, mieterEmail, mieterTelefon, beginnAm, endeAm, kaltmiete, nebenkosten, kaution } +} + +async function mietvertragLaden(id, res) { + const { rows } = await pool.query('SELECT * FROM mietvertraege WHERE id = $1', [id]) + if (!rows.length) { res.status(404).json({ fehler: 'Mietvertrag nicht gefunden.' }); return null } + return rows[0] +} + +app.post('/api/mietvertraege', auth, adminOnly, async (req, res, next) => { + try { + const g = await mietvertragGueltig(req.body || {}, res) + if (!g) return + const id = `mv-${Date.now()}` + const { rows } = await pool.query( + `INSERT INTO mietvertraege (id, unit_id, mieter_name, mieter_email, mieter_telefon, + beginn_am, ende_am, kaltmiete, nebenkosten, kaution, status, erstellt_am, geaendert_am) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,'entwurf',$11,$11) RETURNING *`, + [id, g.unitId, g.mieterName, g.mieterEmail, g.mieterTelefon, g.beginnAm, g.endeAm, + g.kaltmiete, g.nebenkosten, g.kaution, deutschesDatum()], + ) + res.json({ mietvertrag: mapMietvertrag(rows[0]) }) + } catch (e) { next(e) } +}) + +app.patch('/api/mietvertraege/:id', auth, adminOnly, async (req, res, next) => { + try { + const alt = await mietvertragLaden(req.params.id, res) + if (!alt) return + if (alt.status === 'aktiv') return res.status(400).json({ fehler: 'Aktive Verträge können nicht mehr bearbeitet werden.' }) + const b = { ...mapMietvertrag(alt), ...(req.body || {}) } + const g = await mietvertragGueltig(b, res) + if (!g) return + const { rows } = await pool.query( + `UPDATE mietvertraege SET unit_id=$1, mieter_name=$2, mieter_email=$3, mieter_telefon=$4, + beginn_am=$5, ende_am=$6, kaltmiete=$7, nebenkosten=$8, kaution=$9, geaendert_am=$10 + WHERE id=$11 RETURNING *`, + [g.unitId, g.mieterName, g.mieterEmail, g.mieterTelefon, g.beginnAm, g.endeAm, + g.kaltmiete, g.nebenkosten, g.kaution, deutschesDatum(), req.params.id], + ) + res.json({ mietvertrag: mapMietvertrag(rows[0]) }) + } catch (e) { next(e) } +}) + +app.delete('/api/mietvertraege/:id', auth, adminOnly, async (req, res, next) => { + try { + const alt = await mietvertragLaden(req.params.id, res) + if (!alt) return + if (alt.status === 'aktiv') return res.status(400).json({ fehler: 'Aktive Verträge können nicht gelöscht werden.' }) + await pool.query('DELETE FROM mietvertraege WHERE id = $1', [req.params.id]) + res.json({ ok: true }) + } catch (e) { next(e) } +}) + +// Entwurf als PDF erzeugen/aktualisieren (Dokumentenmodul) +app.post('/api/mietvertraege/:id/entwurf', auth, adminOnly, async (req, res, next) => { + try { + const vertrag = await mietvertragLaden(req.params.id, res) + if (!vertrag) return + if (vertrag.status === 'aktiv') return res.status(400).json({ fehler: 'Aktive Verträge können nicht mehr bearbeitet werden.' }) + const dokument = await erzeugeVertragsPdf(pool, vertrag) + res.json({ dokument: mapDocument(dokument) }) + } catch (e) { + if (e.status) return res.status(e.status).json({ fehler: e.message }) + next(e) + } +}) + +// Entwurf per E-Mail an den zukünftigen Mieter versenden (CC: Verwaltung) +app.post('/api/mietvertraege/:id/versenden', auth, adminOnly, async (req, res, next) => { + try { + let vertrag = await mietvertragLaden(req.params.id, res) + if (!vertrag) return + if (vertrag.status === 'aktiv') return res.status(400).json({ fehler: 'Aktive Verträge können nicht erneut versendet werden.' }) + if (!vertrag.mieter_email || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(vertrag.mieter_email)) + return res.status(400).json({ fehler: 'E-Mail-Adresse des zukünftigen Mieters fehlt oder ist ungültig.' }) + // PDF auf aktuellem Datenstand neu erzeugen (ersetzt ggf. altes Entwurfs-PDF) + await erzeugeVertragsPdf(pool, vertrag) + vertrag = (await pool.query('SELECT * FROM mietvertraege WHERE id = $1', [req.params.id])).rows[0] + const { rows: einheiten } = await pool.query('SELECT * FROM units WHERE id = $1', [vertrag.unit_id]) + const einheit = einheiten[0] + const betreff = `Ihr Mietvertragsentwurf – ${einheit.building}, Wohnung ${einheit.number}` + const portalUrl = process.env.PORTAL_URL || 'https://mieterportal.hocks.eu' + const text = `Guten Tag ${vertrag.mieter_name},\n\nanbei erhalten Sie den Entwurf Ihres Mietvertrags für die Wohnung ${einheit.number} in ${einheit.building}. ` + + 'Bitte prüfen Sie den Vertrag; um ihn zu bestätigen, unterschreiben Sie ihn und reichen Sie ihn bei der Verwaltung ein.\n\n' + + `Mit freundlichen Grüßen\nIhre Verwaltung` + const html = mail.baueHtmlMail({ + titel: 'Mietvertragsentwurf', + untertitel: `${einheit.building}, Wohnung ${einheit.number}`, + einleitung: `Guten Tag ${vertrag.mieter_name},`, + zeilen: [ + ['Objekt', `${einheit.building}, Whg. ${einheit.number}`], + ['Mietbeginn', vertrag.beginn_am], + ['Kaltmiete', `${Number(vertrag.kaltmiete).toLocaleString('de-DE', { minimumFractionDigits: 2 })} €`], + ['Kaution', `${Number(vertrag.kaution).toLocaleString('de-DE', { minimumFractionDigits: 2 })} €`], + ], + statusBox: { text: 'Der Vertragsentwurf (PDF) ist an diese E-Mail angehängt.', hintergrund: '#ecfdf5', farbe: '#047857' }, + fussnote: 'Bei Rückfragen antworten Sie bitte direkt auf diese E-Mail.', + }) + await mail.sendeMail(vertrag.mieter_email, betreff, text, html, await mail.verwaltungEmails(), { + dateiname: `Mietvertrag-${einheit.building.replace(/\s+/g, '_')}-Whg${einheit.number}.pdf`, + inhaltB64: fs.readFileSync(path.join(UPLOAD_DIR, path.basename(`mv-${vertrag.id}.pdf`))).toString('base64'), + mime: 'application/pdf', + }) + const { rows } = await pool.query( + `UPDATE mietvertraege SET status = 'versendet', geaendert_am = $1 WHERE id = $2 RETURNING *`, + [deutschesDatum(), req.params.id], + ) + console.log(`[vertrag] Entwurf ${vertrag.id} an ${vertrag.mieter_email} versendet`) + res.json({ mietvertrag: mapMietvertrag(rows[0]) }) + } catch (e) { next(e) } +}) + +// Unterschriebenen Vertrag (Scan/PDF) hochladen → Status „unterschrieben" +app.post('/api/mietvertraege/:id/unterschrift', auth, adminOnly, async (req, res, next) => { + try { + const vertrag = await mietvertragLaden(req.params.id, res) + if (!vertrag) return + if (vertrag.status === 'aktiv') return res.status(400).json({ fehler: 'Aktive Verträge können nicht mehr bearbeitet werden.' }) + upload.single('file')(req, res, async (fehler) => { + try { + if (fehler) return res.status(400).json({ fehler: 'Upload fehlgeschlagen – nur PDF-Dateien 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 { rows: einheiten } = await pool.query('SELECT * FROM units WHERE id = $1', [vertrag.unit_id]) + const einheit = einheiten[0] + const dokumentTitel = `Mietvertrag (unterschrieben) – ${einheit.building}, Whg. ${einheit.number} – ${vertrag.mieter_name}` + let dokumentId = vertrag.unterschriften_dokument_id + const { rows: vorhanden } = await pool.query('SELECT id FROM documents WHERE id = $1', [dokumentId || '']) + if (vorhanden.length) { + await pool.query( + `UPDATE documents SET title = $1, file_name = $1, size = $2, date = $3, unit_id = $4 WHERE id = $5`, + [dokumentTitel, groesse, deutschesDatum(), einheit.id, dokumentId], + ) + } else { + dokumentId = `d-mvu-${vertrag.id}` + await pool.query( + `INSERT INTO documents (id, unit_id, title, category, size, date, file_name, file_path) + VALUES ($1,$2,$3,'Mietvertrag',$4,$5,$3,$6)`, + [dokumentId, einheit.id, dokumentTitel, groesse, deutschesDatum(), `${vertrag.id}.pdf`], + ) + } + const { rows } = await pool.query( + `UPDATE mietvertraege SET status = 'unterschrieben', unterschriften_dokument_id = $1, geaendert_am = $2 + WHERE id = $3 RETURNING *`, + [dokumentId, deutschesDatum(), req.params.id], + ) + res.json({ mietvertrag: mapMietvertrag(rows[0]), dokument: mapDocument((await pool.query('SELECT * FROM documents WHERE id = $1', [dokumentId])).rows[0]) }) + } catch (e) { next(e) } + }) + } catch (e) { next(e) } +}) + +// Mieterkonto anlegen + Wohnung zuweisen (manuell ausgelöst) → Status „aktiv" +app.post('/api/mietvertraege/:id/aktivieren', auth, adminOnly, async (req, res, next) => { + try { + const vertrag = await mietvertragLaden(req.params.id, res) + if (!vertrag) return + if (vertrag.status === 'aktiv') return res.status(400).json({ fehler: 'Der Vertrag ist bereits aktiv.' }) + if (vertrag.status !== 'unterschrieben') + return res.status(400).json({ fehler: 'Erst den unterschriebenen Vertrag hochladen, dann aktivieren.' }) + const { rows: einheiten } = await pool.query('SELECT * FROM units WHERE id = $1', [vertrag.unit_id]) + if (!einheiten.length) return res.status(400).json({ fehler: 'Wohnung unbekannt.' }) + const einheit = einheiten[0] + + // Benutzername aus Name ableiten (vorname.nachname), ggf. numerisch ergänzen + const teile = vertrag.mieter_name.trim().split(/\s+/) + const norm = (s) => s.toLowerCase().replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue').replace(/ß/g, 'ss').replace(/[^a-z0-9.\-]/g, '') + const basis = teile.length > 1 ? `${norm(teile[0])}.${norm(teile[teile.length - 1])}` : norm(teile[0]) + let benutzername = basis || `mieter.${Date.now()}` + let versuch = 0 + while (true) { + const { rows: belegt } = await pool.query('SELECT 1 FROM users WHERE lower(benutzername) = lower($1)', [benutzername]) + if (!belegt.length) break + versuch += 1 + benutzername = `${basis}${versuch + 1}` + if (versuch > 20) return res.status(400).json({ fehler: 'Kein freier Benutzername ableitbar.' }) + } + + const benutzerId = `b-${Date.now()}` + await pool.query( + `INSERT INTO users (id, benutzername, name, passwort_hash, rolle, email, einheit_id, aktiv, erstellt_am, telefon) + VALUES ($1,$2,$3,$4,'mieter',$5,$6,TRUE,$7,$8)`, + [benutzerId, benutzername, vertrag.mieter_name, SHA256_0815, + vertrag.mieter_email, einheit.id, deutschesDatum(), vertrag.mieter_telefon || null], + ) + // Wohnung als vermietet führen + await pool.query( + `UPDATE units SET tenant = $1, status = 'vermietet' WHERE id = $2`, + [vertrag.mieter_name, einheit.id], + ) + const { rows } = await pool.query( + `UPDATE mietvertraege SET status = 'aktiv', benutzer_id = $1, geaendert_am = $2 WHERE id = $3 RETURNING *`, + [benutzerId, deutschesDatum(), req.params.id], + ) + // Zugangsdaten an den Mieter mailen (wenn E-Mail hinterlegt) + if (vertrag.mieter_email && /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(vertrag.mieter_email)) { + const portalUrl = process.env.PORTAL_URL || 'https://mieterportal.hocks.eu' + const text = `Guten Tag ${vertrag.mieter_name},\n\nIhr Mietvertrag ist aktiv – willkommen bei Hinterstein Immobilien! ` + + `Ihr Zugang zum Mieterportal wurde eingerichtet:\n\nBenutzername: ${benutzername}\nPasswort: 0815\n\n` + + `Bitte melden Sie sich an und ändern Sie Ihr Passwort im Profil.\n${portalUrl}\n\nMit freundlichen Grüßen\nIhre Verwaltung` + const html = mail.baueHtmlMail({ + titel: 'Ihr Mieterportal-Zugang ist bereit', + untertitel: `${einheit.building}, Wohnung ${einheit.number}`, + einleitung: `Guten Tag ${vertrag.mieter_name},`, + zeilen: [ + ['Objekt', `${einheit.building}, Whg. ${einheit.number}`], + ['Benutzername', benutzername], + ['Passwort', '0815 (bitte nach dem ersten Login ändern)'], + ], + statusBox: { text: 'Der unterschriebene Mietvertrag ist im Portal hinterlegt.', hintergrund: '#ecfdf5', farbe: '#047857' }, + aktionenHtml: mail.buttonHtml('Zum Mieterportal', portalUrl), + fussnote: 'Bei Fragen wenden Sie sich bitte an die Verwaltung.', + }) + await mail.sendeMail(vertrag.mieter_email, 'Ihr Mieterportal-Zugang – Hinterstein Immobilien', text, html, await mail.verwaltungEmails()) + } + console.log(`[vertrag] ${vertrag.id} aktiviert – Mieterkonto ${benutzername} (${benutzerId}) angelegt`) + res.json({ mietvertrag: mapMietvertrag(rows[0]), benutzername }) + } catch (e) { next(e) } +}) + +// Vermieterdaten (Schlüssel-Wert) lesen/pflegen – Basis der Vertrags-PDFs +app.get('/api/einstellungen', auth, adminOnly, async (req, res, next) => { + try { + const daten = await vermieterDaten(pool) + res.json({ einstellungen: daten }) + } catch (e) { next(e) } +}) + +const EINSTELLUNGEN_KEYS = [ + 'vermieter_name', 'vermieter_strasse', 'vermieter_hausnummer', 'vermieter_plz', 'vermieter_ort', + 'vermieter_telefon', 'vermieter_email', 'vermieter_bankname', 'vermieter_iban', +] +app.put('/api/einstellungen', auth, adminOnly, async (req, res, next) => { + try { + const b = req.body || {} + for (const key of EINSTELLUNGEN_KEYS) { + if (b[key] !== undefined) { + await pool.query( + `INSERT INTO einstellungen (schluessel, wert) VALUES ($1, $2) + ON CONFLICT (schluessel) DO UPDATE SET wert = EXCLUDED.wert`, + [key, String(b[key] ?? '').trim()], + ) + } + } + res.json({ einstellungen: await vermieterDaten(pool) }) + } catch (e) { next(e) } +}) + // ── Fehlerbehandlung ───────────────────────────────────────── app.use((err, _req, res, _next) => { console.error('[api]', err) diff --git a/server/src/vertrag.js b/server/src/vertrag.js new file mode 100644 index 0000000..ab3e9ff --- /dev/null +++ b/server/src/vertrag.js @@ -0,0 +1,241 @@ +/** Mietvertrag als PDF (v1.35). + * + * Erzeugt einen rechtskonformen Mietvertrags-Entwurf (Wohnraummietvertrag, + * Mustervorlage) aus den hinterlegten Vermieterdaten (Tabelle einstellungen), + * den Mieterdaten des Vertrags und den Bestandsdaten (Haus/Wohnung). + * Das PDF landet im Upload-Verzeichnis und wird als Dokument + * („Mietvertrag") der Wohnung zugeordnet – erneutes Erzeugen ersetzt + * Datei und Dokument. + * + * Hinweis: Es handelt sich um eine automatisch erzeugte Mustervorlage; + * die Verwaltung prüft den Entwurf vor Versand/Unterschrift. + */ + +const fs = require('fs') +const path = require('path') +const PDFDocument = require('pdfkit') + +const UPLOAD_DIR = process.env.UPLOAD_DIR || path.join(__dirname, '..', 'uploads') +const FARBE_DUNKEL = '#22333b' +const FARBE_AKZENT = '#f59e0b' +const FARBE_SOFT = '#64748b' + +const eur = (n) => + Number(n || 0).toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' €' +const zahl = (n) => + Number(n || 0).toLocaleString('de-DE', { maximumFractionDigits: 2 }) +const dDatum = () => new Date().toLocaleDateString('de-DE') + +/** Liest die Vermieterdaten (Schlüssel-Wert) als Objekt. */ +async function vermieterDaten(pool) { + const { rows } = await pool.query('SELECT schluessel, wert FROM einstellungen') + const d = {} + for (const r of rows) d[r.schluessel] = r.wert + return { + name: d.vermieter_name || '', + strasse: d.vermieter_strasse || '', + hausnummer: d.vermieter_hausnummer || '', + plz: d.vermieter_plz || '', + ort: d.vermieter_ort || '', + telefon: d.vermieter_telefon || '', + email: d.vermieter_email || '', + bankname: d.vermieter_bankname || '', + iban: d.vermieter_iban || '', + } +} + +const vermieterAdresse = (v) => + [v.strasse && `${v.strasse} ${v.hausnummer}`, v.plz && `${v.plz} ${v.ort}`].filter(Boolean).join(', ') + +/** Zeichnet den Vertrag und liefert den Dateinamen (im UPLOAD_DIR). */ +async function bauePdf(vertrag, einheit, haus, vermieter) { + const dateiName = `mv-${vertrag.id}.pdf` + const vollerPfad = path.join(UPLOAD_DIR, dateiName) + const anschrift = `${haus.strasse} ${haus.hausnummer}, ${haus.plz} ${haus.ort}` + const warmmiete = Number(vertrag.kaltmiete) + Number(vertrag.nebenkosten) + + await new Promise((resolve, reject) => { + const doc = new PDFDocument({ size: 'A4', margin: 50, info: { + Title: `Mietvertrag Entwurf – ${einheit.building}, Whg. ${einheit.number}`, + Author: vermieter.name || 'Hinterstein Immobilien', + } }) + const strom = fs.createWriteStream(vollerPfad) + strom.on('finish', resolve) + strom.on('error', reject) + doc.pipe(strom) + + const breite = doc.page.width - 100 + let y = doc.y + + // ── Briefkopf: Vermieter links, Mieter rechts ── + doc.font('Helvetica-Bold').fontSize(12).fillColor(FARBE_DUNKEL) + doc.text(vermieter.name || 'Vermieter', 50, y) + doc.font('Helvetica').fontSize(9).fillColor(FARBE_SOFT) + const vermieterZeilen = [ + vermieterAdresse(vermieter), + vermieter.telefon, vermieter.email, + ].filter(Boolean) + doc.text(vermieterZeilen.join('\n') || 'Vermieterdaten im Portal hinterlegen', 50, y + 16, { width: 240 }) + doc.font('Helvetica-Bold').fontSize(12).fillColor(FARBE_DUNKEL) + doc.text(vertrag.mieter_name, 310, y, { width: 235 }) + doc.font('Helvetica').fontSize(9).fillColor(FARBE_SOFT) + doc.text('Mieter/in', 310, y + 16, { width: 235 }) + doc.moveTo(50, y + 70).lineTo(50 + breite, y + 70).lineWidth(1.5).strokeColor(FARBE_AKZENT).stroke() + y += 86 + + // ── Titel ── + doc.font('Helvetica-Bold').fontSize(15).fillColor(FARBE_DUNKEL) + doc.text('Mietvertrag über Wohnraum', 50, y, { width: breite, align: 'center' }) + y += 20 + doc.font('Helvetica').fontSize(9).fillColor(FARBE_SOFT) + doc.text(`Entwurf vom ${dDatum()} – erzeugt aus dem Mieterportal`, 50, y, { width: breite, align: 'center' }) + y += 26 + + const absatz = (titel, zeilen) => { + if (y > 700) { doc.addPage(); y = 60 } + doc.font('Helvetica-Bold').fontSize(10.5).fillColor(FARBE_DUNKEL) + doc.text(titel, 50, y) + y += 16 + doc.font('Helvetica').fontSize(9.5).fillColor('#0f172a') + for (const zeile of zeilen) { + const h = doc.heightOfString(zeile, { width: breite }) + if (y + h > 780) { doc.addPage(); y = 60 } + doc.text(zeile, 50, y, { width: breite, lineGap: 2 }) + y += h + 7 + } + y += 6 + } + + // ── Vertragsparteien & Mietobjekt ── + absatz('§ 1 Vertragsparteien und Mietobjekt', [ + `Zwischen dem Vermieter, ${vermieter.name || '…'} (${vermieterAdresse(vermieter) || 'Adresse siehe Briefkopf'}), ` + + `und dem/der Mieter/in, ${vertrag.mieter_name}, wird folgender Mietvertrag geschlossen.`, + `Dem Mieter wird die Wohnung Nr. ${einheit.number} im Haus ${anschrift} ` + + `${haus.lage ? `(${haus.lage}) ` : ''}zum Wohnen überlassen. ` + + `Die Wohnung liegt in Etage ${einheit.floor}, die Wohnfläche beträgt ca. ${zahl(einheit.size)} m².` + + (haus.heizungsart ? ` Die Wohnung wird mit ${haus.heizungsart} beheizt.` : ''), + `Zum Mietobjekt gehören die in der Wohnung vorhandenen fest installierten Einrichtungen.` + ]) + + // ── Mietzeit ── + absatz('§ 2 Mietzeit', [ + `Das Mietverhältnis beginnt am ${vertrag.beginn_am}. ` + + (vertrag.ende_am + ? `Es wird auf den ${vertrag.ende_am} befristet geschlossen.` + : 'Es wird auf unbestimmte Zeit geschlossen (unbefristetes Mietverhältnis).'), + `Die Kündigung richtet sich nach den gesetzlichen Vorschriften (§§ 573, 573c BGB).` + ]) + + // ── Miete ── + absatz('§ 3 Miete und Nebenkosten', [ + `Die monatliche Kaltmiete beträgt ${eur(vertrag.kaltmiete)}.`, + `Als Vorauszahlung auf die Betriebskosten werden monatlich zusätzlich ${eur(vertrag.nebenkosten)} (Nebenkostenvorauszahlung) gezahlt. ` + + `Die jährliche Abrechnung der Betriebskosten erfolgt nach tatsächlichem Verbrauch bzw. nach der in der Betriebskostenverordnung geregelten Verteilung.`, + `Die Warmmiete (Kaltmiete zzgl. Nebenkostenvorauszahlung) beträgt somit ${eur(warmmiete)} monatlich. ` + + `Sie ist jeweils bis zum dritten Werktag des Monats im Voraus zu entrichten.`, + `Die Miete ist auf das Konto ${vermieter.bankname || 'des Vermieters'} (IBAN: ${vermieter.iban || '–'}) zu überweisen. ` + + 'Ein Zurückbehaltungsrecht des Mieters ist ausgeschlossen.' + ]) + + // ── Kaution ── + absatz('§ 4 Kaution', [ + `Der Mieter leistet bei Vertragsschluss eine Kaution in Höhe von ${eur(vertrag.kaution)} ` + + '(maximal drei Nettokaltmieten, § 551 BGB).', + 'Die Kaution kann in drei gleichen monatlichen Raten geleistet werden. Sie wird auf einem Mietkonto mit Zinssatz ' + + 'für Spareinlagen mit dreimonatiger Kündigungsfrist angelegt und nach Beendigung des Mietverhältnisses ' + + 'zuzüglich der Zinsen zurückgegeben.' + ]) + + // ── Pflichten ── + absatz('§ 5 Gebrauch, Instandhaltung und Schönheitsreparaturen', [ + 'Die Wohnung ist nur zum Wohnen bestimmt. Die Überlassung an Dritte (Untervermietung) bedarf der vorherigen schriftlichen Zustimmung des Vermieters.', + 'Der Mieter hat die Wohnung ordnungsgemäß zu pflegen, kleine Instandhaltungen (z. B. Batterien in Rauchwarnmeldern) selbst zu übernehmen und Schäden unverzüglich dem Vermieter zu melden.', + 'Schönheitsreparaturen obliegen dem Mieter im üblichen Umfang (§ 28 Abs. 4 BBauG, § 21 BGB); abweichende Vereinbarungen bleiben vorbehalten.', + 'Rauchwarnmelder werden vom Vermieter bereitgestellt; der Mieter übernimmt die regelmäßige Prüfung (§§ 17, 18 BaulVO NRW).' + ]) + + // ── Schluss ── + absatz('§ 6 Schlussbestimmungen', [ + 'Änderungen und Ergänzungen dieses Vertrags bedürfen zu ihrer Wirksamkeit der Schriftform; dies gilt auch für die Aufhebung dieser Schriftformklausel.', + 'Sollte eine Bestimmung dieses Vertrags unwirksam sein oder werden, bleibt die Wirksamkeit der übrigen Bestimmungen unberührt.', + 'Es gilt das Recht der Bundesrepublik Deutschland. Gerichtsstand ist der Sitz des Vermieters, soweit dem nicht zwingend etwas entgegensteht.' + ]) + + // ── Unterschriften ── + if (y > 620) { doc.addPage(); y = 60 } + y += 10 + doc.font('Helvetica-Bold').fontSize(10.5).fillColor(FARBE_DUNKEL) + doc.text('Unterschriften', 50, y) + y += 18 + doc.font('Helvetica').fontSize(9.5).fillColor('#0f172a') + doc.text(`Ort, Datum`, 50, y) + doc.text(`Ort, Datum`, 310, y) + y += 34 + doc.moveTo(50, y).lineTo(270, y).lineWidth(0.75).strokeColor('#94a3b8').stroke() + doc.moveTo(310, y).lineTo(530, y).lineWidth(0.75).strokeColor('#94a3b8').stroke() + y += 6 + doc.font('Helvetica').fontSize(8.5).fillColor(FARBE_SOFT) + doc.text(vermieter.name || 'Vermieter', 50, y, { width: 220 }) + doc.text(vertrag.mieter_name, 310, y, { width: 220 }) + y += 22 + + // ── Fußzeile ── + doc.font('Helvetica').fontSize(8).fillColor(FARBE_SOFT) + doc.text( + 'Mustervorlage, automatisch aus dem Mieterportal erzeugt. Dieser Entwurf ersetzt keine Rechtsberatung – ' + + 'die Verwaltung prüft den Vertrag vor Versand und Unterschrift.', + 50, Math.max(y + 8, 770), { width: breite }, + ) + + doc.end() + }) + return dateiName +} + +/** Erzeugt den Vertrags-Entwurf: PDF + Dokumenten-Eintrag (ersetzt vorhandene). */ +async function erzeugeVertragsPdf(pool, vertrag) { + const { rows: einheiten } = await pool.query('SELECT * FROM units WHERE id = $1', [vertrag.unit_id]) + if (!einheiten.length) throw Object.assign(new Error('Wohnung nicht gefunden.'), { status: 404 }) + const einheit = einheiten[0] + const { rows: haeuser } = await pool.query('SELECT * FROM buildings WHERE name = $1', [einheit.building]) + if (!haeuser.length) throw Object.assign(new Error('Haus nicht gefunden.'), { status: 404 }) + const haus = haeuser[0] + const vermieter = await vermieterDaten(pool) + + const dateiName = await bauePdf(vertrag, einheit, haus, vermieter) + const groesseBytes = fs.statSync(path.join(UPLOAD_DIR, dateiName)).size + const groesse = groesseBytes > 1024 + ? `${zahl(groesseBytes / 1024)} KB` + : `${groesseBytes} B` + const titel = `Mietvertrag Entwurf – ${einheit.building}, Whg. ${einheit.number} – ${vertrag.mieter_name}` + + const { rows: vorhanden } = await pool.query( + 'SELECT * FROM documents WHERE id = $1', [vertrag.dokument_id || ''], + ) + let dokument + if (vorhanden.length) { + const alt = vorhanden[0].file_path + if (alt && alt !== dateiName) { + await fs.promises.unlink(path.join(UPLOAD_DIR, alt)).catch(() => {}) + } + const { rows } = await pool.query( + `UPDATE documents SET title = $1, file_name = $2, file_path = $3, size = $4, date = $5, category = 'Mietvertrag', unit_id = $6 + WHERE id = $7 RETURNING *`, + [titel, titel, dateiName, groesse, dDatum(), einheit.id, vorhanden[0].id], + ) + dokument = rows[0] + } else { + const dokumentId = `d-mv-${vertrag.id}` + const { rows } = await pool.query( + `INSERT INTO documents (id, unit_id, title, category, size, date, file_name, file_path) + VALUES ($1,$2,$3,'Mietvertrag',$4,$5,$6,$7) RETURNING *`, + [dokumentId, einheit.id, titel, groesse, dDatum(), titel, dateiName], + ) + dokument = rows[0] + } + await pool.query('UPDATE mietvertraege SET dokument_id = $1, geaendert_am = $2 WHERE id = $3', + [dokument.id, dDatum(), vertrag.id]) + return dokument +} + +module.exports = { erzeugeVertragsPdf, vermieterDaten, vermieterAdresse } diff --git a/src/App.tsx b/src/App.tsx index 28617ef..12bd913 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -11,6 +11,7 @@ import Messages from './pages/Messages' import Zaehler from './pages/Zaehler' import Kosten from './pages/Kosten' import Bestand from './pages/Bestand' +import Mietvertraege from './pages/Mietvertraege' import BenutzerVerwaltung from './pages/BenutzerVerwaltung' import Profil from './pages/Profil' import Anleitung from './pages/Anleitung' @@ -88,6 +89,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/components/AppLayout.tsx b/src/components/AppLayout.tsx index 0d1ff7d..22fe4f8 100644 --- a/src/components/AppLayout.tsx +++ b/src/components/AppLayout.tsx @@ -12,6 +12,7 @@ import { Calculator, UserRound, CircleHelp, + FileSignature, } from 'lucide-react' import { useStore } from '../store/store' import ThemeToggle from './ThemeToggle' @@ -45,6 +46,7 @@ export default function AppLayout() { role === 'admin' ? [...navItems, { to: '/bestand', label: 'Häuser & Wohnungen', icon: Building2 }, + { to: '/mietvertraege', label: 'Mietverträge', icon: FileSignature }, { to: '/benutzer', label: 'Benutzerverwaltung', icon: UserCog }] : navItems diff --git a/src/data/anleitung.ts b/src/data/anleitung.ts index 7f6aaf2..36257a5 100644 --- a/src/data/anleitung.ts +++ b/src/data/anleitung.ts @@ -169,6 +169,28 @@ export const anleitungAbschnitte: AnleitungAbschnitt[] = [ }, ], }, + { + gruppe: 'verwaltung', + titel: 'Mietverträge', + punkte: [ + { + titel: 'Vermieterdaten hinterlegen', + text: 'Einmalig über „Vermieterdaten" Name, Anschrift, Kontakt und Bankverbindung pflegen – diese Angaben fließen in den Briefkopf und die Klauseln der Vertrags-PDFs ein.', + }, + { + titel: 'Vertrag anlegen & Entwurf erzeugen', + text: 'Über „Neuer Mietvertrag" Wohnung, Mieterdaten und Mietkonditionen erfassen (Miete und Nebenkosten werden aus der Wohnung vorbefüllt). Der Button „Entwurf" erzeugt ein rechtskonformes Mietvertrags-PDF als Mustervorlage.', + }, + { + titel: 'Entwurf versenden', + text: '„Senden" verschickt den Entwurf als PDF-Anhang an den zukünftigen Mieter; die Verwaltung erhält automatisch eine Kopie (CC).', + }, + { + titel: 'Aktivieren', + text: 'Nach Eingang des unterschriebenen Vertrags: Scan als PDF hochladen („Vertrag hochladen"), dann „Aktivieren" – das Portal legt das Mieterkonto an (Passwort 0815), führt die Wohnung als vermietet und versendet dem Mieter die Zugangsdaten per E-Mail.', + }, + ], + }, { gruppe: 'verwaltung', titel: 'Steuer & Kosten', diff --git a/src/lib/api.ts b/src/lib/api.ts index f92958c..e64629c 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -52,6 +52,7 @@ export async function api(pfad: string, init?: RequestInit, json?: unknown): export const apiGet = (pfad: string) => api(pfad) export const apiPost = (pfad: string, body?: unknown) => api(pfad, { method: 'POST' }, body ?? {}) export const apiPatch = (pfad: string, body?: unknown) => api(pfad, { method: 'PATCH' }, body ?? {}) +export const apiPut = (pfad: string, body?: unknown) => api(pfad, { method: 'PUT' }, body ?? {}) export const apiDelete = (pfad: string) => api(pfad, { method: 'DELETE' }) /** Datei-Upload (multipart/form-data) – Content-Type NICHT setzen, das setzt der Browser samt Boundary. */ diff --git a/src/pages/Anleitung.tsx b/src/pages/Anleitung.tsx index ff79cec..e5f8a1a 100644 --- a/src/pages/Anleitung.tsx +++ b/src/pages/Anleitung.tsx @@ -14,6 +14,7 @@ import { Users, Wallet, Wrench, + FileSignature, } from 'lucide-react' import { useStore } from '../store/store' import { @@ -35,6 +36,7 @@ const Icons: Record = { 'Mein Profil': UserRound, Benutzerverwaltung: Users, 'Häuser & Wohnungen': Building2, + 'Mietverträge': FileSignature, 'Steuer & Kosten': Calculator, 'Schadensmeldungen & Kommunikation': MessagesSquare, 'Dokumente & Zählerstände': FileText, diff --git a/src/pages/Mietvertraege.tsx b/src/pages/Mietvertraege.tsx new file mode 100644 index 0000000..c6870f8 --- /dev/null +++ b/src/pages/Mietvertraege.tsx @@ -0,0 +1,557 @@ +/** Mietvertragsmodul (v1.35) – Verwaltung. + * + * Ablauf pro Vertrag: + * 1. anlegen (Wohnung + Mieterdaten + Mietkonditionen) + * 2. Entwurf als PDF erzeugen (Vermieterdaten + Bestandsdaten → Mustervertrag) + * 3. Entwurf per E-Mail an den zukünftigen Mieter versenden (CC Verwaltung) + * 4. unterschriebenen Vertrag (Scan) hochladen + * 5. manuell aktivieren → Mieterkonto wird angelegt, Wohnung als vermietet geführt + */ + +import { useMemo, useRef, useState } from 'react' +import { + FileSignature, Plus, Pencil, Trash2, Mail, FileText, Upload, UserPlus, + Settings, Search, TriangleAlert, Eye, +} from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { + Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogFooter, +} from '@/components/ui/dialog' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { + Select, SelectContent, SelectItem, SelectTrigger, SelectValue, +} from '@/components/ui/select' +import { useStore, fmtEUR } from '../store/store' +import { fmtDatum } from '../lib/format' +import PdfLightbox from '../components/PdfLightbox' +import type { DocumentItem, Mietvertrag, MietvertragForm, Unit, VermieterDaten } from '../types' + +/** Komma oder Punkt als Dezimaltrenner akzeptieren. */ +const zahl = (s: string) => Number(s.replace(',', '.')) +const dezimal = (n: number) => String(n).replace('.', ',') + +const STATUS_STIL: Record = { + entwurf: 'bg-slate-200 text-slate-700 dark:bg-slate-800 dark:text-slate-300', + versendet: 'bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300', + unterschrieben: 'bg-sky-100 text-sky-800 dark:bg-sky-950 dark:text-sky-300', + aktiv: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-300', +} +const STATUS_LABEL: Record = { + entwurf: 'Entwurf', + versendet: 'Versendet', + unterschrieben: 'Unterschrieben', + aktiv: 'Aktiv', +} + +function einheitLabel(u: Unit | undefined) { + return u ? `${u.building}, Whg. ${u.number}` : '–' +} + +/** Vermieterdaten pflegen (Basis der Vertrags-PDFs). */ +function VermieterDialog() { + const { einstellungen, speichereEinstellungen } = useStore() + const [open, setOpen] = useState(false) + const [f, setF] = useState(einstellungen) + const [sende, setSende] = useState(false) + const [fehler, setFehler] = useState(null) + const set = (k: keyof VermieterDaten) => (e: React.ChangeEvent) => + setF((alt) => ({ ...alt, [k]: e.target.value })) + + const oeffnen = () => { setF(einstellungen); setFehler(null); setOpen(true) } + const submit = async () => { + if (sende) return + setSende(true) + setFehler(null) + const err = await speichereEinstellungen(f) + setSende(false) + if (err) return setFehler(err) + setOpen(false) + } + + return ( + + + + + + + Vermieterdaten + +

+ Diese Angaben erscheinen im Briefkopf und in den Vertragsklauseln der automatisch erzeugten + Mietvertrags-Entwürfe (Name, Anschrift, Kontoverbindung). +

+
+
+ + +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ {fehler && ( +
{fehler}
+ )} +
+ + + + +
+
+ ) +} + +/** Neuanlage / Bearbeitung eines Mietvertrags. */ +function MietvertragDialog({ vertrag, einheiten }: { vertrag?: Mietvertrag; einheiten: Unit[] }) { + const { addMietvertrag, updateMietvertrag } = useStore() + const bearbeiten = Boolean(vertrag) + const [open, setOpen] = useState(false) + const [unitId, setUnitId] = useState(vertrag?.unitId ?? einheiten[0]?.id ?? '') + const [mieterName, setMieterName] = useState(vertrag?.mieterName ?? '') + const [mieterEmail, setMieterEmail] = useState(vertrag?.mieterEmail ?? '') + const [mieterTelefon, setMieterTelefon] = useState(vertrag?.mieterTelefon ?? '') + const [beginnAm, setBeginnAm] = useState(vertrag?.beginnAm ?? '') + const [endeAm, setEndeAm] = useState(vertrag?.endeAm ?? '') + const [kaltmiete, setKaltmiete] = useState(vertrag ? dezimal(vertrag.kaltmiete) : '') + const [nebenkosten, setNebenkosten] = useState(vertrag ? dezimal(vertrag.nebenkosten) : '') + const [kaution, setKaution] = useState(vertrag ? dezimal(vertrag.kaution) : '') + const [sende, setSende] = useState(false) + const [fehler, setFehler] = useState(null) + + const einheitWechseln = (id: string) => { + setUnitId(id) + const u = einheiten.find((x) => x.id === id) + if (u && !bearbeiten) { + setKaltmiete(dezimal(Number(u.coldRent))) + setNebenkosten(dezimal(Number(u.warmRent) - Number(u.coldRent))) + } + } + + const vollstaendig = unitId && mieterName.trim() && beginnAm.trim() && kaltmiete.trim() !== '' + + const submit = async () => { + if (sende) return + setSende(true) + setFehler(null) + const payload: MietvertragForm = { + unitId, + mieterName: mieterName.trim(), + mieterEmail: mieterEmail.trim(), + mieterTelefon: mieterTelefon.trim(), + beginnAm: beginnAm.trim(), + endeAm: endeAm.trim(), + kaltmiete: zahl(kaltmiete), + nebenkosten: nebenkosten.trim() === '' ? 0 : zahl(nebenkosten), + kaution: kaution.trim() === '' ? 0 : zahl(kaution), + } + const err = bearbeiten ? await updateMietvertrag(vertrag!.id, payload) : await addMietvertrag(payload) + setSende(false) + if (err) return setFehler(err) + setOpen(false) + } + + return ( + + + {bearbeiten ? ( + + ) : ( + + )} + + + + {bearbeiten ? 'Mietvertrag bearbeiten' : 'Neuen Mietvertrag anlegen'} + +
+
+ + +
+ +
+

+ Zukünftige Mieterin / zukünftiger Mieter +

+
+ + setMieterName(e.target.value)} /> +
+
+
+ + setMieterEmail(e.target.value)} /> +
+
+ + setMieterTelefon(e.target.value)} /> +
+
+
+ +
+

+ Mietkonditionen +

+
+
+ + setBeginnAm(e.target.value)} /> +
+
+ + setEndeAm(e.target.value)} /> +
+
+
+
+ + setKaltmiete(e.target.value)} /> +
+
+ + setNebenkosten(e.target.value)} /> +
+
+ + setKaution(e.target.value)} /> +
+
+
+ + {fehler && ( +
{fehler}
+ )} +
+ + + + +
+
+ ) +} + +/** Aktionen einer Vertragszeile (Entwurf / Senden / Upload / Aktivieren). */ +function Aktionen({ v }: { v: Mietvertrag }) { + const { + documents, units, deleteMietvertrag, + erzeugeVertragsEntwurf, sendeVertrag, ladeUnterschriftHoch, aktiviereMietvertrag, + } = useStore() + const [sende, setSende] = useState(false) + const [meldung, setMeldung] = useState(null) + const fileRef = useRef(null) + const entwurfDokument: DocumentItem | undefined = documents.find((d) => d.id === v.dokumentId) + const unterschriftenDokument: DocumentItem | undefined = documents.find((d) => d.id === v.unterschriftenDokumentId) + const einheit = units.find((u) => u.id === v.unitId) + + const mitMeldung = async (aufruf: () => Promise, erfolg?: string) => { + if (sende) return + setSende(true) + setMeldung(null) + const err = await aufruf() + setSende(false) + setMeldung(err ?? erfolg ?? null) + } + + const entwurfErzeugen = () => mitMeldung( + async () => (await erzeugeVertragsEntwurf(v.id)).fehler, + 'Entwurf erzeugt – PDF über das Dokumenten-Symbol öffnen.', + ) + + const versenden = () => { + if (!v.mieterEmail) { + window.alert('Bitte zuerst die E-Mail-Adresse des zukünftigen Mieters im Vertrag hinterlegen.') + return + } + if (!window.confirm(`Entwurf an ${v.mieterEmail} versenden?`)) return + mitMeldung(() => sendeVertrag(v.id), 'Entwurf versendet.') + } + + const hochladen = async (datei: File | undefined) => { + if (!datei) return + setSende(true) + setMeldung(null) + const err = await ladeUnterschriftHoch(v.id, datei) + setSende(false) + setMeldung(err ?? 'Unterschriebener Vertrag gespeichert.') + } + + const aktivieren = async () => { + const einheitLabelText = einheit ? `${einheit.building}, Whg. ${einheit.number}` : v.unitId + if (!window.confirm( + `Vertrag aktivieren?\n\nEs wird ein Mieterkonto für „${v.mieterName}" angelegt (Passwort 0815), ` + + `die Wohnung ${einheitLabelText} als vermietet geführt und der Zugang per E-Mail versendet.`, + )) return + setSende(true) + const { fehler: err, benutzername } = await aktiviereMietvertrag(v.id) + setSende(false) + if (err) return setMeldung(err) + window.alert(`Vertrag ist aktiv.\nBenutzername des neuen Mieters: ${benutzername}\nPasswort: 0815`) + setMeldung('Aktiviert.') + } + + const loeschen = async () => { + if (!window.confirm(`Mietvertrag für „${v.mieterName}" wirklich löschen?`)) return + mitMeldung(() => deleteMietvertrag(v.id)) + } + + return ( +
+
+ {/* Entwurf erzeugen/ansehen */} + {entwurfDokument ? ( + + Entwurf + + } + /> + ) : ( + + )} + + {/* Entwurf an zukünftigen Mieter versenden */} + {v.status !== 'aktiv' && ( + + )} + + {/* Unterschriebenen Vertrag hochladen */} + {v.status !== 'aktiv' && ( + <> + { hochladen(e.target.files?.[0]); e.target.value = '' }} + /> + + + )} + + {/* Unterschriften-PDF ansehen */} + {unterschriftenDokument && ( + + Vertrag + + } + /> + )} + + {/* Manuell aktivieren → Mieterkonto anlegen */} + {v.status === 'unterschrieben' && ( + + )} + + {v.status !== 'aktiv' && ( + + )} +
+ {meldung && ( + + {meldung} + + )} +
+ ) +} + +export default function Mietvertraege() { + const { mietvertraege, units, einstellungen } = useStore() + const [suche, setSuche] = useState('') + + const vermieterVollstaendig = Boolean(einstellungen.name.trim() && einstellungen.strasse.trim()) + + const gefiltert = useMemo(() => { + const q = suche.trim().toLowerCase() + if (!q) return mietvertraege + return mietvertraege.filter((v) => + [v.mieterName, v.mieterEmail, v.beginnAm, v.status, einheitLabel(units.find((u) => u.id === v.unitId))] + .some((f) => f.toLowerCase().includes(q)), + ) + }, [mietvertraege, suche, units]) + + return ( +
+
+
+

Mietverträge

+

+ Entwurf erzeugen, an zukünftige Mieter versenden, unterschriebenen Vertrag hinterlegen + und anschließend den Mieterzugang aktivieren +

+
+
+ + +
+
+ + {!vermieterVollstaendig && ( +
+ + + Die Vermieterdaten sind unvollständig – im Vertrags-PDF fehlen dann Briefkopf und Kontoverbindung. + Bitte über „Vermieterdaten" oben pflegen. + +
+ )} + + {mietvertraege.length > 0 && ( +
+ + setSuche(e.target.value)} + aria-label="Mietvertrag suchen" + /> +
+ )} + + {gefiltert.length === 0 ? ( +
+ + {suche + ? `Kein Mietvertrag passt zur Suche „${suche}".` + : 'Noch keine Mietverträge angelegt – über „Neuer Mietvertrag" starten.'} +
+ ) : ( +
+ + + + + + + + + + + + + + {gefiltert.map((v) => { + const einheit = units.find((u) => u.id === v.unitId) + return ( + + + + + + + + + + ) + })} + +
WohnungMieter/inZeitraumMieteKautionStatusAktion
+ {einheitLabel(einheit)} + {einheit &&
Etage {einheit.floor} · {einheit.size} m²
} +
+ {v.mieterName} + {v.mieterEmail &&
{v.mieterEmail}
} +
+ {fmtDatum(v.beginnAm)} – {v.endeAm ? fmtDatum(v.endeAm) : 'unbefristet'} + +
{fmtEUR.format(v.kaltmiete)} kalt
+
+ {fmtEUR.format(v.nebenkosten)} NK = {fmtEUR.format(v.kaltmiete + v.nebenkosten)}
+
{fmtEUR.format(v.kaution)} + {STATUS_LABEL[v.status]} + +
+ + {v.status !== 'aktiv' && } +
+
+
+ )} +
+ ) +} diff --git a/src/store/store.tsx b/src/store/store.tsx index 61f8691..c4f4be4 100644 --- a/src/store/store.tsx +++ b/src/store/store.tsx @@ -36,8 +36,11 @@ import type { HausForm, KostenItem, Beleg, + Mietvertrag, + MietvertragForm, + VermieterDaten, } from '../types' -import { apiGet, apiPost, apiPatch, apiDelete, ApiFehler } from '../lib/api' +import { apiGet, apiPost, apiPatch, apiPut, apiDelete, apiUpload, ApiFehler } from '../lib/api' import { sha256, DEMO_PASSWORT } from '../lib/auth' /** Fallback: Mieter ohne Wohnungszuweisung sehen die erste Einheit des Bestands. */ @@ -57,6 +60,13 @@ interface Bootstrap { buildings: Building[] kosten: KostenItem[] belege: Beleg[] + mietvertraege: Mietvertrag[] + einstellungen: VermieterDaten +} + +const LEERE_EINSTELLUNGEN: VermieterDaten = { + name: '', strasse: '', hausnummer: '', plz: '', ort: '', + telefon: '', email: '', bankname: '', iban: '', } const LEER: Bootstrap = { @@ -72,6 +82,8 @@ const LEER: Bootstrap = { buildings: [], kosten: [], belege: [], + mietvertraege: [], + einstellungen: LEERE_EINSTELLUNGEN, } interface Store extends Bootstrap { @@ -210,6 +222,20 @@ interface Store extends Bootstrap { ) => Promise /** Beleg erneut per OCR analysieren und Zuordnung aktualisieren. */ analysiereBeleg: (id: string) => Promise + // ── Mietverträge (v1.35) ───────────────────────────────── + addMietvertrag: (v: MietvertragForm) => Promise + updateMietvertrag: (id: string, v: Partial) => Promise + deleteMietvertrag: (id: string) => Promise + /** Vertrags-Entwurf als PDF erzeugen/aktualisieren (Dokumentenmodul). */ + erzeugeVertragsEntwurf: (id: string) => Promise<{ fehler: string | null; dokument?: DocumentItem }> + /** Entwurf per E-Mail an den zukünftigen Mieter versenden (CC Verwaltung). */ + sendeVertrag: (id: string) => Promise + /** Unterschriebenen Vertrag (PDF) hochladen → Status „unterschrieben". */ + ladeUnterschriftHoch: (id: string, datei: File) => Promise + /** Mieterkonto anlegen + Wohnung zuweisen (manuell ausgelöst) → Status „aktiv". */ + aktiviereMietvertrag: (id: string) => Promise<{ fehler: string | null; benutzername?: string }> + /** Vermieterdaten speichern (Basis der Vertrags-PDFs). */ + speichereEinstellungen: (d: Partial) => Promise } const StoreContext = createContext(null) @@ -651,6 +677,88 @@ export function StoreProvider({ children }: { children: ReactNode }) { return fehlerNachricht(e) } }, + // ── Mietverträge (v1.35) ───────────────────────────────── + async addMietvertrag(v) { + try { + const { mietvertrag } = await apiPost<{ mietvertrag: Mietvertrag }>('/api/mietvertraege', v) + setDaten((d) => ({ ...d, mietvertraege: [mietvertrag, ...d.mietvertraege] })) + return null + } catch (e) { + return fehlerNachricht(e) + } + }, + async updateMietvertrag(id, v) { + try { + const { mietvertrag } = await apiPatch<{ mietvertrag: Mietvertrag }>(`/api/mietvertraege/${id}`, v) + setDaten((d) => ({ ...d, mietvertraege: d.mietvertraege.map((x) => (x.id === id ? mietvertrag : x)) })) + return null + } catch (e) { + return fehlerNachricht(e) + } + }, + async deleteMietvertrag(id) { + try { + await apiDelete(`/api/mietvertraege/${id}`) + setDaten((d) => ({ ...d, mietvertraege: d.mietvertraege.filter((x) => x.id !== id) })) + return null + } catch (e) { + return fehlerNachricht(e) + } + }, + async erzeugeVertragsEntwurf(id) { + try { + const { dokument } = await apiPost<{ dokument: DocumentItem }>(`/api/mietvertraege/${id}/entwurf`) + await ladeAlles() // Dokumentenliste + dokumentId des Vertrags aktualisieren + return { fehler: null, dokument } + } catch (e) { + return { fehler: fehlerNachricht(e) } + } + }, + async sendeVertrag(id) { + try { + await apiPost(`/api/mietvertraege/${id}/versenden`) + await ladeAlles() // Status + ggf. Dokument aktualisieren + return null + } catch (e) { + return fehlerNachricht(e) + } + }, + async ladeUnterschriftHoch(id, datei) { + try { + await apiUpload<{ mietvertrag: Mietvertrag }>(`/api/mietvertraege/${id}/unterschrift`, datei) + await ladeAlles() + return null + } catch (e) { + return fehlerNachricht(e) + } + }, + async aktiviereMietvertrag(id) { + try { + const { benutzername } = await apiPost<{ mietvertrag: Mietvertrag; benutzername: string }>( + `/api/mietvertraege/${id}/aktivieren`, + ) + await ladeAlles() // Benutzer, Wohnung (Mieter), Vertragsstatus + return { fehler: null, benutzername } + } catch (e) { + return { fehler: fehlerNachricht(e) } + } + }, + async speichereEinstellungen(d) { + try { + const payload: Record = {} + const keyMap: Record = { + name: 'vermieter_name', strasse: 'vermieter_strasse', hausnummer: 'vermieter_hausnummer', + plz: 'vermieter_plz', ort: 'vermieter_ort', telefon: 'vermieter_telefon', + email: 'vermieter_email', bankname: 'vermieter_bankname', iban: 'vermieter_iban', + } + for (const [k, v] of Object.entries(d)) if (keyMap[k]) payload[keyMap[k]] = v + const { einstellungen } = await apiPut<{ einstellungen: VermieterDaten }>('/api/einstellungen', payload) + setDaten((x) => ({ ...x, einstellungen })) + return null + } catch (e) { + return fehlerNachricht(e) + } + }, }), [daten, bereit, me, role, meineEinheit, ladeAlles]) return {children} diff --git a/src/types/index.ts b/src/types/index.ts index b9a2d29..4cac5d3 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -207,3 +207,45 @@ export interface Beleg { gebaeude: string | null // zugeordnetes Haus zuordnung: 'zaehlernummer' | 'adresse' | 'manuell' | null // Zuordnungsweg } + +/** Mietvertrag (v1.35): entwurf → versendet → unterschrieben → aktiv */ +export type MietvertragStatus = 'entwurf' | 'versendet' | 'unterschrieben' | 'aktiv' + +export interface Mietvertrag { + id: string + unitId: string + mieterName: string + mieterEmail: string + mieterTelefon: string + beginnAm: string + /** leer = unbefristet */ + endeAm: string + kaltmiete: number + nebenkosten: number + kaution: number + status: MietvertragStatus + /** Entwurf als PDF (Dokumentenmodul) */ + dokumentId: string | null + /** unterschriebener Vertrag (Scan, Dokumentenmodul) */ + unterschriftenDokumentId: string | null + /** angelegtes Mieterkonto */ + benutzerId: string | null + erstelltAm: string + geaendertAm: string +} + +/** Formular-Daten für Neuanlage/Bearbeitung eines Mietvertrags */ +export type MietvertragForm = Omit + +/** Vermieter-/Portaldaten (Basis der Vertrags-PDFs, v1.35) */ +export interface VermieterDaten { + name: string + strasse: string + hausnummer: string + plz: string + ort: string + telefon: string + email: string + bankname: string + iban: string +}