v1.26: Haeuser ohne Namensfeld, Adresse in Einzelfelder, Flaechen/Lage, Suche + Listenansicht
This commit is contained in:
@@ -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
@@ -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 && (
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user