v1.35: Mietvertragsmodul (Entwurf-PDF, Versand, Unterschrift, Mieteranlage)

This commit is contained in:
2026-10-04 19:39:22 +02:00
parent c82e399a29
commit 9e74813e22
12 changed files with 1334 additions and 2 deletions
+33
View File
@@ -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-<id>, 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
+29
View File
@@ -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() {
+294 -1
View File
@@ -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)
+241
View File
@@ -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 }
+2
View File
@@ -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() {
<Route path="/zaehler" element={<Zaehler />} />
<Route path="/kosten" element={<Kosten />} />
<Route path="/bestand" element={<Bestand />} />
<Route path="/mietvertraege" element={<Mietvertraege />} />
<Route path="/dokumente" element={<Documents />} />
<Route path="/nachrichten" element={<Messages />} />
<Route path="/anleitung" element={<Anleitung />} />
+2
View File
@@ -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
+22
View File
@@ -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',
+1
View File
@@ -52,6 +52,7 @@ export async function api<T>(pfad: string, init?: RequestInit, json?: unknown):
export const apiGet = <T>(pfad: string) => api<T>(pfad)
export const apiPost = <T>(pfad: string, body?: unknown) => api<T>(pfad, { method: 'POST' }, body ?? {})
export const apiPatch = <T>(pfad: string, body?: unknown) => api<T>(pfad, { method: 'PATCH' }, body ?? {})
export const apiPut = <T>(pfad: string, body?: unknown) => api<T>(pfad, { method: 'PUT' }, body ?? {})
export const apiDelete = <T>(pfad: string) => api<T>(pfad, { method: 'DELETE' })
/** Datei-Upload (multipart/form-data) – Content-Type NICHT setzen, das setzt der Browser samt Boundary. */
+2
View File
@@ -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<string, typeof Info> = {
'Mein Profil': UserRound,
Benutzerverwaltung: Users,
'Häuser & Wohnungen': Building2,
'Mietverträge': FileSignature,
'Steuer & Kosten': Calculator,
'Schadensmeldungen & Kommunikation': MessagesSquare,
'Dokumente & Zählerstände': FileText,
+557
View File
@@ -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<Mietvertrag['status'], string> = {
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<Mietvertrag['status'], string> = {
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<VermieterDaten>(einstellungen)
const [sende, setSende] = useState(false)
const [fehler, setFehler] = useState<string | null>(null)
const set = (k: keyof VermieterDaten) => (e: React.ChangeEvent<HTMLInputElement>) =>
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 (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" className="gap-2" onClick={oeffnen}>
<Settings className="h-4 w-4" /> Vermieterdaten
</Button>
</DialogTrigger>
<DialogContent className="max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Vermieterdaten</DialogTitle>
</DialogHeader>
<p className="text-xs text-slate-500 dark:text-slate-400">
Diese Angaben erscheinen im Briefkopf und in den Vertragsklauseln der automatisch erzeugten
Mietvertrags-Entwürfe (Name, Anschrift, Kontoverbindung).
</p>
<div className="space-y-3">
<div className="space-y-1.5">
<Label htmlFor="v-name">Name des Vermieters</Label>
<Input id="v-name" placeholder="z. B. Hinterstein Immobilien, Inhaber Joseph Hinterstein" value={f.name} onChange={set('name')} />
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="v-strasse">Straße</Label>
<Input id="v-strasse" value={f.strasse} onChange={set('strasse')} />
</div>
<div className="space-y-1.5">
<Label htmlFor="v-nr">Hausnummer</Label>
<Input id="v-nr" value={f.hausnummer} onChange={set('hausnummer')} />
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="v-plz">PLZ</Label>
<Input id="v-plz" value={f.plz} onChange={set('plz')} />
</div>
<div className="space-y-1.5">
<Label htmlFor="v-ort">Ort</Label>
<Input id="v-ort" value={f.ort} onChange={set('ort')} />
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="v-tel">Telefon</Label>
<Input id="v-tel" value={f.telefon} onChange={set('telefon')} />
</div>
<div className="space-y-1.5">
<Label htmlFor="v-mail">E-Mail</Label>
<Input id="v-mail" type="email" value={f.email} onChange={set('email')} />
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="v-bank">Bank</Label>
<Input id="v-bank" value={f.bankname} onChange={set('bankname')} />
</div>
<div className="space-y-1.5">
<Label htmlFor="v-iban">IBAN</Label>
<Input id="v-iban" placeholder="DE00 0000 0000 0000 0000 00" value={f.iban} onChange={set('iban')} />
</div>
</div>
{fehler && (
<div className="rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700 dark:bg-red-950 dark:text-red-300" role="alert">{fehler}</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>Abbrechen</Button>
<Button onClick={submit} disabled={sende || !f.name.trim()}>
{sende ? 'Wird gespeichert …' : 'Speichern'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
/** 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<string | null>(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 (
<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 Mietvertrag
</Button>
)}
</DialogTrigger>
<DialogContent className="max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{bearbeiten ? 'Mietvertrag bearbeiten' : 'Neuen Mietvertrag anlegen'}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-1.5">
<Label>Wohnung</Label>
<Select value={unitId} onValueChange={einheitWechseln}>
<SelectTrigger><SelectValue placeholder="Wohnung wählen …" /></SelectTrigger>
<SelectContent>
{einheiten.map((u) => (
<SelectItem key={u.id} value={u.id}>
{u.building}, Whg. {u.number} (Etage {u.floor}, {u.size} m²{u.status === 'frei' ? ', frei' : ''})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<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">
Zukünftige Mieterin / zukünftiger Mieter
</h4>
<div className="space-y-1.5">
<Label htmlFor="m-name">Name (Vor- und Nachname)</Label>
<Input id="m-name" placeholder="z. B. Anna Berger" value={mieterName} onChange={(e) => setMieterName(e.target.value)} />
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="m-mail">E-Mail</Label>
<Input id="m-mail" type="email" placeholder="für Versand des Entwurfs" value={mieterEmail} onChange={(e) => setMieterEmail(e.target.value)} />
</div>
<div className="space-y-1.5">
<Label htmlFor="m-tel">Telefon</Label>
<Input id="m-tel" value={mieterTelefon} onChange={(e) => setMieterTelefon(e.target.value)} />
</div>
</div>
</div>
<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">
Mietkonditionen
</h4>
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="m-beginn">Mietbeginn</Label>
<Input id="m-beginn" placeholder="TT.MM.JJJJ" value={beginnAm} onChange={(e) => setBeginnAm(e.target.value)} />
</div>
<div className="space-y-1.5">
<Label htmlFor="m-ende">Mietende</Label>
<Input id="m-ende" placeholder="leer = unbefristet" value={endeAm} onChange={(e) => setEndeAm(e.target.value)} />
</div>
</div>
<div className="grid gap-3 sm:grid-cols-3">
<div className="space-y-1.5">
<Label htmlFor="m-kalt">Kaltmiete (€/Monat)</Label>
<Input id="m-kalt" inputMode="decimal" value={kaltmiete} onChange={(e) => setKaltmiete(e.target.value)} />
</div>
<div className="space-y-1.5">
<Label htmlFor="m-nk">Nebenkosten (€/Monat)</Label>
<Input id="m-nk" inputMode="decimal" value={nebenkosten} onChange={(e) => setNebenkosten(e.target.value)} />
</div>
<div className="space-y-1.5">
<Label htmlFor="m-kaution">Kaution (€)</Label>
<Input id="m-kaution" inputMode="decimal" value={kaution} onChange={(e) => setKaution(e.target.value)} />
</div>
</div>
</div>
{fehler && (
<div className="rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700 dark:bg-red-950 dark:text-red-300" role="alert">{fehler}</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>Abbrechen</Button>
<Button onClick={submit} disabled={sende || !vollstaendig}>
{sende ? 'Wird gespeichert …' : bearbeiten ? 'Speichern' : 'Vertrag anlegen'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
/** 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<string | null>(null)
const fileRef = useRef<HTMLInputElement>(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<string | null>, 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 (
<div className="flex flex-col items-end gap-1.5">
<div className="flex flex-wrap justify-end gap-1.5">
{/* Entwurf erzeugen/ansehen */}
{entwurfDokument ? (
<PdfLightbox
dokument={entwurfDokument}
trigger={
<Button variant="outline" size="sm" className="gap-1.5" title="Vertrags-Entwurf (PDF)">
<Eye className="h-3.5 w-3.5" /> Entwurf
</Button>
}
/>
) : (
<Button variant="outline" size="sm" className="gap-1.5" onClick={entwurfErzeugen} disabled={sende} title="Entwurf als PDF erzeugen">
<FileText className="h-3.5 w-3.5" /> Entwurf
</Button>
)}
{/* Entwurf an zukünftigen Mieter versenden */}
{v.status !== 'aktiv' && (
<Button variant="outline" size="sm" className="gap-1.5" onClick={versenden} disabled={sende} title="Entwurf per E-Mail versenden (CC Verwaltung)">
<Mail className="h-3.5 w-3.5" /> {v.status === 'entwurf' ? 'Senden' : 'Erneut senden'}
</Button>
)}
{/* Unterschriebenen Vertrag hochladen */}
{v.status !== 'aktiv' && (
<>
<input
ref={fileRef}
type="file"
accept="application/pdf"
className="hidden"
onChange={(e) => { hochladen(e.target.files?.[0]); e.target.value = '' }}
/>
<Button variant="outline" size="sm" className="gap-1.5" onClick={() => fileRef.current?.click()} disabled={sende}
title="Unterschriebenen Vertrag (PDF) hochladen">
<Upload className="h-3.5 w-3.5" /> Vertrag hochladen
</Button>
</>
)}
{/* Unterschriften-PDF ansehen */}
{unterschriftenDokument && (
<PdfLightbox
dokument={unterschriftenDokument}
trigger={
<Button variant="outline" size="sm" className="gap-1.5" title="Unterschriebener Vertrag (PDF)">
<FileSignature className="h-3.5 w-3.5" /> Vertrag
</Button>
}
/>
)}
{/* Manuell aktivieren → Mieterkonto anlegen */}
{v.status === 'unterschrieben' && (
<Button size="sm" className="gap-1.5" onClick={aktivieren} disabled={sende}
title="Mieterkonto anlegen, Wohnung als vermietet führen, Zugang per E-Mail senden">
<UserPlus className="h-3.5 w-3.5" /> Aktivieren
</Button>
)}
{v.status !== 'aktiv' && (
<button
type="button"
onClick={loeschen}
disabled={sende}
title="Mietvertrag löschen"
aria-label={`Mietvertrag für ${v.mieterName} löschen`}
className="text-slate-400 transition-colors hover:text-red-600 disabled:opacity-40 dark:text-slate-500 dark:hover:text-red-400"
>
<Trash2 className="h-4 w-4" />
</button>
)}
</div>
{meldung && (
<span className={`max-w-64 text-right text-xs ${meldung.includes('fehl') || meldung.includes('Bitte') ? 'text-red-600 dark:text-red-400' : 'text-emerald-600 dark:text-emerald-400'}`}>
{meldung}
</span>
)}
</div>
)
}
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 (
<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">Mietverträge</h1>
<p className="text-sm text-slate-500 dark:text-slate-400">
Entwurf erzeugen, an zukünftige Mieter versenden, unterschriebenen Vertrag hinterlegen
und anschließend den Mieterzugang aktivieren
</p>
</div>
<div className="flex flex-wrap gap-2">
<VermieterDialog />
<MietvertragDialog einheiten={units} />
</div>
</div>
{!vermieterVollstaendig && (
<div className="flex items-start gap-2 rounded-2xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900 dark:border-amber-900 dark:bg-amber-950/60 dark:text-amber-200">
<TriangleAlert className="mt-0.5 h-4 w-4 shrink-0" />
<span>
Die Vermieterdaten sind unvollständig – im Vertrags-PDF fehlen dann Briefkopf und Kontoverbindung.
Bitte über <strong>„Vermieterdaten"</strong> oben pflegen.
</span>
</div>
)}
{mietvertraege.length > 0 && (
<div className="relative 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="Vertrag suchen (Mieter, Wohnung, Status) …"
value={suche}
onChange={(e) => setSuche(e.target.value)}
aria-label="Mietvertrag suchen"
/>
</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">
<FileSignature className="mx-auto mb-3 h-8 w-8 text-slate-300 dark:text-slate-600" />
{suche
? `Kein Mietvertrag passt zur Suche „${suche}".`
: 'Noch keine Mietverträge angelegt – über „Neuer Mietvertrag" starten.'}
</div>
) : (
<div className="overflow-x-auto rounded-2xl border bg-white dark:border-slate-700 dark:bg-slate-900">
<table className="w-full min-w-[880px] 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/in</th>
<th className="px-3 py-2.5">Zeitraum</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="px-3 py-2.5">Status</th>
<th className="px-3 py-2.5 text-right">Aktion</th>
</tr>
</thead>
<tbody>
{gefiltert.map((v) => {
const einheit = units.find((u) => u.id === v.unitId)
return (
<tr key={v.id} className="border-b align-top 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">
{einheitLabel(einheit)}
{einheit && <div className="text-xs font-normal text-slate-400">Etage {einheit.floor} · {einheit.size} m²</div>}
</td>
<td className="px-3 py-2.5">
{v.mieterName}
{v.mieterEmail && <div className="text-xs text-slate-400">{v.mieterEmail}</div>}
</td>
<td className="px-3 py-2.5 text-slate-600 dark:text-slate-300">
{fmtDatum(v.beginnAm)} – {v.endeAm ? fmtDatum(v.endeAm) : 'unbefristet'}
</td>
<td className="hidden px-3 py-2.5 md:table-cell">
<div className="tabular-nums">{fmtEUR.format(v.kaltmiete)} <span className="text-xs text-slate-400">kalt</span></div>
<div className="text-xs text-slate-400">+ {fmtEUR.format(v.nebenkosten)} NK = {fmtEUR.format(v.kaltmiete + v.nebenkosten)}</div>
</td>
<td className="hidden px-3 py-2.5 tabular-nums lg:table-cell">{fmtEUR.format(v.kaution)}</td>
<td className="px-3 py-2.5">
<Badge variant="secondary" className={STATUS_STIL[v.status]}>{STATUS_LABEL[v.status]}</Badge>
</td>
<td className="px-3 py-2.5">
<div className="flex flex-col items-end gap-2">
<Aktionen v={v} />
{v.status !== 'aktiv' && <MietvertragDialog vertrag={v} einheiten={units} />}
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
</div>
)
}
+109 -1
View File
@@ -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<string | null>
/** Beleg erneut per OCR analysieren und Zuordnung aktualisieren. */
analysiereBeleg: (id: string) => Promise<string | null>
// ── Mietverträge (v1.35) ─────────────────────────────────
addMietvertrag: (v: MietvertragForm) => Promise<string | null>
updateMietvertrag: (id: string, v: Partial<MietvertragForm>) => Promise<string | null>
deleteMietvertrag: (id: string) => Promise<string | null>
/** 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<string | null>
/** Unterschriebenen Vertrag (PDF) hochladen → Status „unterschrieben". */
ladeUnterschriftHoch: (id: string, datei: File) => Promise<string | null>
/** 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<VermieterDaten>) => Promise<string | null>
}
const StoreContext = createContext<Store | null>(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<string, string> = {}
const keyMap: Record<string, string> = {
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 <StoreContext.Provider value={value}>{children}</StoreContext.Provider>
+42
View File
@@ -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<Mietvertrag, 'id' | 'status' | 'dokumentId' | 'unterschriftenDokumentId' | 'benutzerId' | 'erstelltAm' | 'geaendertAm'>
/** 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
}