v1.35: Mietvertragsmodul (Entwurf-PDF, Versand, Unterschrift, Mieteranlage)
This commit is contained in:
@@ -189,6 +189,39 @@ tar -czf mp-dist.tar.gz -C dist .
|
|||||||
|
|
||||||
## 8. Verlauf / offene Punkte
|
## 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.
|
**Erledigt 04.10. (v1.34):** SparkReceipt-Zuordnung nutzt Mietobjekt-Adresse.
|
||||||
Problem: auf eingehenden Rechnungen steht als Adresse der Rechnungsempfänger
|
Problem: auf eingehenden Rechnungen steht als Adresse der Rechnungsempfänger
|
||||||
(Rott 250 = eigene Verwaltungsadresse, als Haus gepflegt) – die sekundäre
|
(Rott 250 = eigene Verwaltungsadresse, als Haus gepflegt) – die sekundäre
|
||||||
|
|||||||
@@ -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 gebaude TEXT;
|
||||||
ALTER TABLE belege ADD COLUMN IF NOT EXISTS zuordnung TEXT; -- zaehlernummer | adresse | manuell
|
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
|
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() {
|
async function init() {
|
||||||
|
|||||||
+294
-1
@@ -15,6 +15,7 @@ const { pool, init, sessionsAufraeumen } = require('./db')
|
|||||||
const { seed, seedeZaehler, seedeHaeuser, seedeKosten } = require('./seed')
|
const { seed, seedeZaehler, seedeHaeuser, seedeKosten } = require('./seed')
|
||||||
const mail = require('./mail')
|
const mail = require('./mail')
|
||||||
const { erzeugeAbrechnung } = require('./abrechnung')
|
const { erzeugeAbrechnung } = require('./abrechnung')
|
||||||
|
const { erzeugeVertragsPdf, vermieterDaten } = require('./vertrag')
|
||||||
const zaehlerVerlauf = require('./zaehlerVerlauf')
|
const zaehlerVerlauf = require('./zaehlerVerlauf')
|
||||||
|
|
||||||
const PORT = Number(process.env.PORT || 3000)
|
const PORT = Number(process.env.PORT || 3000)
|
||||||
@@ -172,6 +173,14 @@ const mapDocument = (d) => ({
|
|||||||
fileName: d.file_name ?? null, hasFile: Boolean(d.file_path),
|
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 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) => ({
|
const mapThread = (t) => ({
|
||||||
id: t.id, unitId: t.unit_id, tenant: t.tenant, subject: t.subject,
|
id: t.id, unitId: t.unit_id, tenant: t.tenant, subject: t.subject,
|
||||||
unreadByAdmin: t.unread_by_admin, unreadByTenant: t.unread_by_tenant,
|
unreadByAdmin: t.unread_by_admin, unreadByTenant: t.unread_by_tenant,
|
||||||
@@ -342,7 +351,7 @@ app.get('/api/bootstrap', auth, async (req, res, next) => {
|
|||||||
try {
|
try {
|
||||||
const istAdmin = req.user.rolle === 'verwaltung'
|
const istAdmin = req.user.rolle === 'verwaltung'
|
||||||
const einheit = req.user.einheit_id
|
const einheit = req.user.einheit_id
|
||||||
const [units, transactions, documents, tickets, threads, messages, announcements, users, codes, meters, buildings, costs, belege] =
|
const [units, transactions, documents, tickets, threads, messages, announcements, users, codes, meters, buildings, costs, belege, mietvertraege] =
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
pool.query('SELECT * FROM units ORDER BY id'),
|
pool.query('SELECT * FROM units ORDER BY id'),
|
||||||
istAdmin
|
istAdmin
|
||||||
@@ -379,6 +388,9 @@ app.get('/api/bootstrap', auth, async (req, res, next) => {
|
|||||||
istAdmin
|
istAdmin
|
||||||
? pool.query('SELECT * FROM belege ORDER BY erfasst_am DESC')
|
? pool.query('SELECT * FROM belege ORDER BY erfasst_am DESC')
|
||||||
: { rows: [] },
|
: { rows: [] },
|
||||||
|
istAdmin
|
||||||
|
? pool.query('SELECT * FROM mietvertraege ORDER BY erstellt_am DESC')
|
||||||
|
: { rows: [] },
|
||||||
])
|
])
|
||||||
// Threads mit ihren Nachrichten zusammenführen
|
// Threads mit ihren Nachrichten zusammenführen
|
||||||
const nachrichtenProThread = {}
|
const nachrichtenProThread = {}
|
||||||
@@ -402,6 +414,8 @@ app.get('/api/bootstrap', auth, async (req, res, next) => {
|
|||||||
buildings: buildings.rows.map(mapBuilding),
|
buildings: buildings.rows.map(mapBuilding),
|
||||||
kosten: costs.rows.map(mapKosten),
|
kosten: costs.rows.map(mapKosten),
|
||||||
belege: belege.rows.map(mapBeleg),
|
belege: belege.rows.map(mapBeleg),
|
||||||
|
mietvertraege: mietvertraege.rows.map(mapMietvertrag),
|
||||||
|
einstellungen: await vermieterDaten(pool),
|
||||||
})
|
})
|
||||||
} catch (e) { next(e) }
|
} catch (e) { next(e) }
|
||||||
})
|
})
|
||||||
@@ -802,6 +816,12 @@ app.delete('/api/documents/:id', auth, adminOnly, async (req, res, next) => {
|
|||||||
try {
|
try {
|
||||||
const { rows } = await pool.query('DELETE FROM documents WHERE id = $1 RETURNING file_path', [req.params.id])
|
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.' })
|
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) {
|
if (rows[0].file_path) {
|
||||||
// Datei best-effort mitlöschen (Fehler hier dürfen den Request nicht killen)
|
// 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(() => {})
|
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'],
|
['threads', 'unit_id', 'Unterhaltungen'],
|
||||||
['registration_codes', 'einheit_id', 'Registrierungscodes'],
|
['registration_codes', 'einheit_id', 'Registrierungscodes'],
|
||||||
['belege', 'unit_id', 'Belege'],
|
['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])
|
const { rows: r } = await pool.query(`SELECT COUNT(*)::int AS n FROM ${tabelle} WHERE ${spalte} = $1`, [req.params.id])
|
||||||
if (r[0].n > 0) blocker.push(`${r[0].n} ${label}`)
|
if (r[0].n > 0) blocker.push(`${r[0].n} ${label}`)
|
||||||
@@ -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 ─────────────────────────────────────────
|
// ── Fehlerbehandlung ─────────────────────────────────────────
|
||||||
app.use((err, _req, res, _next) => {
|
app.use((err, _req, res, _next) => {
|
||||||
console.error('[api]', err)
|
console.error('[api]', err)
|
||||||
|
|||||||
@@ -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 }
|
||||||
@@ -11,6 +11,7 @@ import Messages from './pages/Messages'
|
|||||||
import Zaehler from './pages/Zaehler'
|
import Zaehler from './pages/Zaehler'
|
||||||
import Kosten from './pages/Kosten'
|
import Kosten from './pages/Kosten'
|
||||||
import Bestand from './pages/Bestand'
|
import Bestand from './pages/Bestand'
|
||||||
|
import Mietvertraege from './pages/Mietvertraege'
|
||||||
import BenutzerVerwaltung from './pages/BenutzerVerwaltung'
|
import BenutzerVerwaltung from './pages/BenutzerVerwaltung'
|
||||||
import Profil from './pages/Profil'
|
import Profil from './pages/Profil'
|
||||||
import Anleitung from './pages/Anleitung'
|
import Anleitung from './pages/Anleitung'
|
||||||
@@ -88,6 +89,7 @@ export default function App() {
|
|||||||
<Route path="/zaehler" element={<Zaehler />} />
|
<Route path="/zaehler" element={<Zaehler />} />
|
||||||
<Route path="/kosten" element={<Kosten />} />
|
<Route path="/kosten" element={<Kosten />} />
|
||||||
<Route path="/bestand" element={<Bestand />} />
|
<Route path="/bestand" element={<Bestand />} />
|
||||||
|
<Route path="/mietvertraege" element={<Mietvertraege />} />
|
||||||
<Route path="/dokumente" element={<Documents />} />
|
<Route path="/dokumente" element={<Documents />} />
|
||||||
<Route path="/nachrichten" element={<Messages />} />
|
<Route path="/nachrichten" element={<Messages />} />
|
||||||
<Route path="/anleitung" element={<Anleitung />} />
|
<Route path="/anleitung" element={<Anleitung />} />
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
Calculator,
|
Calculator,
|
||||||
UserRound,
|
UserRound,
|
||||||
CircleHelp,
|
CircleHelp,
|
||||||
|
FileSignature,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useStore } from '../store/store'
|
import { useStore } from '../store/store'
|
||||||
import ThemeToggle from './ThemeToggle'
|
import ThemeToggle from './ThemeToggle'
|
||||||
@@ -45,6 +46,7 @@ export default function AppLayout() {
|
|||||||
role === 'admin'
|
role === 'admin'
|
||||||
? [...navItems,
|
? [...navItems,
|
||||||
{ to: '/bestand', label: 'Häuser & Wohnungen', icon: Building2 },
|
{ to: '/bestand', label: 'Häuser & Wohnungen', icon: Building2 },
|
||||||
|
{ to: '/mietvertraege', label: 'Mietverträge', icon: FileSignature },
|
||||||
{ to: '/benutzer', label: 'Benutzerverwaltung', icon: UserCog }]
|
{ to: '/benutzer', label: 'Benutzerverwaltung', icon: UserCog }]
|
||||||
: navItems
|
: navItems
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
gruppe: 'verwaltung',
|
||||||
titel: 'Steuer & Kosten',
|
titel: 'Steuer & Kosten',
|
||||||
|
|||||||
@@ -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 apiGet = <T>(pfad: string) => api<T>(pfad)
|
||||||
export const apiPost = <T>(pfad: string, body?: unknown) => api<T>(pfad, { method: 'POST' }, body ?? {})
|
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 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' })
|
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. */
|
/** Datei-Upload (multipart/form-data) – Content-Type NICHT setzen, das setzt der Browser samt Boundary. */
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
Users,
|
Users,
|
||||||
Wallet,
|
Wallet,
|
||||||
Wrench,
|
Wrench,
|
||||||
|
FileSignature,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useStore } from '../store/store'
|
import { useStore } from '../store/store'
|
||||||
import {
|
import {
|
||||||
@@ -35,6 +36,7 @@ const Icons: Record<string, typeof Info> = {
|
|||||||
'Mein Profil': UserRound,
|
'Mein Profil': UserRound,
|
||||||
Benutzerverwaltung: Users,
|
Benutzerverwaltung: Users,
|
||||||
'Häuser & Wohnungen': Building2,
|
'Häuser & Wohnungen': Building2,
|
||||||
|
'Mietverträge': FileSignature,
|
||||||
'Steuer & Kosten': Calculator,
|
'Steuer & Kosten': Calculator,
|
||||||
'Schadensmeldungen & Kommunikation': MessagesSquare,
|
'Schadensmeldungen & Kommunikation': MessagesSquare,
|
||||||
'Dokumente & Zählerstände': FileText,
|
'Dokumente & Zählerstände': FileText,
|
||||||
|
|||||||
@@ -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
@@ -36,8 +36,11 @@ import type {
|
|||||||
HausForm,
|
HausForm,
|
||||||
KostenItem,
|
KostenItem,
|
||||||
Beleg,
|
Beleg,
|
||||||
|
Mietvertrag,
|
||||||
|
MietvertragForm,
|
||||||
|
VermieterDaten,
|
||||||
} from '../types'
|
} 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'
|
import { sha256, DEMO_PASSWORT } from '../lib/auth'
|
||||||
|
|
||||||
/** Fallback: Mieter ohne Wohnungszuweisung sehen die erste Einheit des Bestands. */
|
/** Fallback: Mieter ohne Wohnungszuweisung sehen die erste Einheit des Bestands. */
|
||||||
@@ -57,6 +60,13 @@ interface Bootstrap {
|
|||||||
buildings: Building[]
|
buildings: Building[]
|
||||||
kosten: KostenItem[]
|
kosten: KostenItem[]
|
||||||
belege: Beleg[]
|
belege: Beleg[]
|
||||||
|
mietvertraege: Mietvertrag[]
|
||||||
|
einstellungen: VermieterDaten
|
||||||
|
}
|
||||||
|
|
||||||
|
const LEERE_EINSTELLUNGEN: VermieterDaten = {
|
||||||
|
name: '', strasse: '', hausnummer: '', plz: '', ort: '',
|
||||||
|
telefon: '', email: '', bankname: '', iban: '',
|
||||||
}
|
}
|
||||||
|
|
||||||
const LEER: Bootstrap = {
|
const LEER: Bootstrap = {
|
||||||
@@ -72,6 +82,8 @@ const LEER: Bootstrap = {
|
|||||||
buildings: [],
|
buildings: [],
|
||||||
kosten: [],
|
kosten: [],
|
||||||
belege: [],
|
belege: [],
|
||||||
|
mietvertraege: [],
|
||||||
|
einstellungen: LEERE_EINSTELLUNGEN,
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Store extends Bootstrap {
|
interface Store extends Bootstrap {
|
||||||
@@ -210,6 +222,20 @@ interface Store extends Bootstrap {
|
|||||||
) => Promise<string | null>
|
) => Promise<string | null>
|
||||||
/** Beleg erneut per OCR analysieren und Zuordnung aktualisieren. */
|
/** Beleg erneut per OCR analysieren und Zuordnung aktualisieren. */
|
||||||
analysiereBeleg: (id: string) => Promise<string | null>
|
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)
|
const StoreContext = createContext<Store | null>(null)
|
||||||
@@ -651,6 +677,88 @@ export function StoreProvider({ children }: { children: ReactNode }) {
|
|||||||
return fehlerNachricht(e)
|
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])
|
}), [daten, bereit, me, role, meineEinheit, ladeAlles])
|
||||||
|
|
||||||
return <StoreContext.Provider value={value}>{children}</StoreContext.Provider>
|
return <StoreContext.Provider value={value}>{children}</StoreContext.Provider>
|
||||||
|
|||||||
@@ -207,3 +207,45 @@ export interface Beleg {
|
|||||||
gebaeude: string | null // zugeordnetes Haus
|
gebaeude: string | null // zugeordnetes Haus
|
||||||
zuordnung: 'zaehlernummer' | 'adresse' | 'manuell' | null // Zuordnungsweg
|
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
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user