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

This commit is contained in:
2026-10-04 20:51:00 +02:00
parent 9e74813e22
commit 9d14d367ee
22 changed files with 1840 additions and 107 deletions
+4
View File
@@ -12,6 +12,8 @@ import Zaehler from './pages/Zaehler'
import Kosten from './pages/Kosten'
import Bestand from './pages/Bestand'
import Mietvertraege from './pages/Mietvertraege'
import MieterVerwaltung from './pages/MieterVerwaltung'
import Assistent from './pages/Assistent'
import BenutzerVerwaltung from './pages/BenutzerVerwaltung'
import Profil from './pages/Profil'
import Anleitung from './pages/Anleitung'
@@ -90,6 +92,8 @@ export default function App() {
<Route path="/kosten" element={<Kosten />} />
<Route path="/bestand" element={<Bestand />} />
<Route path="/mietvertraege" element={<Mietvertraege />} />
<Route path="/mieter" element={<MieterVerwaltung />} />
<Route path="/assistent" element={<Assistent />} />
<Route path="/dokumente" element={<Documents />} />
<Route path="/nachrichten" element={<Messages />} />
<Route path="/anleitung" element={<Anleitung />} />
+5 -1
View File
@@ -13,6 +13,8 @@ import {
UserRound,
CircleHelp,
FileSignature,
Users,
Sparkles,
} from 'lucide-react'
import { useStore } from '../store/store'
import ThemeToggle from './ThemeToggle'
@@ -46,8 +48,10 @@ export default function AppLayout() {
role === 'admin'
? [...navItems,
{ to: '/bestand', label: 'Häuser & Wohnungen', icon: Building2 },
{ to: '/mieter', label: 'Mieterverwaltung', icon: Users },
{ to: '/mietvertraege', label: 'Mietverträge', icon: FileSignature },
{ to: '/benutzer', label: 'Benutzerverwaltung', icon: UserCog }]
{ to: '/benutzer', label: 'Benutzerverwaltung', icon: UserCog },
{ to: '/assistent', label: 'KI-Assistent', icon: Sparkles }]
: navItems
return (
+41 -1
View File
@@ -165,7 +165,29 @@ export const anleitungAbschnitte: AnleitungAbschnitt[] = [
},
{
titel: 'Wohnungen zuordnen',
text: 'Jede Wohnung gehört zu einem Haus: Etage, Wohnfläche, Kalt-/Warmmiete, Mieter, Status (vermietet/frei) und optional Heizungsart je Einheit pflegen – bei Neuanlage wird die Heizungsart automatisch aus dem Haus übernommen. Der Bestand erscheint anschließend im Dashboard.',
text: 'Jede Wohnung gehört zu einem Haus: Etage, Wohnfläche, Heizungsart und Status (Vermietet, Leerstand oder Unklar) je Einheit pflegen – bei Neuanlage wird die Heizungsart automatisch aus dem Haus übernommen und der Status auf Leerstand gesetzt. Miete und Mieter werden nicht mehr am Wohnungsobjekt gepflegt, sondern in der Mieterverwaltung.',
},
],
},
{
gruppe: 'verwaltung',
titel: 'Mieterverwaltung',
punkte: [
{
titel: 'Mieter anlegen',
text: 'Über „Neuer Mieter" eine Wohneinheit belegen: Name, Vorname, Telefon und E-Mail, Kalt-/Warmmiete, Vertragsstatus, Mietzeitraum und Kaution (Höhe, Art Bar/Überwiesen/Andere, Zahlung komplett oder in Raten). Beim Speichern wird die Wohnung automatisch auf „Vermietet" gesetzt.',
},
{
titel: 'Kündigung pflegen',
text: 'Bei Vertragsstatus „Gekündigt" erscheinen die Felder „Gekündigt zum" (Datum) und „Gekündigt von" (Mieter oder Vermieter).',
},
{
titel: 'Dokumente hochladen',
text: 'Im Bearbeiten-Dialog je Kategorie (Personalausweis, SCHUFA-Auskunft, Bankauszug, Sonstiges) einen Scan oder ein PDF hochladen – die Miniaturvorschau öffnet per Klick die Lightbox mit Druck- und E-Mail-Option. Ein erneuter Upload ersetzt die Datei der Kategorie.',
},
{
titel: 'Mieter löschen',
text: 'In der Listenansicht löschbar; die Wohnung wird dabei automatisch wieder auf „Leerstand" gesetzt.',
},
],
},
@@ -227,6 +249,24 @@ export const anleitungAbschnitte: AnleitungAbschnitt[] = [
},
],
},
{
gruppe: 'verwaltung',
titel: 'KI-Assistent',
punkte: [
{
titel: 'Fragen stellen',
text: 'Im Modul „KI-Assistent" Fragen auf Deutsch stellen, z. B. „Welche Wohnungen stehen leer?" oder „Wie hoch ist das monatliche Miet-Soll?". Der Assistent beantwortet sie über Kimi mit Live-Zugriff auf Bestand, Mieter, Zähler, Kosten und Schäden.',
},
{
titel: 'Zugrunde liegende Daten prüfen',
text: 'Antworten werden aus den Portal-Daten erzeugt, können aber fehlerhaft sein. Kritische Zahlen und Personendaten bitte im jeweiligen Modul gegenprüfen.',
},
{
titel: 'Voraussetzungen & Kosten',
text: 'Der Assistent läuft über Ihren n8n-Container und die Kimi-API (Moonshot). Die Einrichtung ist in der Projektdatei n8n/README-assistent.md beschrieben; der typische Verbrauch liegt bei wenigen Euro pro Monat. Ohne konfigurierten Webhook meldet der Assistent, dass er noch nicht einsatzbereit ist.',
},
],
},
{
gruppe: 'verwaltung',
titel: 'Dokumente & Zählerstände',
+3
View File
@@ -15,6 +15,7 @@ import {
Wallet,
Wrench,
FileSignature,
Sparkles,
} from 'lucide-react'
import { useStore } from '../store/store'
import {
@@ -36,6 +37,8 @@ const Icons: Record<string, typeof Info> = {
'Mein Profil': UserRound,
Benutzerverwaltung: Users,
'Häuser & Wohnungen': Building2,
Mieterverwaltung: Users,
'KI-Assistent': Sparkles,
'Mietverträge': FileSignature,
'Steuer & Kosten': Calculator,
'Schadensmeldungen & Kommunikation': MessagesSquare,
+168
View File
@@ -0,0 +1,168 @@
import { useRef, useState } from 'react'
import { Bot, CircleAlert, Eraser, Send, Sparkles, UserRound } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { apiPost } from '../lib/api'
import { useStore } from '../store/store'
interface Nachricht {
rolle: 'user' | 'assistant'
text: string
}
const BEISPIELE = [
'Welche Wohnungen stehen leer?',
'Wie hoch ist das monatliche Miet-Soll?',
'Gibt es offene Schadensmeldungen?',
'Bei welchen Zählern gab es Auffälligkeiten?',
'Wie hoch waren die umlegbaren Kosten 2026?',
]
/** KI-Assistent (v1.37): Verwaltung fragt, N8N + Kimi antworten.
* Der Verlauf lebt nur in dieser Sitzung (keine Speicherung). */
export default function Assistent() {
const { role } = useStore()
const [nachrichten, setNachrichten] = useState<Nachricht[]>([])
const [eingabe, setEingabe] = useState('')
const [sende, setSende] = useState(false)
const [fehler, setFehler] = useState<string | null>(null)
const endeRef = useRef<HTMLDivElement>(null)
const senden = async (frage: string) => {
const text = frage.trim()
if (!text || sende) return
setSende(true)
setFehler(null)
const verlauf = [...nachrichten, { rolle: 'user' as const, text }]
setNachrichten(verlauf)
setEingabe('')
try {
const { antwort } = await apiPost<{ antwort: string }>('/api/assist', {
frage: text,
verlauf: verlauf.slice(-20),
})
setNachrichten((x) => [...x, { rolle: 'assistant', text: antwort }])
} catch (e) {
setFehler(e instanceof Error ? e.message : 'Assistent antwortet nicht.')
// Nutzerfrage zurücknehmen, damit der Verlauf nicht kippt
setNachrichten((x) => x.slice(0, -1))
} finally {
setSende(false)
setTimeout(() => endeRef.current?.scrollIntoView({ behavior: 'smooth' }), 50)
}
}
if (role !== 'admin') {
return (
<div className="rounded-2xl border border-dashed bg-white p-10 text-center text-sm text-slate-500 dark:text-slate-400">
Der KI-Assistent steht nur der Verwaltung zur Verfügung.
</div>
)
}
return (
<div className="flex h-[calc(100vh-12rem)] flex-col space-y-4">
<div className="flex flex-wrap items-end justify-between gap-3">
<div>
<h1 className="flex items-center gap-2 text-2xl font-bold tracking-tight">
<Sparkles className="h-6 w-6 text-teal-600 dark:text-teal-400" /> KI-Assistent
</h1>
<p className="text-sm text-slate-500 dark:text-slate-400">
Fragen an das Portal – beantwortet von Kimi über N8N mit Live-Zugriff auf Bestand, Mieter,
Zähler, Kosten und Schäden. Antworten können fehlerhaft sein – kritische Daten bitte prüfen.
</p>
</div>
{nachrichten.length > 0 && (
<Button variant="outline" size="sm" className="gap-1.5" onClick={() => setNachrichten([])}>
<Eraser className="h-3.5 w-3.5" /> Verlauf leeren
</Button>
)}
</div>
{/* Nachrichtenbereich */}
<div className="flex-1 space-y-3 overflow-y-auto rounded-2xl border bg-white p-4 dark:border-slate-700 dark:bg-slate-900">
{nachrichten.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center text-center">
<Bot className="mb-3 h-10 w-10 text-slate-300 dark:text-slate-600" />
<p className="max-w-md text-sm text-slate-500 dark:text-slate-400">
Stellen Sie eine Frage zu Ihrem Bestand – zum Beispiel:
</p>
<div className="mt-3 flex flex-wrap justify-center gap-2">
{BEISPIELE.map((b) => (
<button
key={b}
type="button"
onClick={() => senden(b)}
className="rounded-full border px-3 py-1.5 text-xs text-slate-600 transition-colors hover:border-teal-500 hover:text-teal-700 dark:border-slate-700 dark:text-slate-300 dark:hover:border-teal-500 dark:hover:text-teal-400"
>
{b}
</button>
))}
</div>
</div>
) : (
nachrichten.map((m, i) => (
<div key={i} className={`flex gap-2.5 ${m.rolle === 'user' ? 'justify-end' : ''}`}>
{m.rolle === 'assistant' && (
<span className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-teal-50 dark:bg-teal-950">
<Bot className="h-4 w-4 text-teal-700 dark:text-teal-300" />
</span>
)}
<div
className={`max-w-[85%] whitespace-pre-wrap rounded-2xl px-3.5 py-2.5 text-sm leading-relaxed sm:max-w-[75%] ${
m.rolle === 'user'
? 'bg-teal-600 text-white'
: 'bg-slate-100 text-slate-800 dark:bg-slate-800 dark:text-slate-100'
}`}
>
{m.text}
</div>
{m.rolle === 'user' && (
<span className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-slate-100 dark:bg-slate-800">
<UserRound className="h-4 w-4 text-slate-500 dark:text-slate-400" />
</span>
)}
</div>
))
)}
{sende && (
<div className="flex items-center gap-2 text-sm text-slate-400 dark:text-slate-500">
<Bot className="h-4 w-4 animate-pulse" /> Kimi denkt nach …
</div>
)}
{fehler && (
<div className="flex items-start gap-2 rounded-xl bg-red-50 px-3.5 py-2.5 text-sm text-red-700 dark:bg-red-950 dark:text-red-300" role="alert">
<CircleAlert className="mt-0.5 h-4 w-4 shrink-0" /> {fehler}
</div>
)}
<div ref={endeRef} />
</div>
{/* Eingabe */}
<form
className="flex items-end gap-2"
onSubmit={(e) => {
e.preventDefault()
senden(eingabe)
}}
>
<textarea
value={eingabe}
onChange={(e) => setEingabe(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
senden(eingabe)
}
}}
placeholder={'Frage eingeben, z. B. „Welche Wohnungen stehen leer?" …'}
rows={2}
className="max-h-32 min-h-11 flex-1 resize-none rounded-xl border border-input bg-background px-3 py-2.5 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring dark:bg-slate-900"
aria-label="Frage an den Assistenten"
/>
<Button type="submit" className="gap-1.5" disabled={sende || !eingabe.trim()}>
<Send className="h-4 w-4" /> Senden
</Button>
</form>
</div>
)
}
+1 -1
View File
@@ -301,7 +301,7 @@ export default function BenutzerVerwaltung() {
aria-label="Wohnung für Code"
>
<option value="">Wohnung wählen …</option>
{units.filter((u) => u.status === 'frei').map((u) => (
{units.filter((u) => u.status === 'leerstand' || u.status === 'unklar').map((u) => (
<option key={u.id} value={u.id}>{u.building}, Whg. {u.number}</option>
))}
{units.filter((u) => u.status === 'vermietet').map((u) => (
+35 -27
View File
@@ -22,7 +22,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { useStore, fmtEUR } from '../store/store'
import { useStore } from '../store/store'
import type { Building, HausForm, Unit } from '../types'
import { HEIZUNGSARTEN } from '../types'
@@ -203,10 +203,7 @@ function WohnungDialog({ wohnung, gebaeude, festesHaus }: { wohnung?: Unit; geba
const [number, setNumber] = useState(wohnung?.number ?? '')
const [floor, setFloor] = useState(String(wohnung?.floor ?? '0'))
const [size, setSize] = useState(wohnung ? String(wohnung.size).replace('.', ',') : '')
const [coldRent, setColdRent] = useState(wohnung ? String(wohnung.coldRent).replace('.', ',') : '')
const [warmRent, setWarmRent] = useState(wohnung ? String(wohnung.warmRent).replace('.', ',') : '')
const [tenant, setTenant] = useState(wohnung?.tenant ?? '')
const [status, setStatus] = useState<'vermietet' | 'frei'>(wohnung?.status ?? 'frei')
const [status, setStatus] = useState<Unit['status']>(wohnung?.status ?? 'leerstand')
const [heizungsart, setHeizungsart] = useState(wohnung?.heizungsart ?? '')
const [sende, setSende] = useState(false)
const [fehler, setFehler] = useState<string | null>(null)
@@ -229,9 +226,6 @@ function WohnungDialog({ wohnung, gebaeude, festesHaus }: { wohnung?: Unit; geba
number: number.trim(),
floor: zahl(floor),
size: zahl(size),
coldRent: zahl(coldRent),
warmRent: zahl(warmRent),
tenant: tenant.trim(),
status,
heizungsart,
}
@@ -299,21 +293,20 @@ function WohnungDialog({ wohnung, gebaeude, festesHaus }: { wohnung?: Unit; geba
</p>
</div>
</Abschnitt>
<Abschnitt titel="Miete">
<Feld id="w-kalt" label="Kaltmiete (€)" inputMode="decimal" placeholder="z. B. 520" value={coldRent} onChange={(e) => setColdRent(e.target.value)} />
<Feld id="w-warm" label="Warmmiete (€)" inputMode="decimal" placeholder="z. B. 690" value={warmRent} onChange={(e) => setWarmRent(e.target.value)} />
</Abschnitt>
<Abschnitt titel="Mieter">
<Feld id="w-mieter" label="Mieter (Name)" hint="Leer lassen, solange die Wohnung frei ist" placeholder="z. B. Anna Berger" value={tenant} onChange={(e) => setTenant(e.target.value)} />
<Abschnitt titel="Status">
<div className="space-y-1.5">
<Label>Status</Label>
<Select value={status} onValueChange={(v) => setStatus(v as 'vermietet' | 'frei')}>
<Select value={status} onValueChange={(v) => setStatus(v as Unit['status'])}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="vermietet">vermietet</SelectItem>
<SelectItem value="frei">frei</SelectItem>
<SelectItem value="vermietet">Vermietet</SelectItem>
<SelectItem value="leerstand">Leerstand</SelectItem>
<SelectItem value="unklar">Unklar</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-slate-400 dark:text-slate-500">
Vermietet/Leerstand wird bei Anlage oder Löschung eines Mieters in der Mieterverwaltung automatisch gesetzt.
</p>
</div>
</Abschnitt>
{fehler && (
@@ -324,7 +317,7 @@ function WohnungDialog({ wohnung, gebaeude, festesHaus }: { wohnung?: Unit; geba
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>Abbrechen</Button>
<Button onClick={submit} disabled={sende || !building || !number.trim() || !size || !coldRent || !warmRent}>
<Button onClick={submit} disabled={sende || !building || !number.trim() || !size}>
{sende ? 'Wird gespeichert …' : bearbeiten ? 'Speichern' : 'Wohnung anlegen'}
</Button>
</DialogFooter>
@@ -340,6 +333,21 @@ function sortiereWohnungen(liste: Unit[]): Unit[] {
)
}
/** Farben des Wohnungsstatus-Badges. */
function statusBadgeKlasse(status: Unit['status']) {
if (status === 'vermietet')
return 'bg-emerald-100 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-300'
if (status === 'unklar')
return 'bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300'
return 'bg-slate-200 text-slate-600 dark:bg-slate-800 dark:text-slate-300'
}
const STATUS_LABEL: Record<Unit['status'], string> = {
vermietet: 'Vermietet',
leerstand: 'Leerstand',
unklar: 'Unklar',
}
/** Wohnungszeile, in Karten- und Listenansicht gleich. */
function WohnungsZeile({ u, gebaeude, festesHaus }: { u: Unit; gebaeude: string[]; festesHaus: string }) {
const { deleteWohnung, role } = useStore()
@@ -357,19 +365,19 @@ function WohnungsZeile({ u, gebaeude, festesHaus }: { u: Unit; gebaeude: string[
Etage {u.floor} · {u.size.toLocaleString('de-DE')} m²
</span>
</div>
<Badge
className={u.status === 'vermietet' ? 'bg-emerald-100 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-300' : 'bg-slate-200 text-slate-600 dark:bg-slate-800 dark:text-slate-300'}
variant="secondary"
>
{u.status}
<Badge className={statusBadgeKlasse(u.status)} variant="secondary">
{STATUS_LABEL[u.status]}
</Badge>
</div>
<div className="mt-1 flex items-center justify-between gap-2 text-xs text-slate-500 dark:text-slate-400">
<span className="flex items-center gap-1 truncate">
<Users className="h-3.5 w-3.5 shrink-0" /> {u.tenant ?? '–'}
</span>
<a
href="/mieter"
className="flex items-center gap-1 truncate text-teal-700 hover:underline dark:text-teal-400"
title="Mieterverwaltung öffnen"
>
<Users className="h-3.5 w-3.5 shrink-0" /> Mieter verwalten
</a>
<span className="flex shrink-0 items-center gap-2">
<span className="font-medium text-slate-700 dark:text-slate-200">{fmtEUR.format(u.warmRent)}</span>
<WohnungDialog wohnung={u} gebaeude={gebaeude} festesHaus={festesHaus} />
{role === 'admin' && (
<button
+13 -12
View File
@@ -59,11 +59,12 @@ function StatCard({
}
function TenantDashboard() {
const { meineEinheit: demoUnit, me, transactions, tickets, threads, announcements } = useStore()
const { meineEinheit: demoUnit, me, transactions, tickets, threads, announcements, mieter } = useStore()
const myTx = transactions.filter((t) => t.unitId === demoUnit.id)
const openTx = myTx.filter((t) => t.status !== 'bezahlt')
const myTickets = tickets.filter((t) => t.unitId === demoUnit.id)
const unread = threads.filter((t) => t.unreadByTenant)
const meinMieter = mieter.find((m) => m.unitId === demoUnit.id)
const chartData = monthShort.slice(0, 10).map((m, i) => ({
m,
@@ -89,8 +90,8 @@ function TenantDashboard() {
<StatCard
icon={Wallet}
label="Monatliche Warmmiete"
value={fmtEUR.format(demoUnit.warmRent)}
sub={`Kaltmiete ${fmtEUR.format(demoUnit.coldRent)} + NK`}
value={meinMieter ? fmtEUR.format(meinMieter.warmmiete) : '–'}
sub={meinMieter ? `Kaltmiete ${fmtEUR.format(meinMieter.kaltmiete)} + NK` : 'Mieterdaten folgen'}
/>
<StatCard
icon={Euro}
@@ -109,8 +110,8 @@ function TenantDashboard() {
<StatCard
icon={TicketCheck}
label="Mietverhältnis seit"
value="März 2024"
sub="Kaution: 1.500,00 € hinterlegt"
value={meinMieter?.mietbeginnAm ? fmtDatum(meinMieter.mietbeginnAm) : '–'}
sub={meinMieter ? `Kaution: ${fmtEUR.format(meinMieter.kautionHoehe)} hinterlegt` : 'Mieterdaten folgen'}
/>
</div>
@@ -216,10 +217,10 @@ function TenantDashboard() {
}
function AdminDashboard() {
const { units, transactions, tickets, announcements, buildings: gebaeude } = useStore()
const { units, transactions, tickets, announcements, buildings: gebaeude, mieter } = useStore()
const rented = units.filter((u) => u.status === 'vermietet')
const occupancy = Math.round((rented.length / units.length) * 100)
const monthlyIncome = rented.reduce((s, u) => s + u.warmRent, 0)
const occupancy = units.length ? Math.round((rented.length / units.length) * 100) : 0
const monthlyIncome = mieter.reduce((s, m) => s + m.warmmiete, 0)
const openTickets = tickets.filter((t) => t.status === 'offen')
const overdue = transactions.filter((t) => t.status === 'überfällig')
@@ -235,7 +236,7 @@ function AdminDashboard() {
return {
name: g.name,
Vermietet: us.filter((u) => u.status === 'vermietet').length,
Frei: us.filter((u) => u.status === 'frei').length,
Leerstand: us.filter((u) => u.status === 'leerstand').length,
}
})
@@ -249,7 +250,7 @@ function AdminDashboard() {
</div>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<StatCard icon={Building2} label="Wohneinheiten gesamt" value={String(units.length)} sub={`${units.filter((u) => u.status === 'frei').length} aktuell frei`} />
<StatCard icon={Building2} label="Wohneinheiten gesamt" value={String(units.length)} sub={`${units.filter((u) => u.status === 'leerstand').length} aktuell im Leerstand`} />
<StatCard icon={Users} label="Vermietungsquote" value={`${occupancy} %`} sub={`${rented.length} von ${units.length} Wohneinheiten`} />
<StatCard icon={Euro} label="Monatliche Warmmiete (Soll)" value={fmtEUR.format(monthlyIncome)} sub="Summe aller vermieteten Einheiten" />
<StatCard
@@ -293,13 +294,13 @@ function AdminDashboard() {
<YAxis type="category" dataKey="name" tick={{ fontSize: 11 }} stroke="#94a3b8" width={95} />
<Tooltip />
<Bar dataKey="Vermietet" stackId="a" fill="#0d9488" radius={[0, 0, 0, 0]} isAnimationActive={false} />
<Bar dataKey="Frei" stackId="a" fill="#f59e0b" radius={[0, 6, 6, 0]} isAnimationActive={false} />
<Bar dataKey="Leerstand" stackId="a" fill="#f59e0b" radius={[0, 6, 6, 0]} isAnimationActive={false} />
</BarChart>
</ResponsiveContainer>
</div>
<div className="mt-2 flex gap-4 text-xs text-slate-500">
<span className="flex items-center gap-1.5"><span className="h-2.5 w-2.5 rounded-sm bg-teal-600" /> vermietet</span>
<span className="flex items-center gap-1.5"><span className="h-2.5 w-2.5 rounded-sm bg-amber-500" /> frei</span>
<span className="flex items-center gap-1.5"><span className="h-2.5 w-2.5 rounded-sm bg-amber-500" /> Leerstand</span>
</div>
</div>
</div>
+517
View File
@@ -0,0 +1,517 @@
import { useMemo, useRef, useState } from 'react'
import {
BadgeEuro, CalendarRange, FileText, Pencil, Phone, Plus, Search, Trash2, Upload, Users,
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select'
import { useStore, fmtEUR } from '../store/store'
import { fmtDatum } from '../lib/format'
import PdfLightbox from '../components/PdfLightbox'
import type { DocumentItem, KautionArt, Mieter, Unit, Vertragsstatus } from '../types'
/** Komma oder Punkt als Dezimaltrenner akzeptieren. */
const zahl = (s: string) => Number(s.replace(',', '.'))
/** „04.10.2026" → „2026-10-04" (für <input type="date">); ISO bleibt. */
const zuIso = (wert: string) => {
const m = /^(\d{2})\.(\d{2})\.(\d{4})$/.exec(wert.trim())
return m ? `${m[3]}-${m[2]}-${m[1]}` : wert
}
const VERTRAGSSTATUS_LABEL: Record<Vertragsstatus, string> = {
aktiv: 'Aktiv',
befristet: 'Befristet',
gekuendigt: 'Gekündigt',
}
function vertragsBadgeKlasse(status: Vertragsstatus) {
if (status === 'aktiv') return 'bg-emerald-100 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-300'
if (status === 'befristet') return 'bg-sky-100 text-sky-800 dark:bg-sky-950 dark:text-sky-300'
return 'bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300'
}
const KAUTION_ART_LABEL: Record<KautionArt, string> = {
bar: 'Bar',
ueberwiesen: 'Überwiesen',
sonstiges: 'Andere',
}
/** Optischer Abschnitt im einspaltigen Formular. */
function Abschnitt({ titel, children }: { titel: string; children: React.ReactNode }) {
return (
<div className="space-y-3 rounded-xl border bg-slate-50 p-4 dark:border-slate-700 dark:bg-slate-800/50">
<h4 className="text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
{titel}
</h4>
{children}
</div>
)
}
function Feld({ id, label, hint, ...input }: {
id: string
label: string
hint?: string
} & React.InputHTMLAttributes<HTMLInputElement>) {
return (
<div className="space-y-1.5">
<Label htmlFor={id}>{label}</Label>
<Input id={id} {...input} />
{hint && <p className="text-xs text-slate-400 dark:text-slate-500">{hint}</p>}
</div>
)
}
/** Belegt eine Wohneinheit bereits von einem anderen Mieter? */
function einheitLabel(u: Unit) {
return `${u.building}, Whg. ${u.number}`
}
// ── Dokumente-Sektion im Mieter-Formular ────────────────────────────────
const DOKUMENT_KATEGORIEN = ['Personalausweis', 'SCHUFA-Auskunft', 'Bankauszug', 'Sonstiges'] as const
const dokumentSlug = (kategorie: string) => kategorie.toLowerCase().replace(/[^a-z0-9]+/g, '-')
/** Ein Kategorie-Slot: Upload-Button + Miniatur-Preview (öffnet Lightbox). */
function DokumentSlot({ mieter, kategorie }: { mieter: Mieter; kategorie: string }) {
const { documents, ladeMieterDokumentHoch } = useStore()
const fileInput = useRef<HTMLInputElement>(null)
const [sende, setSende] = useState(false)
const [fehler, setFehler] = useState<string | null>(null)
const dokumentId = `d-md-${mieter.id}-${dokumentSlug(kategorie)}`
const dokument = documents.find((d) => d.id === dokumentId) as DocumentItem | undefined
const hochladen = async (datei: File) => {
setSende(true)
setFehler(null)
const err = await ladeMieterDokumentHoch(mieter.id, kategorie, datei)
setSende(false)
if (err) return setFehler(err)
if (fileInput.current) fileInput.current.value = ''
}
return (
<div className="rounded-lg border bg-white p-3 dark:border-slate-700 dark:bg-slate-900">
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-200">{kategorie}</span>
<Button
variant="outline"
size="sm"
className="gap-1.5"
disabled={sende}
onClick={() => fileInput.current?.click()}
>
<Upload className="h-3.5 w-3.5" /> {dokument ? 'Ersetzen' : 'Hochladen'}
</Button>
<input
ref={fileInput}
type="file"
accept=".pdf,.jpg,.jpeg,.png,application/pdf,image/jpeg,image/png"
className="hidden"
onChange={(e) => {
const datei = e.target.files?.[0]
if (datei) hochladen(datei)
}}
/>
</div>
{dokument ? (
<PdfLightbox
dokument={dokument}
trigger={
<button type="button" className="mt-2 block w-full text-left" title="Vorschau öffnen">
{/* Mini-iframe rendert PDF und Bilder zuverlässig als Thumbnail */}
<iframe
src={`/api/documents/${dokument.id}/preview`}
title={`Vorschau: ${kategorie}`}
className="pointer-events-none h-28 w-full rounded-md border border-slate-200 bg-slate-100 dark:border-slate-700 dark:bg-slate-800"
/>
<span className="mt-1 flex items-center gap-1 text-xs text-teal-700 dark:text-teal-400">
<FileText className="h-3.5 w-3.5" />
{dokument.fileName ?? dokument.title} · {dokument.size}
</span>
</button>
}
/>
) : (
<p className="mt-2 text-xs text-slate-400 dark:text-slate-500">
Noch kein Dokument – Scan oder PDF hochladen (ersetzt vorhandene Datei dieser Kategorie).
</p>
)}
{fehler && <p className="mt-1 text-xs text-red-600 dark:text-red-400">{fehler}</p>}
</div>
)
}
// ── Neuanlage / Bearbeitung ─────────────────────────────────────────────
function MieterDialog({ mieter }: { mieter?: Mieter }) {
const { addMieter, updateMieter, units, mieter: alleMieter } = useStore()
const bearbeiten = Boolean(mieter)
const [open, setOpen] = useState(false)
const [unitId, setUnitId] = useState(mieter?.unitId ?? '')
const [name, setName] = useState(mieter?.name ?? '')
const [vorname, setVorname] = useState(mieter?.vorname ?? '')
const [telefon, setTelefon] = useState(mieter?.telefon ?? '')
const [email, setEmail] = useState(mieter?.email ?? '')
const [kaltmiete, setKaltmiete] = useState(mieter ? String(mieter.kaltmiete).replace('.', ',') : '')
const [warmmiete, setWarmmiete] = useState(mieter ? String(mieter.warmmiete).replace('.', ',') : '')
const [vertragsstatus, setVertragsstatus] = useState<Vertragsstatus>(mieter?.vertragsstatus ?? 'aktiv')
const [gekuendigtAm, setGekuendigtAm] = useState(zuIso(mieter?.gekuendigtAm ?? ''))
const [gekuendigtVon, setGekuendigtVon] = useState<'mieter' | 'vermieter' | ''>(mieter?.gekuendigtVon ?? '')
const [kautionHoehe, setKautionHoehe] = useState(mieter ? String(mieter.kautionHoehe).replace('.', ',') : '')
const [kautionArt, setKautionArt] = useState<KautionArt>(mieter?.kautionArt ?? 'ueberwiesen')
const [kautionSonstiges, setKautionSonstiges] = useState(mieter?.kautionSonstiges ?? '')
const [kautionszahlung, setKautionszahlung] = useState<'komplett' | 'raten'>(mieter?.kautionszahlung ?? 'komplett')
const [kautionRaten, setKautionRaten] = useState(mieter ? String(mieter.kautionRaten || '') : '')
const [mietbeginnAm, setMietbeginnAm] = useState(zuIso(mieter?.mietbeginnAm ?? ''))
const [mietendeAm, setMietendeAm] = useState(zuIso(mieter?.mietendeAm ?? ''))
const [sende, setSende] = useState(false)
const [fehler, setFehler] = useState<string | null>(null)
/** Freie Wohnungen + die eigene (beim Bearbeiten). */
const wohnungen = units.filter(
(u) => u.id === unitId || !alleMieter.some((m) => m.unitId === u.id),
)
const payload = () => ({
unitId,
name: name.trim(),
vorname: vorname.trim(),
telefon: telefon.trim(),
email: email.trim(),
kaltmiete: kaltmiete.trim() === '' ? 0 : zahl(kaltmiete),
warmmiete: warmmiete.trim() === '' ? 0 : zahl(warmmiete),
vertragsstatus,
gekuendigtAm: vertragsstatus === 'gekuendigt' ? gekuendigtAm : '',
gekuendigtVon: vertragsstatus === 'gekuendigt' ? gekuendigtVon : '',
kautionHoehe: kautionHoehe.trim() === '' ? 0 : zahl(kautionHoehe),
kautionArt,
kautionSonstiges: kautionArt === 'sonstiges' ? kautionSonstiges.trim() : '',
kautionszahlung,
kautionRaten: kautionszahlung === 'raten' && kautionRaten.trim() !== '' ? Math.round(zahl(kautionRaten)) : 0,
mietbeginnAm,
mietendeAm,
})
const submit = async () => {
if (sende) return
setSende(true)
setFehler(null)
const err = bearbeiten ? await updateMieter(mieter!.id, payload()) : await addMieter(payload())
setSende(false)
if (err) return setFehler(err)
setOpen(false)
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
{bearbeiten ? (
<Button variant="outline" size="sm" className="gap-1.5">
<Pencil className="h-3.5 w-3.5" /> Bearbeiten
</Button>
) : (
<Button className="gap-2">
<Plus className="h-4 w-4" /> Neuer Mieter
</Button>
)}
</DialogTrigger>
<DialogContent className="max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{bearbeiten ? `Mieter „${mieter!.vorname} ${mieter!.name}" bearbeiten` : 'Neuen Mieter anlegen'}
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<Abschnitt titel="Person">
<Feld id="m-name" label="Name" placeholder="z. B. Berger" value={name} onChange={(e) => setName(e.target.value)} />
<Feld id="m-vorname" label="Vorname" placeholder="z. B. Anna" value={vorname} onChange={(e) => setVorname(e.target.value)} />
<Feld id="m-tel" label="Telefon" placeholder="z. B. 0151 23456789" value={telefon} onChange={(e) => setTelefon(e.target.value)} />
<Feld id="m-mail" label="E-Mail" type="email" placeholder="z. B. a.berger@mail.de" value={email} onChange={(e) => setEmail(e.target.value)} />
</Abschnitt>
<Abschnitt titel="Wohnung">
<div className="space-y-1.5">
<Label>Wohneinheit</Label>
<Select value={unitId} onValueChange={setUnitId}>
<SelectTrigger><SelectValue placeholder="Wohnung wählen …" /></SelectTrigger>
<SelectContent>
{wohnungen.map((u) => (
<SelectItem key={u.id} value={u.id}>{einheitLabel(u)}</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-slate-400 dark:text-slate-500">
Pro Wohnung kann genau ein Mieter geführt werden. Beim Speichern wird die Wohnung auf „Vermietet" gesetzt.
</p>
</div>
</Abschnitt>
<Abschnitt titel="Miete">
<Feld id="m-kalt" label="Kaltmiete (€/Monat)" inputMode="decimal" placeholder="z. B. 520" value={kaltmiete} onChange={(e) => setKaltmiete(e.target.value)} />
<Feld id="m-warm" label="Warmmiete (€/Monat)" inputMode="decimal" placeholder="z. B. 690" value={warmmiete} onChange={(e) => setWarmmiete(e.target.value)} />
</Abschnitt>
<Abschnitt titel="Vertrag">
<div className="space-y-1.5">
<Label>Vertragsstatus</Label>
<Select value={vertragsstatus} onValueChange={(v) => setVertragsstatus(v as Vertragsstatus)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{(Object.keys(VERTRAGSSTATUS_LABEL) as Vertragsstatus[]).map((s) => (
<SelectItem key={s} value={s}>{VERTRAGSSTATUS_LABEL[s]}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{vertragsstatus === 'gekuendigt' && (
<>
<Feld id="m-gek-am" label="Gekündigt zum" type="date" value={gekuendigtAm} onChange={(e) => setGekuendigtAm(e.target.value)} />
<div className="space-y-1.5">
<Label>Gekündigt von</Label>
<Select value={gekuendigtVon || '__keine'} onValueChange={(v) => setGekuendigtVon(v === '__keine' ? '' : v as 'mieter' | 'vermieter')}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="__keine">Nicht angegeben</SelectItem>
<SelectItem value="mieter">Mieter</SelectItem>
<SelectItem value="vermieter">Vermieter</SelectItem>
</SelectContent>
</Select>
</div>
</>
)}
<Feld id="m-beginn" label="Mietbeginn" type="date" value={mietbeginnAm} onChange={(e) => setMietbeginnAm(e.target.value)} />
<Feld id="m-ende" label="Mietende" hint="Leer lassen bei unbefristetem Vertrag" type="date" value={mietendeAm} onChange={(e) => setMietendeAm(e.target.value)} />
</Abschnitt>
<Abschnitt titel="Kaution">
<Feld id="m-kaut-hoehe" label="Kaution Höhe (€)" inputMode="decimal" placeholder="z. B. 1.500" value={kautionHoehe} onChange={(e) => setKautionHoehe(e.target.value)} />
<div className="space-y-1.5">
<Label>Kaution Art</Label>
<Select value={kautionArt} onValueChange={(v) => setKautionArt(v as KautionArt)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="bar">Bar</SelectItem>
<SelectItem value="ueberwiesen">Überwiesen</SelectItem>
<SelectItem value="sonstiges">Andere</SelectItem>
</SelectContent>
</Select>
</div>
{kautionArt === 'sonstiges' && (
<Feld id="m-kaut-sonst" label="Andere Art (Freitext)" placeholder="z. B. Bürgschaft der Eltern" value={kautionSonstiges} onChange={(e) => setKautionSonstiges(e.target.value)} />
)}
<div className="space-y-1.5">
<Label>Kautionszahlung</Label>
<Select value={kautionszahlung} onValueChange={(v) => setKautionszahlung(v as 'komplett' | 'raten')}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="komplett">Komplett gezahlt</SelectItem>
<SelectItem value="raten">Ratenzahlung</SelectItem>
</SelectContent>
</Select>
</div>
{kautionszahlung === 'raten' && (
<Feld id="m-kaut-raten" label="Anzahl der Raten" inputMode="numeric" placeholder="z. B. 3" value={kautionRaten} onChange={(e) => setKautionRaten(e.target.value)} />
)}
</Abschnitt>
{bearbeiten && (
<Abschnitt titel="Dokumente">
<p className="text-xs text-slate-400 dark:text-slate-500">
Personalausweis, SCHUFA-Auskunft, Bankauszug u. a. – Klick auf die Vorschau öffnet Druck- und
E-Mail-Optionen. Pro Kategorie wird genau eine Datei (PDF oder Bild) geführt.
</p>
<div className="grid gap-3 sm:grid-cols-2">
{DOKUMENT_KATEGORIEN.map((k) => (
<DokumentSlot key={k} mieter={mieter!} kategorie={k} />
))}
</div>
</Abschnitt>
)}
{fehler && (
<div className="rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700 dark:bg-red-950 dark:text-red-300" role="alert">
{fehler}
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>Abbrechen</Button>
<Button onClick={submit} disabled={sende || !unitId || !name.trim()}>
{sende ? 'Wird gespeichert …' : bearbeiten ? 'Speichern' : 'Mieter anlegen'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
// ── Listenansicht ───────────────────────────────────────────────────────
export default function MieterVerwaltung() {
const { mieter, units, deleteMieter } = useStore()
const [suche, setSuche] = useState('')
const einheitVon = (unitId: string) => units.find((u) => u.id === unitId)
const gefiltert = useMemo(() => {
const q = suche.trim().toLowerCase()
const liste = [...mieter].sort(
(a, b) => einheitVon(a.unitId)?.building.localeCompare(einheitVon(b.unitId)?.building ?? '') ||
(einheitVon(a.unitId)?.number ?? '').localeCompare(einheitVon(b.unitId)?.number ?? '', 'de-DE', { numeric: true }),
)
if (!q) return liste
return liste.filter((m) => {
const u = einheitVon(m.unitId)
return [m.name, m.vorname, m.email, m.telefon, u?.building ?? '', u?.number ?? '']
.some((f) => f.toLowerCase().includes(q))
})
}, [mieter, units, suche])
const loesche = async (m: Mieter) => {
if (!window.confirm(`Mieter „${m.vorname} ${m.name}" wirklich löschen? Die Wohnung wird auf „Leerstand" gesetzt.`)) return
const fehler = await deleteMieter(m.id)
if (fehler) window.alert(fehler)
}
return (
<div className="space-y-6">
<div className="flex flex-wrap items-end justify-between gap-3">
<div>
<h1 className="text-2xl font-bold tracking-tight">Mieterverwaltung</h1>
<p className="text-sm text-slate-500 dark:text-slate-400">
Zu jeder Wohnung genau ein Mieterobjekt – Miete, Vertrag, Kaution und Dokumente werden hier gepflegt
</p>
</div>
<MieterDialog />
</div>
{mieter.length > 0 && (
<div className="flex flex-wrap items-center gap-2">
<div className="relative min-w-56 flex-1 sm:max-w-xs">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<Input
className="pl-9"
placeholder="Mieter oder Wohnung suchen …"
value={suche}
onChange={(e) => setSuche(e.target.value)}
aria-label="Mieter suchen"
/>
</div>
{suche && (
<span className="text-xs text-slate-500 dark:text-slate-400">
{gefiltert.length} von {mieter.length} Mietern
</span>
)}
</div>
)}
{mieter.length === 0 ? (
<div className="rounded-2xl border border-dashed bg-white p-10 text-center text-sm text-slate-500 dark:text-slate-400">
<Users className="mx-auto mb-3 h-8 w-8 text-slate-300 dark:text-slate-600" />
Noch keine Mieter angelegt – über „Neuer Mieter" die erste Wohneinheit belegen.
</div>
) : gefiltert.length === 0 ? (
<div className="rounded-2xl border border-dashed bg-white p-10 text-center text-sm text-slate-500 dark:text-slate-400">
<Search className="mx-auto mb-3 h-8 w-8 text-slate-300 dark:text-slate-600" />
Kein Mieter passt zur Suche „{suche}".
</div>
) : (
<div className="overflow-hidden rounded-2xl border bg-white dark:border-slate-700 dark:bg-slate-900">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-slate-50 text-left text-xs uppercase tracking-wide text-slate-500 dark:border-slate-700 dark:bg-slate-800/60 dark:text-slate-400">
<th className="px-3 py-2.5">Wohnung</th>
<th className="px-3 py-2.5">Mieter</th>
<th className="hidden px-3 py-2.5 lg:table-cell">Kontakt</th>
<th className="hidden px-3 py-2.5 md:table-cell">Miete</th>
<th className="hidden px-3 py-2.5 lg:table-cell">Kaution</th>
<th className="hidden px-3 py-2.5 xl:table-cell">Zeitraum</th>
<th className="px-3 py-2.5">Vertrag</th>
<th className="px-3 py-2.5 text-right">Aktion</th>
</tr>
</thead>
<tbody>
{gefiltert.map((m) => {
const u = einheitVon(m.unitId)
return (
<tr key={m.id} className="border-b last:border-0 hover:bg-slate-50 dark:border-slate-700 dark:hover:bg-slate-800/40">
<td className="px-3 py-2.5 font-medium text-slate-800 dark:text-slate-100">
{u ? einheitLabel(u) : '–'}
</td>
<td className="px-3 py-2.5">
{m.vorname} {m.name}
</td>
<td className="hidden px-3 py-2.5 lg:table-cell">
<div className="flex flex-col gap-0.5 text-xs text-slate-500 dark:text-slate-400">
{m.telefon && (
<span className="flex items-center gap-1"><Phone className="h-3 w-3" /> {m.telefon}</span>
)}
{m.email && <span>{m.email}</span>}
{!m.telefon && !m.email && '–'}
</div>
</td>
<td className="hidden px-3 py-2.5 md:table-cell">
<div className="text-slate-700 dark:text-slate-200">{fmtEUR.format(m.warmmiete)}</div>
<div className="text-xs text-slate-400 dark:text-slate-500">kalt {fmtEUR.format(m.kaltmiete)}</div>
</td>
<td className="hidden px-3 py-2.5 lg:table-cell">
{m.kautionHoehe > 0 ? (
<div className="flex items-center gap-1 text-slate-600 dark:text-slate-300">
<BadgeEuro className="h-3.5 w-3.5 text-slate-400" />
{fmtEUR.format(m.kautionHoehe)}
<span className="text-xs text-slate-400 dark:text-slate-500">
· {KAUTION_ART_LABEL[m.kautionArt]}
{m.kautionArt === 'sonstiges' && m.kautionSonstiges && ` (${m.kautionSonstiges})`}
{m.kautionszahlung === 'raten' && m.kautionRaten > 0 && ` · ${m.kautionRaten} Raten`}
</span>
</div>
) : (
<span className="text-slate-400 dark:text-slate-500">–</span>
)}
</td>
<td className="hidden px-3 py-2.5 xl:table-cell">
<div className="flex items-center gap-1 text-xs text-slate-500 dark:text-slate-400">
<CalendarRange className="h-3.5 w-3.5 text-slate-400" />
{fmtDatum(m.mietbeginnAm)} – {m.mietendeAm ? fmtDatum(m.mietendeAm) : 'unbefristet'}
</div>
</td>
<td className="px-3 py-2.5">
<Badge className={vertragsBadgeKlasse(m.vertragsstatus)} variant="secondary">
{VERTRAGSSTATUS_LABEL[m.vertragsstatus]}
</Badge>
{m.vertragsstatus === 'gekuendigt' && m.gekuendigtAm && (
<div className="mt-1 text-xs text-slate-400 dark:text-slate-500">
zum {fmtDatum(m.gekuendigtAm)}
{m.gekuendigtVon && ` von ${m.gekuendigtVon === 'mieter' ? 'Mieter' : 'Vermieter'}`}
</div>
)}
</td>
<td className="px-3 py-2.5 text-right">
<span className="inline-flex items-center gap-1">
<MieterDialog mieter={m} />
<button
type="button"
className="text-slate-400 transition-colors hover:text-red-600 dark:text-slate-500 dark:hover:text-red-400"
onClick={() => loesche(m)}
aria-label={`Mieter ${m.vorname} ${m.name} löschen`}
title="Mieter löschen"
>
<Trash2 className="h-4 w-4" />
</button>
</span>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
</div>
)
}
+6 -6
View File
@@ -147,7 +147,7 @@ function VermieterDialog() {
/** Neuanlage / Bearbeitung eines Mietvertrags. */
function MietvertragDialog({ vertrag, einheiten }: { vertrag?: Mietvertrag; einheiten: Unit[] }) {
const { addMietvertrag, updateMietvertrag } = useStore()
const { addMietvertrag, updateMietvertrag, mieter } = useStore()
const bearbeiten = Boolean(vertrag)
const [open, setOpen] = useState(false)
const [unitId, setUnitId] = useState(vertrag?.unitId ?? einheiten[0]?.id ?? '')
@@ -164,10 +164,10 @@ function MietvertragDialog({ vertrag, einheiten }: { vertrag?: Mietvertrag; einh
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 m = mieter.find((x) => x.unitId === id)
if (m && !bearbeiten) {
setKaltmiete(dezimal(Number(m.kaltmiete)))
setNebenkosten(dezimal(Number(m.warmmiete) - Number(m.kaltmiete)))
}
}
@@ -219,7 +219,7 @@ function MietvertragDialog({ vertrag, einheiten }: { vertrag?: Mietvertrag; einh
<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' : ''})
{u.building}, Whg. {u.number} (Etage {u.floor}, {u.size} m²{u.status === 'leerstand' ? ', Leerstand' : ''})
</SelectItem>
))}
</SelectContent>
+11 -8
View File
@@ -28,7 +28,8 @@ const statusStyle: Record<string, string> = {
}
function TenantPayments() {
const { meineEinheit: demoUnit, transactions } = useStore()
const { meineEinheit: demoUnit, transactions, mieter } = useStore()
const meinMieter = mieter.find((m) => m.unitId === demoUnit.id)
const myTx = useMemo(
() =>
transactions
@@ -57,7 +58,7 @@ function TenantPayments() {
<div className="mb-2 flex items-center gap-2 text-sm text-slate-500">
<CalendarDays className="h-4 w-4" /> Fällig am 03.10.2026
</div>
<div className="text-xl font-bold">{fmtEUR.format(demoUnit.warmRent)}</div>
<div className="text-xl font-bold">{meinMieter ? fmtEUR.format(meinMieter.warmmiete) : '–'}</div>
<div className="mt-0.5 text-xs text-slate-500">Warmmiete Oktober (überwiesen ausstehend)</div>
</div>
<div className="rounded-2xl border bg-white p-5 shadow-sm">
@@ -138,9 +139,11 @@ function TenantPayments() {
}
function AdminPayments() {
const { units, transactions, buildings } = useStore()
const { units, transactions, buildings, mieter } = useStore()
const [building, setBuilding] = useState('alle')
const mieterVon = (unitId: string) => mieter.find((m) => m.unitId === unitId)
const list = units
.filter((u) => u.status === 'vermietet')
.filter((u) => building === 'alle' || u.building === building)
@@ -148,11 +151,11 @@ function AdminPayments() {
const tx = transactions.filter((t) => t.unitId === u.id)
const overdue = tx.filter((t) => t.status === 'überfällig')
const lastPaid = tx.filter((t) => t.status === 'bezahlt').slice(-1)[0]
return { unit: u, overdue, lastPaid }
return { unit: u, mieter: mieterVon(u.id), overdue, lastPaid }
})
const totals = {
soll: list.reduce((s, r) => s + r.unit.warmRent, 0),
soll: list.reduce((s, r) => s + (r.mieter?.warmmiete ?? 0), 0),
rueckstand: list.reduce((s, r) => s + r.overdue.reduce((a, t) => a + t.amount, 0), 0),
}
@@ -203,13 +206,13 @@ function AdminPayments() {
</TableRow>
</TableHeader>
<TableBody>
{list.map(({ unit, overdue, lastPaid }) => (
{list.map(({ unit, mieter: m, overdue, lastPaid }) => (
<TableRow key={unit.id}>
<TableCell className="font-medium">
{unit.building}, Whg. {unit.number}
</TableCell>
<TableCell>{unit.tenant}</TableCell>
<TableCell>{fmtEUR.format(unit.warmRent)}</TableCell>
<TableCell>{m ? `${m.vorname} ${m.name}` : '–'}</TableCell>
<TableCell>{m ? fmtEUR.format(m.warmmiete) : '–'}</TableCell>
<TableCell>{lastPaid ? `${lastPaid.month} (${lastPaid.paidAt})` : '–'}</TableCell>
<TableCell className={overdue.length ? 'font-semibold text-red-600' : 'text-slate-400'}>
{overdue.length ? fmtEUR.format(overdue.reduce((a, t) => a + t.amount, 0)) : '–'}
+55 -4
View File
@@ -39,6 +39,7 @@ import type {
Mietvertrag,
MietvertragForm,
VermieterDaten,
Mieter,
} from '../types'
import { apiGet, apiPost, apiPatch, apiPut, apiDelete, apiUpload, ApiFehler } from '../lib/api'
import { sha256, DEMO_PASSWORT } from '../lib/auth'
@@ -62,6 +63,7 @@ interface Bootstrap {
belege: Beleg[]
mietvertraege: Mietvertrag[]
einstellungen: VermieterDaten
mieter: Mieter[]
}
const LEERE_EINSTELLUNGEN: VermieterDaten = {
@@ -84,6 +86,7 @@ const LEER: Bootstrap = {
belege: [],
mietvertraege: [],
einstellungen: LEERE_EINSTELLUNGEN,
mieter: [],
}
interface Store extends Bootstrap {
@@ -164,10 +167,7 @@ interface Store extends Bootstrap {
number: string
floor: number
size: number
coldRent: number
warmRent: number
tenant: string
status: 'vermietet' | 'frei'
status: Unit['status']
heizungsart: string
}) => Promise<string | null>
updateWohnung: (id: string, u: Partial<Unit>) => Promise<string | null>
@@ -236,6 +236,12 @@ interface Store extends Bootstrap {
aktiviereMietvertrag: (id: string) => Promise<{ fehler: string | null; benutzername?: string }>
/** Vermieterdaten speichern (Basis der Vertrags-PDFs). */
speichereEinstellungen: (d: Partial<VermieterDaten>) => Promise<string | null>
// ── Mieterverwaltung (v1.36) ─────────────────────────────
addMieter: (m: Omit<Mieter, 'id' | 'erstelltAm' | 'geaendertAm'>) => Promise<string | null>
updateMieter: (id: string, m: Partial<Omit<Mieter, 'id' | 'erstelltAm' | 'geaendertAm'>>) => Promise<string | null>
deleteMieter: (id: string) => Promise<string | null>
/** Mieterdokument (Personalausweis, SCHUFA …) hochladen – ersetzt den Slot je Kategorie. */
ladeMieterDokumentHoch: (mieterId: string, kategorie: string, datei: File) => Promise<string | null>
}
const StoreContext = createContext<Store | null>(null)
@@ -759,6 +765,51 @@ export function StoreProvider({ children }: { children: ReactNode }) {
return fehlerNachricht(e)
}
},
// ── Mieterverwaltung (v1.36) ─────────────────────────────
async addMieter(m) {
try {
const { mieter } = await apiPost<{ mieter: Mieter }>('/api/mieter', m)
setDaten((d) => ({
...d, mieter: [...d.mieter, mieter].sort((a, b) => a.name.localeCompare(b.name)),
units: d.units.map((u) => (u.id === mieter.unitId ? { ...u, status: 'vermietet' } : u)),
}))
return null
} catch (e) {
return fehlerNachricht(e)
}
},
async updateMieter(id, m) {
try {
const { mieter } = await apiPatch<{ mieter: Mieter }>(`/api/mieter/${id}`, m)
setDaten((d) => ({ ...d, mieter: d.mieter.map((x) => (x.id === id ? mieter : x)) }))
return null
} catch (e) {
return fehlerNachricht(e)
}
},
async deleteMieter(id) {
try {
const alt = daten.mieter.find((x) => x.id === id)
await apiDelete(`/api/mieter/${id}`)
setDaten((d) => ({
...d,
mieter: d.mieter.filter((x) => x.id !== id),
units: alt ? d.units.map((u) => (u.id === alt.unitId ? { ...u, status: 'leerstand' } : u)) : d.units,
}))
return null
} catch (e) {
return fehlerNachricht(e)
}
},
async ladeMieterDokumentHoch(mieterId, kategorie, datei) {
try {
await apiUpload(`/api/mieter/${mieterId}/dokumente?kategorie=${encodeURIComponent(kategorie)}`, datei)
await ladeAlles() // Dokumentenliste aktualisieren
return null
} catch (e) {
return fehlerNachricht(e)
}
},
}), [daten, bereit, me, role, meineEinheit, ladeAlles])
return <StoreContext.Provider value={value}>{children}</StoreContext.Provider>
+36 -4
View File
@@ -27,20 +27,52 @@ export interface RegistrierungsCode {
verwendetAm?: string
}
/** Wohnungsstatus (v1.36): vermietet, Leerstand oder unklar. */
export type UnitStatus = 'vermietet' | 'leerstand' | 'unklar'
export interface Unit {
id: string
building: string
number: string
floor: number
size: number // m²
coldRent: number // €
warmRent: number // €
tenant: string | null
status: 'vermietet' | 'frei'
status: UnitStatus
/** Heizungsart der Wohnung (Dropdown, '' = nicht angegeben) */
heizungsart: string
}
/** Mieterobjekt (v1.36): 1:1 zur Wohnung – Miete, Kontakt, Vertrag,
* Kaution und Mietzeitraum leben hier (nicht mehr am Wohnungsobjekt). */
export type Vertragsstatus = 'aktiv' | 'befristet' | 'gekuendigt'
export type KautionArt = 'bar' | 'ueberwiesen' | 'sonstiges'
export interface Mieter {
id: string
unitId: string
name: string
vorname: string
telefon: string
email: string
kaltmiete: number // €/Monat
warmmiete: number // €/Monat
vertragsstatus: Vertragsstatus
/** „gekündigt zum" – nur bei vertragsstatus = gekuendigt */
gekuendigtAm: string
/** „gekündigt von": mieter | vermieter | '' */
gekuendigtVon: 'mieter' | 'vermieter' | ''
kautionHoehe: number // €
kautionArt: KautionArt
/** Freitext bei kautionArt = sonstiges („Andere") */
kautionSonstiges: string
kautionszahlung: 'komplett' | 'raten'
/** Anzahl der Raten (bei kautionszahlung = raten) */
kautionRaten: number
mietbeginnAm: string
mietendeAm: string // leer = unbefristet
erstelltAm: string
geaendertAm: string
}
export interface Transaction {
id: string
unitId: string