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
+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
}