v1.26: Haeuser ohne Namensfeld, Adresse in Einzelfelder, Flaechen/Lage, Suche + Listenansicht

This commit is contained in:
2026-10-04 17:08:59 +02:00
parent 6fb70e015f
commit 6e2d783e12
12 changed files with 434 additions and 141 deletions
+5 -1
View File
@@ -157,7 +157,11 @@ export const anleitungAbschnitte: AnleitungAbschnitt[] = [
punkte: [
{
titel: 'Haus anlegen',
text: 'Pflichtangaben: Baujahr, Adresse, Etagenhöhe, Stockwerke, Anzahl Wohneinheiten und Quadratmeter.',
text: 'Pflichtangaben: Straße, Hausnummer, PLZ, Ort, Baujahr, Anzahl Stockwerke, Etagenhöhe, Anzahl Wohneinheiten und Wohnfläche. Optional: Lage (z. B. Stadtteil) und Grundstücksfläche. Hausname und Adresszeile werden automatisch aus Straße und Hausnummer gebildet.',
},
{
titel: 'Häuser suchen & Listenansicht',
text: 'Über das Suchfeld lassen sich Häuser nach Adresse, Ort, PLZ oder Lage filtern. Der Umschalter Karten/Liste wählt die Ansicht; in der Liste öffnet ein Klick auf eine Zeile die Wohneinheiten des Hauses.',
},
{
titel: 'Wohnungen zuordnen',
+299 -101
View File
@@ -1,5 +1,8 @@
import { useMemo, useState } from 'react'
import { Building2, Home, MapPin, CalendarDays, ArrowUpDown, LayoutGrid, Ruler, Pencil, Plus, Users, DoorOpen } from 'lucide-react'
import { Fragment, useMemo, useState } from 'react'
import {
Building2, Home, MapPin, CalendarDays, ArrowUpDown, LayoutGrid, Ruler, Pencil,
Plus, Users, DoorOpen, Search, List, ChevronDown, ChevronRight,
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
@@ -20,37 +23,77 @@ import {
SelectValue,
} from '@/components/ui/select'
import { useStore, fmtEUR } from '../store/store'
import type { Building, Unit } from '../types'
import type { Building, HausForm, Unit } 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 [name, setName] = useState(haus?.name ?? '')
const [adresse, setAdresse] = useState(haus?.adresse ?? '')
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 [quadratmeter, setQuadratmeter] = useState(String(haus?.quadratmeter ?? '').replace('.', ','))
const [wohnflaeche, setWohnflaeche] = useState(String(haus?.wohnflaeche ?? '').replace('.', ','))
const [grundstuecksflaeche, setGrundstuecksflaeche] = useState(
haus?.grundstuecksflaeche != null ? String(haus.grundstuecksflaeche).replace('.', ',') : '',
)
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 = {
name: name.trim(),
adresse: adresse.trim(),
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),
quadratmeter: zahl(quadratmeter),
wohnflaeche: zahl(wohnflaeche),
grundstuecksflaeche: grundstuecksflaeche.trim() ? zahl(grundstuecksflaeche) : null,
}
const err = bearbeiten ? await updateHaus(haus!.name, payload) : await addHaus(payload)
setSende(false)
@@ -71,45 +114,33 @@ function HausDialog({ haus }: { haus?: Building }) {
</Button>
)}
</DialogTrigger>
<DialogContent>
<DialogContent className="max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{bearbeiten ? `Haus „${haus!.name}" bearbeiten` : 'Neues Haus anlegen'}</DialogTitle>
<DialogTitle>
{bearbeiten ? `Haus „${haus!.adresse}" bearbeiten` : 'Neues Haus anlegen'}
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="h-name">Hausname</Label>
<Input id="h-name" placeholder="z. B. Lindenallee 8" value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div className="space-y-1.5">
<Label htmlFor="h-adr">Adresse</Label>
<Input id="h-adr" placeholder="Straße Nr., PLZ Ort" value={adresse} onChange={(e) => setAdresse(e.target.value)} />
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="h-bau">Baujahr</Label>
<Input id="h-bau" inputMode="numeric" placeholder="z. B. 1998" value={baujahr} onChange={(e) => setBaujahr(e.target.value)} />
</div>
<div className="space-y-1.5">
<Label htmlFor="h-stock">Stockwerke</Label>
<Input id="h-stock" inputMode="numeric" placeholder="z. B. 3" value={stockwerke} onChange={(e) => setStockwerke(e.target.value)} />
</div>
</div>
<div className="grid gap-4 sm:grid-cols-3">
<div className="space-y-1.5">
<Label htmlFor="h-hoehe">Etagenhöhe (m)</Label>
<Input id="h-hoehe" inputMode="decimal" placeholder="2,65" value={etagenhoehe} onChange={(e) => setEtagenhoehe(e.target.value)} />
</div>
<div className="space-y-1.5">
<Label htmlFor="h-whg">Wohneinheiten (Soll)</Label>
<Input id="h-whg" inputMode="numeric" placeholder="z. B. 12" value={wohneinheiten} onChange={(e) => setWohneinheiten(e.target.value)} />
</div>
<div className="space-y-1.5">
<Label htmlFor="h-qm">Quadratmeter (m²)</Label>
<Input id="h-qm" inputMode="decimal" placeholder="z. B. 980" value={quadratmeter} onChange={(e) => setQuadratmeter(e.target.value)} />
</div>
</div>
<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)} />
</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>
{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}
@@ -118,7 +149,7 @@ function HausDialog({ haus }: { haus?: Building }) {
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>Abbrechen</Button>
<Button onClick={submit} disabled={sende || !name.trim() || !adresse.trim() || !baujahr || !stockwerke || !etagenhoehe || !wohneinheiten || !quadratmeter}>
<Button onClick={submit} disabled={sende || !vollstaendig}>
{sende ? 'Wird gespeichert …' : bearbeiten ? 'Speichern' : 'Haus anlegen'}
</Button>
</DialogFooter>
@@ -264,8 +295,69 @@ function sortiereWohnungen(liste: Unit[]): Unit[] {
)
}
/** Wohnungszeile, in Karten- und Listenansicht gleich. */
function WohnungsZeile({ u, gebaeude, festesHaus }: { u: Unit; gebaeude: string[]; festesHaus: string }) {
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={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>
</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>
<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} />
</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 } = useStore()
const [suche, setSuche] = useState('')
const [ansicht, setAnsicht] = useState<'karten' | 'liste'>('karten')
const [offen, setOffen] = useState<string | null>(null)
const gebaeudeNamen = useMemo(
() => buildings.map((b) => b.name).sort(),
@@ -290,6 +382,16 @@ export default function Bestand() {
[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">
@@ -306,30 +408,80 @@ export default function Bestand() {
</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">
{buildings.map((h) => {
{gefilterteHaeuser.map((h) => {
const wohnungen = wohnungenJeHaus.get(h.name) ?? []
const angelegt = wohnungen.length
const voll = angelegt === h.wohneinheiten
const ueberSoll = angelegt > h.wohneinheiten
return (
<div key={h.name} className="flex flex-col rounded-2xl border bg-white p-5 shadow-sm">
{/* Haus-Kopf */}
<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">{h.name}</div>
<div className="flex items-center gap-1 text-xs text-slate-500 dark:text-slate-400">
<MapPin className="h-3 w-3" /> {h.adresse}
<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>
)}
</div>
</div>
</div>
@@ -351,32 +503,15 @@ export default function Bestand() {
<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.quadratmeter.toLocaleString('de-DE')} m²</dd>
<dd className="text-right font-medium text-slate-800 dark:text-slate-100">{h.wohnflaeche.toLocaleString('de-DE')} m²</dd>
</dl>
{/* Belegungsfortschritt: angelegte Wohneinheiten gegen Soll */}
<div className="mt-4">
<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 {h.wohneinheiten} 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, h.wohneinheiten ? (angelegt / h.wohneinheiten) * 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>
)}
<BelegungsFortschritt angelegt={angelegt} soll={h.wohneinheiten} />
</div>
{/* Wohneinheiten des Hauses (1:n) */}
<div className="mt-4 border-t pt-3">
<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})
@@ -391,31 +526,7 @@ export default function Bestand() {
) : (
<ul className="space-y-2">
{wohnungen.map((u) => (
<li key={u.id} className="rounded-xl border bg-slate-50/60 px-3 py-2 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={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>
</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>
<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={gebaeudeNamen} festesHaus={h.name} />
</span>
</div>
</li>
<WohnungsZeile key={u.id} u={u} gebaeude={gebaeudeNamen} festesHaus={h.name} />
))}
</ul>
)}
@@ -424,6 +535,93 @@ export default function Bestand() {
)
})}
</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}</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()}>
<HausDialog haus={h} />
</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²`}
</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 && (
+4 -4
View File
@@ -147,8 +147,8 @@ function KostenDialog({ position, vorbefuellung, onGespeichert, triggerLabel }:
</SelectTrigger>
<SelectContent>
{buildings.map((b) => (
<SelectItem key={b.name} value={b.name}>
{b.name}
<SelectItem key={b.adresse} value={b.name}>
{b.adresse}
</SelectItem>
))}
</SelectContent>
@@ -613,8 +613,8 @@ export default function Kosten() {
<SelectContent>
<SelectItem value="alle">Alle Häuser</SelectItem>
{buildings.map((b) => (
<SelectItem key={b.name} value={b.name}>
{b.name}
<SelectItem key={b.adresse} value={b.name}>
{b.adresse}
</SelectItem>
))}
</SelectContent>
+3 -2
View File
@@ -33,6 +33,7 @@ import type {
MeterReading,
ZaehlerArt,
Building,
HausForm,
KostenItem,
Beleg,
} from '../types'
@@ -137,8 +138,8 @@ interface Store extends Bootstrap {
/** Verlaufseintrag löschen (Korrektur, Verwaltung) */
zaehlerEintragLoeschen: (meterId: string, eintragId: string) => Promise<string | null>
// ── Bestand: Häuser & Wohnungen (Verwaltung) ────────────
addHaus: (h: Omit<Building, 'name'> & { name: string }) => Promise<string | null>
updateHaus: (name: string, h: Partial<Building>) => Promise<string | null>
addHaus: (h: HausForm) => Promise<string | null>
updateHaus: (name: string, h: Partial<HausForm>) => Promise<string | null>
addWohnung: (u: {
building: string
number: string
+14 -2
View File
@@ -108,15 +108,27 @@ export interface Announcement {
export type ZaehlerArt = 'Strom' | 'Wasser' | 'Gas' | 'Wärme' | 'Sonstiges'
export interface Building {
/** Interner Schlüssel – wird serverseitig aus Straße + Hausnummer abgeleitet. */
name: string
/** Anzeige: „Straße Nr., PLZ Ort" (serverseitig zusammengesetzt). */
adresse: string
strasse: string
hausnummer: string
plz: string
ort: string
/** Optional, z. B. Stadtteil („Krefeld-Hüls") */
lage: string
baujahr: number
stockwerke: number
etagenhoehe: number // Deckenhöhe in m
wohneinheiten: number
quadratmeter: number
wohneinheiten: number // Anzahl Wohneinheiten (Soll)
wohnflaeche: number // Gesamtwohnfläche in m²
grundstuecksflaeche: number | null // in m², optional
}
/** Formular-Daten für Neuanlage/Bearbeitung (Name/Adresse entfallen). */
export type HausForm = Omit<Building, 'name' | 'adresse'>
export interface Meter {
id: string
unitId: string | null // null = Haus-/Allgemeinzähler