774 lines
36 KiB
TypeScript
774 lines
36 KiB
TypeScript
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>
|
||
)
|
||
}
|