v1.15: Haus -> Wohneinheiten Struktur in der Verwaltung + Fix Haus-Umbenennung (costs FK ON UPDATE CASCADE)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user