Files
Mieterportal/src/pages/Bestand.tsx
T

774 lines
36 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Fragment, useMemo, useState } from 'react'
import {
Building2, Home, MapPin, CalendarDays, ArrowUpDown, LayoutGrid, Ruler, Pencil,
Plus, Users, DoorOpen, Search, List, ChevronDown, ChevronRight, Flame, Trash2,
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
DialogFooter,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { useStore } from '../store/store'
import type { Building, HausForm, Unit } from '../types'
import { HEIZUNGSARTEN } from '../types'
/** Komma oder Punkt als Dezimaltrenner akzeptieren. */
const zahl = (s: string) => Number(s.replace(',', '.'))
/** Optischer Abschnitt im einspaltigen Haus-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>
)
}
function HausDialog({ haus }: { haus?: Building }) {
const { addHaus, updateHaus } = useStore()
const bearbeiten = Boolean(haus)
const [open, setOpen] = useState(false)
const [strasse, setStrasse] = useState(haus?.strasse ?? '')
const [hausnummer, setHausnummer] = useState(haus?.hausnummer ?? '')
const [plz, setPlz] = useState(haus?.plz ?? '')
const [ort, setOrt] = useState(haus?.ort ?? '')
const [lage, setLage] = useState(haus?.lage ?? '')
const [baujahr, setBaujahr] = useState(String(haus?.baujahr ?? ''))
const [stockwerke, setStockwerke] = useState(String(haus?.stockwerke ?? ''))
const [etagenhoehe, setEtagenhoehe] = useState(String(haus?.etagenhoehe ?? '').replace('.', ','))
const [wohneinheiten, setWohneinheiten] = useState(String(haus?.wohneinheiten ?? ''))
const [wohnflaeche, setWohnflaeche] = useState(String(haus?.wohnflaeche ?? '').replace('.', ','))
const [grundstuecksflaeche, setGrundstuecksflaeche] = useState(
haus?.grundstuecksflaeche != null ? String(haus.grundstuecksflaeche).replace('.', ',') : '',
)
const [heizungsart, setHeizungsart] = useState(haus?.heizungsart ?? '')
const [eigenobjekt, setEigenobjekt] = useState(haus?.eigenobjekt ?? false)
const [sende, setSende] = useState(false)
const [fehler, setFehler] = useState<string | null>(null)
const vollstaendig =
strasse.trim() && hausnummer.trim() && /^\d{5}$/.test(plz.trim()) && ort.trim() &&
baujahr && stockwerke && etagenhoehe && wohneinheiten && wohnflaeche
const submit = async () => {
if (sende) return
setSende(true)
setFehler(null)
const payload: HausForm = {
strasse: strasse.trim(),
hausnummer: hausnummer.trim(),
plz: plz.trim(),
ort: ort.trim(),
lage: lage.trim(),
baujahr: zahl(baujahr),
stockwerke: zahl(stockwerke),
etagenhoehe: zahl(etagenhoehe),
wohneinheiten: zahl(wohneinheiten),
wohnflaeche: zahl(wohnflaeche),
grundstuecksflaeche: grundstuecksflaeche.trim() ? zahl(grundstuecksflaeche) : null,
heizungsart,
eigenobjekt,
}
const err = bearbeiten ? await updateHaus(haus!.name, payload) : await addHaus(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" /> Neues Haus
</Button>
)}
</DialogTrigger>
<DialogContent className="max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{bearbeiten ? `Haus „${haus!.adresse}" bearbeiten` : 'Neues Haus anlegen'}
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<p className="text-xs text-slate-500 dark:text-slate-400">
Hausname und Adresszeile werden automatisch aus Straße und Hausnummer gebildet.
</p>
<Abschnitt titel="Adresse">
<Feld id="h-strasse" label="Straße" placeholder="z. B. Lindenallee" value={strasse} onChange={(e) => setStrasse(e.target.value)} />
<Feld id="h-nr" label="Hausnummer" placeholder="z. B. 8" value={hausnummer} onChange={(e) => setHausnummer(e.target.value)} />
<Feld id="h-plz" label="PLZ" inputMode="numeric" placeholder="z. B. 47839" value={plz} onChange={(e) => setPlz(e.target.value)} />
<Feld id="h-ort" label="Ort" placeholder="z. B. Krefeld" value={ort} onChange={(e) => setOrt(e.target.value)} />
<Feld id="h-lage" label="Lage" hint={'Optional, z. B. Stadtteil („Krefeld-Hüls")'} placeholder="z. B. Stadtteil Hüls" value={lage} onChange={(e) => setLage(e.target.value)} />
</Abschnitt>
<Abschnitt titel="Gebäude">
<Feld id="h-bau" label="Baujahr" inputMode="numeric" placeholder="z. B. 1998" value={baujahr} onChange={(e) => setBaujahr(e.target.value)} />
<Feld id="h-stock" label="Anzahl Stockwerke" inputMode="numeric" placeholder="z. B. 3" value={stockwerke} onChange={(e) => setStockwerke(e.target.value)} />
<Feld id="h-hoehe" label="Etagenhöhe (m)" inputMode="decimal" placeholder="2,65" value={etagenhoehe} onChange={(e) => setEtagenhoehe(e.target.value)} />
<Feld id="h-whg" label="Anzahl Wohneinheiten" inputMode="numeric" placeholder="z. B. 12" value={wohneinheiten} onChange={(e) => setWohneinheiten(e.target.value)} />
<div className="space-y-1.5">
<Label>Heizungsart</Label>
<Select value={heizungsart || '__keine'} onValueChange={(v) => setHeizungsart(v === '__keine' ? '' : v)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="__keine">Nicht angegeben</SelectItem>
{HEIZUNGSARTEN.map((hz) => (
<SelectItem key={hz} value={hz}>{hz}</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-slate-400 dark:text-slate-500">Optional</p>
</div>
</Abschnitt>
<Abschnitt titel="Flächen">
<Feld id="h-wohnfl" label="Wohnfläche gesamt (m²)" inputMode="decimal" placeholder="z. B. 980" value={wohnflaeche} onChange={(e) => setWohnflaeche(e.target.value)} />
<Feld id="h-grundfl" label="Grundstücksfläche (m²)" hint="Optional" inputMode="decimal" placeholder="z. B. 1.250" value={grundstuecksflaeche} onChange={(e) => setGrundstuecksflaeche(e.target.value)} />
</Abschnitt>
<Abschnitt titel="Zuordnung Belege">
<label className="flex cursor-pointer items-start gap-2.5 text-sm">
<input
id="h-eigenobjekt"
type="checkbox"
checked={eigenobjekt}
onChange={(e) => setEigenobjekt(e.target.checked)}
className="mt-0.5 h-4 w-4 rounded border-slate-300 accent-teal-600"
/>
<span>
<span className="font-medium text-slate-800 dark:text-slate-100">Eigenobjekt / Rechnungsempfänger</span>
<span className="block text-xs text-slate-500 dark:text-slate-400">
Diese Adresse steht auf eingehenden Rechnungen als Empfänger (z. B. die eigene Verwaltungsadresse).
Eigenobjekte werden bei der automatischen Beleg-Zuordnung übersprungen – die Zuordnung erfolgt dann
über die Adresse bzw. Stromzählernummer des Mietobjekts.
</span>
</span>
</label>
</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 || !vollstaendig}>
{sende ? 'Wird gespeichert …' : bearbeiten ? 'Speichern' : 'Haus anlegen'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
function WohnungDialog({ wohnung, gebaeude, festesHaus }: { wohnung?: Unit; gebaeude: string[]; festesHaus?: string }) {
const { addWohnung, updateWohnung, buildings } = useStore()
const bearbeiten = Boolean(wohnung)
const [open, setOpen] = useState(false)
const [building, setBuilding] = useState(wohnung?.building ?? festesHaus ?? gebaeude[0] ?? '')
const [number, setNumber] = useState(wohnung?.number ?? '')
const [floor, setFloor] = useState(String(wohnung?.floor ?? '0'))
const [size, setSize] = useState(wohnung ? String(wohnung.size).replace('.', ',') : '')
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)
/** Bei Neuanlage: Heizungsart der Wohneinheit aus dem gewählten Haus vorbelegen. */
const waehleHaus = (b: string) => {
setBuilding(b)
if (!bearbeiten) {
const h = buildings.find((x) => x.name === b)
if (h?.heizungsart) setHeizungsart(h.heizungsart)
}
}
const submit = async () => {
if (sende) return
setSende(true)
setFehler(null)
const payload = {
building,
number: number.trim(),
floor: zahl(floor),
size: zahl(size),
status,
heizungsart,
}
const err = bearbeiten ? await updateWohnung(wohnung!.id, payload) : await addWohnung(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 size="sm" className="gap-1.5">
<Plus className="h-3.5 w-3.5" /> Wohnung
</Button>
)}
</DialogTrigger>
<DialogContent className="max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{bearbeiten
? `Wohnung ${wohnung!.building}, Whg. ${wohnung!.number} bearbeiten`
: festesHaus
? `Neue Wohnung in „${festesHaus}" anlegen`
: 'Neue Wohnung anlegen'}
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<Abschnitt titel="Zuordnung">
<div className="space-y-1.5">
<Label>Haus</Label>
<Select value={building} onValueChange={waehleHaus} disabled={Boolean(festesHaus)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{gebaeude.map((g) => (
<SelectItem key={g} value={g}>{g}</SelectItem>
))}
</SelectContent>
</Select>
{festesHaus && <p className="text-xs text-slate-400 dark:text-slate-500">Die Wohnung gehört fest zu diesem Haus.</p>}
</div>
<Feld id="w-nr" label="Wohnungsnummer" placeholder="z. B. 12" value={number} onChange={(e) => setNumber(e.target.value)} />
</Abschnitt>
<Abschnitt titel="Ausstattung">
<Feld id="w-etage" label="Etage" hint="0 = Erdgeschoss, negative Werte = Keller" inputMode="numeric" placeholder="z. B. 1" value={floor} onChange={(e) => setFloor(e.target.value)} />
<Feld id="w-qm" label="Wohnfläche (m²)" inputMode="decimal" placeholder="z. B. 68,5" value={size} onChange={(e) => setSize(e.target.value)} />
<div className="space-y-1.5">
<Label>Heizungsart</Label>
<Select value={heizungsart || '__keine'} onValueChange={(v) => setHeizungsart(v === '__keine' ? '' : v)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="__keine">Nicht angegeben</SelectItem>
{HEIZUNGSARTEN.map((hz) => (
<SelectItem key={hz} value={hz}>{hz}</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-slate-400 dark:text-slate-500">
Optional{!bearbeiten && ' – wird bei Neuanlage automatisch aus dem Haus übernommen'}.
</p>
</div>
</Abschnitt>
<Abschnitt titel="Status">
<div className="space-y-1.5">
<Label>Status</Label>
<Select value={status} onValueChange={(v) => setStatus(v as Unit['status'])}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<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 && (
<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 || !building || !number.trim() || !size}>
{sende ? 'Wird gespeichert …' : bearbeiten ? 'Speichern' : 'Wohnung anlegen'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
/** Wohnungen eines Hauses: Etage aufsteigend, dann Nummer numerisch. */
function sortiereWohnungen(liste: Unit[]): Unit[] {
return [...liste].sort(
(a, b) => a.floor - b.floor || a.number.localeCompare(b.number, 'de-DE', { numeric: true }),
)
}
/** 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()
const loesche = async () => {
if (!window.confirm(`Wohnung ${u.building}, Whg. ${u.number} wirklich löschen?`)) return
const fehler = await deleteWohnung(u.id)
if (fehler) window.alert(fehler)
}
return (
<li className="rounded-xl border bg-slate-50/60 px-3 py-2 dark:border-slate-700 dark:bg-slate-800/60">
<div className="flex items-center justify-between gap-2">
<div className="text-sm font-semibold text-slate-800 dark:text-slate-100">
Whg. {u.number}
<span className="ml-1.5 text-xs font-normal text-slate-400 dark:text-slate-500">
Etage {u.floor} · {u.size.toLocaleString('de-DE')} m²
</span>
</div>
<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">
<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">
<WohnungDialog wohnung={u} gebaeude={gebaeude} festesHaus={festesHaus} />
{role === 'admin' && (
<button
type="button"
className="text-slate-400 transition-colors hover:text-red-600 dark:text-slate-500 dark:hover:text-red-400"
onClick={loesche}
aria-label={`Wohnung ${u.number} löschen`}
title="Wohnung löschen"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</span>
</div>
</li>
)
}
/** Fortschrittsbalken „angelegte Wohneinheiten gegen Soll". */
function BelegungsFortschritt({ angelegt, soll }: { angelegt: number; soll: number }) {
const voll = angelegt === soll
const ueberSoll = angelegt > soll
return (
<div>
<div className="mb-1 flex items-center justify-between text-xs">
<span className="flex items-center gap-1.5 text-slate-500 dark:text-slate-400">
<LayoutGrid className="h-3.5 w-3.5" /> Wohneinheiten
</span>
<span className={`font-semibold ${voll ? 'text-teal-700 dark:text-teal-400' : ueberSoll ? 'text-amber-600 dark:text-amber-400' : 'text-slate-700 dark:text-slate-200'}`}>
{angelegt} von {soll} angelegt
</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-slate-100 dark:bg-slate-800">
<div
className={`h-full rounded-full transition-all ${voll ? 'bg-teal-500' : ueberSoll ? 'bg-amber-500' : 'bg-slate-400'}`}
style={{ width: `${Math.min(100, soll ? (angelegt / soll) * 100 : 0)}%` }}
/>
</div>
{ueberSoll && (
<p className="mt-1 text-xs text-amber-600 dark:text-amber-400">Achtung: mehr Wohnungen angelegt als im Soll vorgesehen.</p>
)}
</div>
)
}
export default function Bestand() {
const { buildings, units, deleteHaus, role } = useStore()
const [suche, setSuche] = useState('')
const [ansicht, setAnsicht] = useState<'karten' | 'liste'>('liste')
const [offen, setOffen] = useState<string | null>(null)
const loescheHaus = async (name: string) => {
if (!window.confirm(`Haus „${name}" wirklich löschen?`)) return
const fehler = await deleteHaus(name)
if (fehler) window.alert(fehler)
if (offen === name) setOffen(null)
}
const gebaeudeNamen = useMemo(
() => buildings.map((b) => b.name).sort(),
[buildings],
)
// 1:n-Struktur: jede Wohnung gehört zu genau einem Haus
const wohnungenJeHaus = useMemo(() => {
const m = new Map<string, Unit[]>()
for (const u of units) {
const l = m.get(u.building) ?? []
l.push(u)
m.set(u.building, l)
}
for (const [k, l] of m) m.set(k, sortiereWohnungen(l))
return m
}, [units])
// Wohnungen ohne Haus (kann bei Altbeständen vorkommen) separat anzeigen
const verwaisteWohnungen = useMemo(
() => units.filter((u) => !buildings.some((b) => b.name === u.building)),
[units, buildings],
)
// Suche über Adresse, Einzelfelder, Lage und internem Namen
const gefilterteHaeuser = useMemo(() => {
const q = suche.trim().toLowerCase()
if (!q) return buildings
return buildings.filter((h) =>
[h.adresse, h.strasse, h.hausnummer, h.plz, h.ort, h.lage, h.name]
.some((f) => f.toLowerCase().includes(q)),
)
}, [buildings, suche])
return (
<div className="space-y-8">
<div className="flex flex-wrap items-end justify-between gap-3">
<div>
<h1 className="text-2xl font-bold tracking-tight">Häuser & Wohnungen</h1>
<p className="text-sm text-slate-500 dark:text-slate-400">
Bestand pflegen – jedes Haus besteht aus mehreren Wohneinheiten, die hier
zugeordnet angelegt und bearbeitet werden
</p>
</div>
<div className="flex flex-wrap gap-2">
<WohnungDialog gebaeude={gebaeudeNamen} />
<HausDialog />
</div>
</div>
{/* Suche + Ansichtsumschalter */}
{buildings.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="Haus suchen …"
value={suche}
onChange={(e) => setSuche(e.target.value)}
aria-label="Haus suchen"
/>
</div>
<div className="flex rounded-lg border p-0.5 dark:border-slate-700">
<Button
variant={ansicht === 'karten' ? 'default' : 'ghost'}
size="sm"
className="gap-1.5"
onClick={() => setAnsicht('karten')}
aria-pressed={ansicht === 'karten'}
>
<LayoutGrid className="h-3.5 w-3.5" /> Karten
</Button>
<Button
variant={ansicht === 'liste' ? 'default' : 'ghost'}
size="sm"
className="gap-1.5"
onClick={() => setAnsicht('liste')}
aria-pressed={ansicht === 'liste'}
>
<List className="h-3.5 w-3.5" /> Liste
</Button>
</div>
{suche && (
<span className="text-xs text-slate-500 dark:text-slate-400">
{gefilterteHaeuser.length} von {buildings.length} Häusern
</span>
)}
</div>
)}
{buildings.length === 0 ? (
<div className="rounded-2xl border border-dashed bg-white p-10 text-center text-sm text-slate-500 dark:text-slate-400">
<Building2 className="mx-auto mb-3 h-8 w-8 text-slate-300 dark:text-slate-600" />
Noch keine Häuser angelegt – legen Sie zuerst ein Haus an, dann dessen Wohneinheiten.
</div>
) : gefilterteHaeuser.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 Haus passt zur Suche „{suche}".
</div>
) : ansicht === 'karten' ? (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
{gefilterteHaeuser.map((h) => {
const wohnungen = wohnungenJeHaus.get(h.name) ?? []
const angelegt = wohnungen.length
return (
<div key={h.name} className="flex flex-col rounded-2xl border bg-white p-5 shadow-sm dark:border-slate-700 dark:bg-slate-900">
{/* Haus-Kopf: Überschrift ist die Adresse */}
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2">
<span className="rounded-xl bg-slate-100 p-2 dark:bg-slate-800">
<Building2 className="h-5 w-5 text-slate-600 dark:text-slate-300" />
</span>
<div>
<div className="font-semibold text-slate-800 dark:text-slate-100">{h.adresse}</div>
<div className="flex flex-wrap items-center gap-1 text-xs text-slate-500 dark:text-slate-400">
<MapPin className="h-3 w-3" />
{h.lage || '–'}
{h.grundstuecksflaeche != null && (
<span className="text-slate-400 dark:text-slate-500">
· Grundstück {h.grundstuecksflaeche.toLocaleString('de-DE')} m²
</span>
)}
{h.eigenobjekt && (
<Badge variant="secondary" className="bg-sky-100 text-sky-800 dark:bg-sky-950 dark:text-sky-300">
Eigenobjekt
</Badge>
)}
</div>
</div>
</div>
<span className="flex shrink-0 items-center gap-1">
<HausDialog haus={h} />
{role === 'admin' && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-slate-400 hover:text-red-600 dark:text-slate-500 dark:hover:text-red-400"
onClick={() => loescheHaus(h.name)}
aria-label={`Haus ${h.adresse} löschen`}
title="Haus löschen"
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</span>
</div>
<dl className="mt-4 grid grid-cols-2 gap-x-4 gap-y-2 text-xs">
<div className="flex items-center gap-1.5 text-slate-500 dark:text-slate-400">
<CalendarDays className="h-3.5 w-3.5 shrink-0" /> Baujahr
</div>
<dd className="text-right font-medium text-slate-800 dark:text-slate-100">{h.baujahr}</dd>
<div className="flex items-center gap-1.5 text-slate-500 dark:text-slate-400">
<ArrowUpDown className="h-3.5 w-3.5 shrink-0" /> Stockwerke
</div>
<dd className="text-right font-medium text-slate-800 dark:text-slate-100">{h.stockwerke}</dd>
<div className="flex items-center gap-1.5 text-slate-500 dark:text-slate-400">
<Ruler className="h-3.5 w-3.5 shrink-0" /> Etagenhöhe
</div>
<dd className="text-right font-medium text-slate-800 dark:text-slate-100">{h.etagenhoehe.toLocaleString('de-DE')} m</dd>
<div className="flex items-center gap-1.5 text-slate-500 dark:text-slate-400">
<Home className="h-3.5 w-3.5 shrink-0" /> Wohnfläche
</div>
<dd className="text-right font-medium text-slate-800 dark:text-slate-100">{h.wohnflaeche.toLocaleString('de-DE')} m²</dd>
{h.heizungsart && (
<>
<div className="flex items-center gap-1.5 text-slate-500 dark:text-slate-400">
<Flame className="h-3.5 w-3.5 shrink-0" /> Heizung
</div>
<dd className="text-right font-medium text-slate-800 dark:text-slate-100">{h.heizungsart}</dd>
</>
)}
</dl>
<div className="mt-4">
<BelegungsFortschritt angelegt={angelegt} soll={h.wohneinheiten} />
</div>
{/* Wohneinheiten des Hauses (1:n) */}
<div className="mt-4 border-t pt-3 dark:border-slate-700">
<div className="mb-2 flex items-center justify-between">
<h3 className="text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
Wohneinheiten ({angelegt})
</h3>
<WohnungDialog gebaeude={gebaeudeNamen} festesHaus={h.name} />
</div>
{angelegt === 0 ? (
<div className="rounded-xl border border-dashed py-6 text-center text-xs text-slate-400 dark:text-slate-500">
<DoorOpen className="mx-auto mb-2 h-6 w-6 text-slate-300 dark:text-slate-600" />
Noch keine Wohnungen – über „+ Wohnung" die erste Einheit anlegen.
</div>
) : (
<ul className="space-y-2">
{wohnungen.map((u) => (
<WohnungsZeile key={u.id} u={u} gebaeude={gebaeudeNamen} festesHaus={h.name} />
))}
</ul>
)}
</div>
</div>
)
})}
</div>
) : (
/* ── Listenansicht ─────────────────────────────── */
<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="w-8 px-3 py-2.5" aria-label="Details" />
<th className="px-3 py-2.5">Adresse</th>
<th className="hidden px-3 py-2.5 md:table-cell">Lage</th>
<th className="hidden px-3 py-2.5 sm:table-cell">Baujahr</th>
<th className="hidden px-3 py-2.5 lg:table-cell">Stockwerke</th>
<th className="hidden px-3 py-2.5 lg:table-cell">Wohnfläche</th>
<th className="px-3 py-2.5">Wohneinheiten</th>
<th className="px-3 py-2.5 text-right">Aktion</th>
</tr>
</thead>
<tbody>
{gefilterteHaeuser.map((h) => {
const wohnungen = wohnungenJeHaus.get(h.name) ?? []
const angelegt = wohnungen.length
const aufgeklappt = offen === h.name
return (
<Fragment key={h.name}>
<tr
className="cursor-pointer border-b last:border-0 hover:bg-slate-50 dark:border-slate-700 dark:hover:bg-slate-800/40"
onClick={() => setOffen(aufgeklappt ? null : h.name)}
>
<td className="px-3 py-2.5 text-slate-400">
{aufgeklappt ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</td>
<td className="px-3 py-2.5 font-medium text-slate-800 dark:text-slate-100">
{h.adresse}
{h.eigenobjekt && (
<Badge variant="secondary" className="ml-2 bg-sky-100 text-sky-800 dark:bg-sky-950 dark:text-sky-300">
Eigenobjekt
</Badge>
)}
</td>
<td className="hidden px-3 py-2.5 text-slate-500 dark:text-slate-400 md:table-cell">{h.lage || '–'}</td>
<td className="hidden px-3 py-2.5 text-slate-500 dark:text-slate-400 sm:table-cell">{h.baujahr}</td>
<td className="hidden px-3 py-2.5 text-slate-500 dark:text-slate-400 lg:table-cell">{h.stockwerke}</td>
<td className="hidden px-3 py-2.5 text-slate-500 dark:text-slate-400 lg:table-cell">
{h.wohnflaeche.toLocaleString('de-DE')} m²
</td>
<td className="px-3 py-2.5">
<span
className={
angelegt === h.wohneinheiten
? 'font-medium text-teal-700 dark:text-teal-400'
: angelegt > h.wohneinheiten
? 'font-medium text-amber-600 dark:text-amber-400'
: 'text-slate-600 dark:text-slate-300'
}
>
{angelegt} / {h.wohneinheiten}
</span>
</td>
<td className="px-3 py-2.5 text-right" onClick={(e) => e.stopPropagation()}>
<span className="inline-flex items-center gap-1">
<HausDialog haus={h} />
{role === 'admin' && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-slate-400 hover:text-red-600 dark:text-slate-500 dark:hover:text-red-400"
onClick={() => loescheHaus(h.name)}
aria-label={`Haus ${h.adresse} löschen`}
title="Haus löschen"
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</span>
</td>
</tr>
{aufgeklappt && (
<tr className="border-b bg-slate-50/60 dark:border-slate-700 dark:bg-slate-800/30">
<td colSpan={8} className="px-3 py-3">
<div className="mb-2 flex flex-wrap items-center justify-between gap-2 text-xs text-slate-500 dark:text-slate-400">
<span>
Baujahr {h.baujahr} · {h.stockwerke} Stockwerke · Etagenhöhe{' '}
{h.etagenhoehe.toLocaleString('de-DE')} m · Wohnfläche{' '}
{h.wohnflaeche.toLocaleString('de-DE')} m²
{h.grundstuecksflaeche != null &&
` · Grundstück ${h.grundstuecksflaeche.toLocaleString('de-DE')} m²`}
{h.heizungsart && ` · Heizung: ${h.heizungsart}`}
</span>
<WohnungDialog gebaeude={gebaeudeNamen} festesHaus={h.name} />
</div>
{angelegt === 0 ? (
<p className="rounded-xl border border-dashed py-4 text-center text-xs text-slate-400 dark:text-slate-500">
Noch keine Wohnungen angelegt.
</p>
) : (
<ul className="grid gap-2 lg:grid-cols-2">
{wohnungen.map((u) => (
<WohnungsZeile key={u.id} u={u} gebaeude={gebaeudeNamen} festesHaus={h.name} />
))}
</ul>
)}
</td>
</tr>
)}
</Fragment>
)
})}
</tbody>
</table>
</div>
)}
{verwaisteWohnungen.length > 0 && (
<section className="space-y-3">
<h2 className="text-lg font-semibold">Wohnungen ohne Hauszuordnung</h2>
<p className="text-sm text-slate-500 dark:text-slate-400">
Diese Wohnungen verweisen auf ein nicht angelegtes Haus – bitte Zuordnung über „Bearbeiten" korrigieren.
</p>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
{verwaisteWohnungen.map((u) => (
<div key={u.id} className="rounded-2xl border border-amber-200 bg-amber-50/50 p-4 dark:border-amber-900 dark:bg-amber-950/40">
<div className="font-semibold">{u.building}, Whg. {u.number}</div>
<div className="mt-2">
<WohnungDialog wohnung={u} gebaeude={gebaeudeNamen} />
</div>
</div>
))}
</div>
</section>
)}
</div>
)
}