v1.15: Haus -> Wohneinheiten Struktur in der Verwaltung + Fix Haus-Umbenennung (costs FK ON UPDATE CASCADE)

This commit is contained in:
2026-10-04 12:25:47 +02:00
parent b57e9276f8
commit 189ce10d65
3 changed files with 169 additions and 103 deletions
+19
View File
@@ -189,6 +189,25 @@ tar -czf mp-dist.tar.gz -C dist .
## 8. Verlauf / offene Punkte
**Erledigt 04.10. (v1.15):** Struktur „1 Haus → mehrere Wohneinheiten" in der
Verwaltung verankert + Bugfix Haus-Umbenennung. BUGFIX (wichtig): der FK
`costs_building_fkey` aus v1.14 hatte kein ON UPDATE CASCADE → jede
Haus-Umbenennung (PATCH /api/buildings/:name) schlug mit FK-Verletzung fehl
(„Interner Serverfehler"), sobald Kostenpositionen existieren. Fix in db.js:
Constraint per DO-Block idempotent auf ON UPDATE CASCADE neu gesetzt (DROP +
ADD; SCHEMA läuft als eine Simple-Query ohne Parameter, DO-Blöcke sind darin
erlaubt). E2E-Rundreise verifiziert: Umbenennen zieht units + meters +
costs mit, Rückbenennung sauber. UI (Bestand.tsx komplett überarbeitet):
Haus-Karten enthalten jetzt die 1:n-Struktur sichtbar – pro Haus
Belegungsfortschritt (Balken „X von Y Wohneinheiten angelegt", amber bei
Über-Soll, Hinweistext), eingebettete sortierte Wohnungsliste (Etage, dann
Nummer numerisch) mit Status/Mieter/Warmmiete/Bearbeiten, „+ Wohnung"
direkt pro Haus (festesHaus-Prop → Haus-Select vorbelegt und disabled,
Dialogtitel passt sich an). Globaler „Neue Wohnung"-Button oben bleibt mit
freier Hauswahl. Verwaiste Wohnungen (Haus existiert nicht) werden separat
mit Korrekturhinweis gelistet. Server: unverändert – POST/PATCH /api/units
prüft bereits, dass das Ziel-Haus in buildings existiert.
**Erledigt 04.10. (v1.14):** Steuermodul „Steuer & Kosten" (/kosten, Nav zwischen
Zählerstände und Dokumente, für beide Rollen sichtbar). Neue Tabelle `costs`
(building NOT NULL → buildings.name, unit_id NULL = hausweite Position, jahr,
+11
View File
@@ -153,6 +153,17 @@ CREATE TABLE IF NOT EXISTS costs (
);
CREATE INDEX IF NOT EXISTS idx_costs_building ON costs(building);
CREATE INDEX IF NOT EXISTS idx_costs_unit ON costs(unit_id);
-- FK hausweit: Umbenennung eines Hauses muss costs.building automatisch
-- mitziehen (sonst bricht jede Haus-Umbenennung mit FK-Verletzung ab,
-- sobald Kostenpositionen existieren). Constraint idempotent neu setzen.
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'costs_building_fkey') THEN
ALTER TABLE costs DROP CONSTRAINT costs_building_fkey;
END IF;
ALTER TABLE costs ADD CONSTRAINT costs_building_fkey
FOREIGN KEY (building) REFERENCES buildings(name) ON UPDATE CASCADE;
END $$;
`
async function init() {
+139 -103
View File
@@ -1,5 +1,5 @@
import { useMemo, useState } from 'react'
import { Building2, Home, MapPin, CalendarDays, ArrowUpDown, LayoutGrid, Ruler, Pencil, Plus, Users } from 'lucide-react'
import { Building2, Home, MapPin, CalendarDays, ArrowUpDown, LayoutGrid, Ruler, Pencil, Plus, Users, DoorOpen } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
@@ -102,7 +102,7 @@ function HausDialog({ haus }: { haus?: Building }) {
<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</Label>
<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">
@@ -127,11 +127,11 @@ function HausDialog({ haus }: { haus?: Building }) {
)
}
function WohnungDialog({ wohnung, gebaeude }: { wohnung?: Unit; gebaeude: string[] }) {
function WohnungDialog({ wohnung, gebaeude, festesHaus }: { wohnung?: Unit; gebaeude: string[]; festesHaus?: string }) {
const { addWohnung, updateWohnung } = useStore()
const bearbeiten = Boolean(wohnung)
const [open, setOpen] = useState(false)
const [building, setBuilding] = useState(wohnung?.building ?? gebaeude[0] ?? '')
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('.', ',') : '')
@@ -170,20 +170,26 @@ function WohnungDialog({ wohnung, gebaeude }: { wohnung?: Unit; gebaeude: string
<Pencil className="h-3.5 w-3.5" /> Bearbeiten
</Button>
) : (
<Button className="gap-2">
<Plus className="h-4 w-4" /> Neue Wohnung
<Button size="sm" className="gap-1.5">
<Plus className="h-3.5 w-3.5" /> Wohnung
</Button>
)}
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{bearbeiten ? `Wohnung ${wohnung!.building}, Whg. ${wohnung!.number} bearbeiten` : 'Neue Wohnung anlegen'}</DialogTitle>
<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">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label>Haus</Label>
<Select value={building} onValueChange={setBuilding}>
<Select value={building} onValueChange={setBuilding} disabled={Boolean(festesHaus)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{gebaeude.map((g) => (
@@ -191,6 +197,7 @@ function WohnungDialog({ wohnung, gebaeude }: { wohnung?: Unit; gebaeude: string
))}
</SelectContent>
</Select>
{festesHaus && <p className="text-xs text-slate-400">Die Wohnung gehört fest zu diesem Haus.</p>}
</div>
<div className="space-y-1.5">
<Label htmlFor="w-nr">Wohnungsnummer</Label>
@@ -250,36 +257,47 @@ function WohnungDialog({ wohnung, gebaeude }: { wohnung?: Unit; gebaeude: string
)
}
/** 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 }),
)
}
export default function Bestand() {
const { buildings, units } = useStore()
const [filterHaus, setFilterHaus] = useState('alle')
const gebaeudeNamen = useMemo(
() => buildings.map((b) => b.name).sort(),
[buildings],
)
const sichtbareWohnungen = useMemo(
() =>
units
.filter((u) => filterHaus === 'alle' || u.building === filterHaus)
.sort((a, b) => a.building.localeCompare(b.building) || a.number.localeCompare(b.number, 'de-DE', { numeric: true })),
[units, filterHaus],
)
const anzahlJeHaus = useMemo(() => {
const z: Record<string, number> = {}
for (const u of units) z[u.building] = (z[u.building] || 0) + 1
return z
// 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],
)
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">
Bestand pflegen – Häuser anlegen und Wohnungen verwalten
Bestand pflegen – jedes Haus besteht aus mehreren Wohneinheiten, die hier
zugeordnet angelegt und bearbeitet werden
</p>
</div>
<div className="flex flex-wrap gap-2">
@@ -288,18 +306,21 @@ export default function Bestand() {
</div>
</div>
{/* ── Häuser ── */}
<section className="space-y-3">
<h2 className="text-lg font-semibold">Häuser</h2>
{buildings.length === 0 ? (
<div className="rounded-2xl border border-dashed bg-white p-10 text-center text-sm text-slate-500">
<Building2 className="mx-auto mb-3 h-8 w-8 text-slate-300" />
Noch keine Häuser angelegt.
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
{buildings.map((h) => (
<div key={h.name} className="rounded-2xl border bg-white p-5 shadow-sm">
{buildings.length === 0 ? (
<div className="rounded-2xl border border-dashed bg-white p-10 text-center text-sm text-slate-500">
<Building2 className="mx-auto mb-3 h-8 w-8 text-slate-300" />
Noch keine Häuser angelegt – legen Sie zuerst ein Haus an, dann dessen Wohneinheiten.
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
{buildings.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 className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2">
<span className="rounded-xl bg-slate-100 p-2">
@@ -327,87 +348,102 @@ export default function Bestand() {
<Ruler className="h-3.5 w-3.5 shrink-0" /> Etagenhöhe
</div>
<dd className="text-right font-medium text-slate-800">{h.etagenhoehe.toLocaleString('de-DE')} m</dd>
<div className="flex items-center gap-1.5 text-slate-500">
<LayoutGrid className="h-3.5 w-3.5 shrink-0" /> Wohneinheiten
</div>
<dd className="text-right font-medium text-slate-800">
{h.wohneinheiten}
<span className="text-slate-400"> (angelegt: {anzahlJeHaus[h.name] ?? 0})</span>
</dd>
<div className="flex items-center gap-1.5 text-slate-500">
<Home className="h-3.5 w-3.5 shrink-0" /> Wohnfläche
</div>
<dd className="text-right font-medium text-slate-800">{h.quadratmeter.toLocaleString('de-DE')} m²</dd>
</dl>
</div>
))}
</div>
)}
</section>
{/* ── Wohnungen ── */}
<section className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<h2 className="text-lg font-semibold">Wohnungen</h2>
<Select value={filterHaus} onValueChange={setFilterHaus}>
<SelectTrigger className="h-9 w-auto min-w-40"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="alle">Alle Häuser</SelectItem>
{gebaeudeNamen.map((g) => (
<SelectItem key={g} value={g}>{g}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{sichtbareWohnungen.length === 0 ? (
<div className="rounded-2xl border border-dashed bg-white p-10 text-center text-sm text-slate-500">
<Home className="mx-auto mb-3 h-8 w-8 text-slate-300" />
Keine Wohnungen für diesen Filter.
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
{sichtbareWohnungen.map((u) => (
<div key={u.id} className="rounded-2xl border bg-white p-5 shadow-sm">
<div className="flex items-start justify-between gap-2">
<div>
<div className="font-semibold">
{u.building}, Whg. {u.number}
</div>
<div className="mt-0.5 flex items-center gap-1 text-xs text-slate-500">
<Building2 className="h-3 w-3" /> Etage {u.floor} · {u.size.toLocaleString('de-DE')} m²
</div>
</div>
<Badge
className={u.status === 'vermietet' ? 'bg-emerald-100 text-emerald-800' : 'bg-slate-100 text-slate-600'}
variant="secondary"
>
{u.status}
</Badge>
</div>
<dl className="mt-3 space-y-1.5 text-xs">
<div className="flex justify-between">
<dt className="text-slate-500">Kaltmiete</dt>
<dd className="font-medium text-slate-800">{fmtEUR.format(u.coldRent)}</dd>
</div>
<div className="flex justify-between">
<dt className="text-slate-500">Warmmiete</dt>
<dd className="font-medium text-slate-800">{fmtEUR.format(u.warmRent)}</dd>
</div>
<div className="flex justify-between">
<dt className="flex items-center gap-1 text-slate-500">
<Users className="h-3.5 w-3.5" /> Mieter
</dt>
<dd className="font-medium text-slate-800">{u.tenant ?? '–'}</dd>
</div>
</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">
<LayoutGrid className="h-3.5 w-3.5" /> Wohneinheiten
</span>
<span className={`font-semibold ${voll ? 'text-teal-700' : ueberSoll ? 'text-amber-600' : 'text-slate-700'}`}>
{angelegt} von {h.wohneinheiten} angelegt
</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-slate-100">
<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">Achtung: mehr Wohnungen angelegt als im Soll vorgesehen.</p>
)}
</div>
{/* Wohneinheiten des Hauses (1:n) */}
<div className="mt-4 border-t pt-3">
<div className="mb-2 flex items-center justify-between">
<h3 className="text-xs font-semibold uppercase tracking-wide text-slate-500">
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">
<DoorOpen className="mx-auto mb-2 h-6 w-6 text-slate-300" />
Noch keine Wohnungen – über „+ Wohnung" die erste Einheit anlegen.
</div>
) : (
<ul className="space-y-2">
{wohnungen.map((u) => (
<li key={u.id} className="rounded-xl border bg-slate-50/60 px-3 py-2">
<div className="flex items-center justify-between gap-2">
<div className="text-sm font-semibold text-slate-800">
Whg. {u.number}
<span className="ml-1.5 text-xs font-normal text-slate-400">
Etage {u.floor} · {u.size.toLocaleString('de-DE')} m²
</span>
</div>
<Badge
className={u.status === 'vermietet' ? 'bg-emerald-100 text-emerald-800' : 'bg-slate-200 text-slate-600'}
variant="secondary"
>
{u.status}
</Badge>
</div>
<div className="mt-1 flex items-center justify-between gap-2 text-xs text-slate-500">
<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">{fmtEUR.format(u.warmRent)}</span>
<WohnungDialog wohnung={u} gebaeude={gebaeudeNamen} festesHaus={h.name} />
</span>
</div>
</li>
))}
</ul>
)}
</div>
</div>
)
})}
</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">
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">
<div className="font-semibold">{u.building}, Whg. {u.number}</div>
<div className="mt-2">
<WohnungDialog wohnung={u} gebaeude={gebaeudeNamen} />
</div>
</div>
))}
</div>
)}
</section>
</section>
)}
</div>
)
}