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:
@@ -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 />} />
|
||||
|
||||
@@ -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
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user