diff --git a/kimi.md b/kimi.md index 833ec1a..e4ef26d 100644 --- a/kimi.md +++ b/kimi.md @@ -189,6 +189,22 @@ tar -czf mp-dist.tar.gz -C dist . ## 8. Verlauf / offene Punkte +**Erledigt 04.10. (v1.26):** Haus-Formular ohne Namensfeld, Adresse in Einzelfelder. +buildings um strasse/hausnummer/plz/ort/lage/wohnflaeche/grundstuecksflaeche +erweitert (7 idempotente ALTERs, quadratmeter bleibt Alt-Spalte); name (PK) und +adresse (Anzeige) werden serverseitig aus Straße+Hausnummer bzw. komplett +abgeleitet (hausNameAus/hausAdresseAus, POST + PATCH). Start-Migration +haeuserFelderMigrieren zerlegt Alt-Adressen („Straße Nr., PLZ Ort") via +adresseZerlegen und zieht wohnflaeche aus quadratmeter nach (Log-Zeile +„Häuser: N Adresse(n) in Einzelfelder zerlegt"). pruefeHaus validiert PLZ +(5 Ziffern), Pflicht: Straße/Hausnummer/PLZ/Ort/Baujahr/Stockwerke/Etagenhöhe/ +Anzahl Wohneinheiten/Wohnfläche; Lage + Grundstücksfläche optional. +Haus-Formular einspaltig in Abschnitten (Adresse/Gebäude/Flächen), Karte und +Liste zeigen h.adresse als Überschrift; NEU: Suche (Adresse/Ort/PLZ/Lage) und +Listenansicht (Tabelle, Zeilenklick öffnet Wohneinheiten, Kosten-Selects +zeigen b.adresse). Typ: Building (neu) + HausForm = Omit. +Anleitung aktualisiert. + **Erledigt 04.10. (v1.25):** Zählerverlauf + Anomalitätserkennung. Neue Tabelle meter_readings (seq BIGSERIAL als Sortierung – erstellt_am ist Text und sortiert lexikografisch falsch!): jeder erfasste Stand bleibt als Eintrag bestehen diff --git a/server/src/db.js b/server/src/db.js index 11067bd..8d810ff 100644 --- a/server/src/db.js +++ b/server/src/db.js @@ -164,6 +164,14 @@ CREATE TABLE IF NOT EXISTS buildings ( wohneinheiten INT NOT NULL, -- Anzahl Wohneinheiten (Soll) quadratmeter NUMERIC(9,1) NOT NULL -- Gesamtwohnfläche in m² ); +-- v1.26: Adresse in Einzelfelder aufgeteilt, Flächen aufgeteilt, optionale Lage +ALTER TABLE buildings ADD COLUMN IF NOT EXISTS strasse TEXT NOT NULL DEFAULT ''; +ALTER TABLE buildings ADD COLUMN IF NOT EXISTS hausnummer TEXT NOT NULL DEFAULT ''; +ALTER TABLE buildings ADD COLUMN IF NOT EXISTS plz TEXT NOT NULL DEFAULT ''; +ALTER TABLE buildings ADD COLUMN IF NOT EXISTS ort TEXT NOT NULL DEFAULT ''; +ALTER TABLE buildings ADD COLUMN IF NOT EXISTS lage TEXT NOT NULL DEFAULT ''; +ALTER TABLE buildings ADD COLUMN IF NOT EXISTS wohnflaeche NUMERIC(9,1); +ALTER TABLE buildings ADD COLUMN IF NOT EXISTS grundstuecksflaeche NUMERIC(9,1); CREATE TABLE IF NOT EXISTS costs ( id TEXT PRIMARY KEY, diff --git a/server/src/index.js b/server/src/index.js index 4d20fe1..86a9757 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -185,9 +185,15 @@ const mapMeter = (m) => ({ letzteAnomalie: m.letzte_anomalie === undefined ? undefined : Boolean(m.letzte_anomalie), }) const mapBuilding = (b) => ({ - name: b.name, adresse: b.adresse, baujahr: b.baujahr, stockwerke: b.stockwerke, + name: b.name, adresse: b.adresse, + strasse: b.strasse ?? '', hausnummer: b.hausnummer ?? '', + plz: b.plz ?? '', ort: b.ort ?? '', lage: b.lage ?? '', + baujahr: b.baujahr, stockwerke: b.stockwerke, etagenhoehe: Number(b.etagenhoehe), wohneinheiten: b.wohneinheiten, - quadratmeter: Number(b.quadratmeter), + // Fallback: Bestand vor v1.26 trägt die Wohnfläche noch in quadratmeter + wohnflaeche: Number(b.wohnflaeche ?? b.quadratmeter), + grundstuecksflaeche: b.grundstuecksflaeche === null || b.grundstuecksflaeche === undefined + ? null : Number(b.grundstuecksflaeche), }) const mapKosten = (k) => ({ id: k.id, building: k.building, unitId: k.unit_id, jahr: k.jahr, @@ -1047,20 +1053,33 @@ app.post('/api/belege/:id/analysieren', auth, adminOnly, async (req, res, next) }) // ── Bestand: Häuser & Wohnungen (Verwaltung) ──────────────── +/** Name/Anzeigeadresse werden aus den Einzelfeldern zusammengesetzt (v1.26). */ +function hausNameAus(b) { + return `${String(b.strasse || '').trim()} ${String(b.hausnummer || '').trim()}`.trim() +} +function hausAdresseAus(b) { + return `${hausNameAus(b)}, ${String(b.plz || '').trim()} ${String(b.ort || '').trim()}`.trim() +} + function pruefeHaus(b) { const fehler = [] const baujahr = Number(b.baujahr) const stockwerke = Number(b.stockwerke) const etagenhoehe = Number(b.etagenhoehe) const wohneinheiten = Number(b.wohneinheiten) - const quadratmeter = Number(b.quadratmeter) - if (!String(b.name || '').trim()) fehler.push('Hausname fehlt.') - if (!String(b.adresse || '').trim()) fehler.push('Adresse fehlt.') + const wohnflaeche = Number(b.wohnflaeche) + const grund = b.grundstuecksflaeche === null || b.grundstuecksflaeche === undefined || b.grundstuecksflaeche === '' + ? null : Number(b.grundstuecksflaeche) + if (!String(b.strasse || '').trim()) fehler.push('Straße fehlt.') + if (!String(b.hausnummer || '').trim()) fehler.push('Hausnummer fehlt.') + if (!/^\d{5}$/.test(String(b.plz || '').trim())) fehler.push('PLZ ungültig (5 Ziffern).') + if (!String(b.ort || '').trim()) fehler.push('Ort fehlt.') if (!Number.isInteger(baujahr) || baujahr < 1500 || baujahr > new Date().getFullYear() + 2) fehler.push('Baujahr ungültig.') - if (!Number.isInteger(stockwerke) || stockwerke < 0 || stockwerke > 50) fehler.push('Stockwerke ungültig.') + if (!Number.isInteger(stockwerke) || stockwerke < 0 || stockwerke > 50) fehler.push('Anzahl Stockwerke ungültig.') if (!Number.isFinite(etagenhoehe) || etagenhoehe <= 0 || etagenhoehe > 10) fehler.push('Etagenhöhe ungültig.') - if (!Number.isInteger(wohneinheiten) || wohneinheiten < 0) fehler.push('Wohneinheiten ungültig.') - if (!Number.isFinite(quadratmeter) || quadratmeter <= 0) fehler.push('Quadratmeter ungültig.') + if (!Number.isInteger(wohneinheiten) || wohneinheiten < 0) fehler.push('Anzahl Wohneinheiten ungültig.') + if (!Number.isFinite(wohnflaeche) || wohnflaeche <= 0) fehler.push('Wohnfläche ungültig.') + if (grund !== null && (!Number.isFinite(grund) || grund <= 0)) fehler.push('Grundstücksfläche ungültig.') return fehler } @@ -1069,14 +1088,18 @@ app.post('/api/buildings', auth, adminOnly, async (req, res, next) => { const b = req.body || {} const fehler = pruefeHaus(b) if (fehler.length) return res.status(400).json({ fehler: fehler.join(' ') }) - const name = String(b.name).trim() + const name = hausNameAus(b) const vorhanden = await pool.query('SELECT 1 FROM buildings WHERE name = $1', [name]) - if (vorhanden.rows.length) return res.status(400).json({ fehler: 'Dieses Haus existiert bereits.' }) + if (vorhanden.rows.length) return res.status(400).json({ fehler: 'Ein Haus mit dieser Straße und Hausnummer existiert bereits.' }) + const grund = b.grundstuecksflaeche ? Number(b.grundstuecksflaeche) : null const { rows } = await pool.query( - `INSERT INTO buildings (name, adresse, baujahr, stockwerke, etagenhoehe, wohneinheiten, quadratmeter) - VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING *`, - [name, String(b.adresse).trim(), Number(b.baujahr), Number(b.stockwerke), - Number(b.etagenhoehe), Number(b.wohneinheiten), Number(b.quadratmeter)], + `INSERT INTO buildings (name, adresse, strasse, hausnummer, plz, ort, lage, + baujahr, stockwerke, etagenhoehe, wohneinheiten, wohnflaeche, grundstuecksflaeche) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) RETURNING *`, + [name, hausAdresseAus(b), String(b.strasse).trim(), String(b.hausnummer).trim(), + String(b.plz).trim(), String(b.ort).trim(), String(b.lage || '').trim(), + Number(b.baujahr), Number(b.stockwerke), Number(b.etagenhoehe), + Number(b.wohneinheiten), Number(b.wohnflaeche), grund], ) res.json({ building: mapBuilding(rows[0]) }) } catch (e) { next(e) } @@ -1090,19 +1113,25 @@ app.patch('/api/buildings/:name', auth, adminOnly, async (req, res, next) => { const b = { ...alt, ...(req.body || {}) } const fehler = pruefeHaus(b) if (fehler.length) return res.status(400).json({ fehler: fehler.join(' ') }) - const neuName = String(b.name).trim() + // Name folgt immer Straße + Hausnummer; die Anzeigeadresse wird neu zusammengesetzt + const neuName = hausNameAus(b) if (neuName !== alt.name) { const vorhanden = await pool.query('SELECT 1 FROM buildings WHERE name = $1', [neuName]) - if (vorhanden.rows.length) return res.status(400).json({ fehler: 'Dieser Hausname ist bereits vergeben.' }) + if (vorhanden.rows.length) return res.status(400).json({ fehler: 'Ein Haus mit dieser Straße und Hausnummer existiert bereits.' }) } + const grund = b.grundstuecksflaeche ? Number(b.grundstuecksflaeche) : null const client = await pool.connect() try { await client.query('BEGIN') await client.query( - `UPDATE buildings SET name = $1, adresse = $2, baujahr = $3, stockwerke = $4, - etagenhoehe = $5, wohneinheiten = $6, quadratmeter = $7 WHERE name = $8`, - [neuName, String(b.adresse).trim(), Number(b.baujahr), Number(b.stockwerke), - Number(b.etagenhoehe), Number(b.wohneinheiten), Number(b.quadratmeter), req.params.name], + `UPDATE buildings SET name = $1, adresse = $2, strasse = $3, hausnummer = $4, plz = $5, ort = $6, lage = $7, + baujahr = $8, stockwerke = $9, etagenhoehe = $10, wohneinheiten = $11, + wohnflaeche = $12, grundstuecksflaeche = $13 + WHERE name = $14`, + [neuName, hausAdresseAus(b), String(b.strasse).trim(), String(b.hausnummer).trim(), + String(b.plz).trim(), String(b.ort).trim(), String(b.lage || '').trim(), + Number(b.baujahr), Number(b.stockwerke), Number(b.etagenhoehe), Number(b.wohneinheiten), + Number(b.wohnflaeche), grund, req.params.name], ) // Haus umbenannt → Bezüge in Wohnungen und Zählern mitziehen if (neuName !== alt.name) { @@ -1471,6 +1500,33 @@ app.use((err, _req, res, _next) => { }) // ── Start ──────────────────────────────────────────────────── +/** Zerlegt „Straße Nr., PLZ Ort" in Einzelfelder (Best-Effort-Migration v1.26). */ +function adresseZerlegen(adresse) { + const teile = { strasse: '', hausnummer: '', plz: '', ort: '' } + const linksRechts = String(adresse).split(',') + const rechts = (linksRechts[1] || '').trim() + const plzMatch = /(\d{5})\s*(.*)/.exec(rechts) + if (plzMatch) { teile.plz = plzMatch[1]; teile.ort = plzMatch[2].trim() } + const links = (linksRechts[0] || '').trim().split(/\s+/) + teile.hausnummer = links.length > 1 ? links.pop() : '' + teile.strasse = links.join(' ') + return teile +} + +async function haeuserFelderMigrieren() { + const { rows } = await pool.query("SELECT * FROM buildings WHERE strasse = '' AND adresse <> ''") + for (const b of rows) { + const t = adresseZerlegen(b.adresse) + await pool.query( + 'UPDATE buildings SET strasse = $1, hausnummer = $2, plz = $3, ort = $4 WHERE name = $5', + [t.strasse, t.hausnummer, t.plz, t.ort, b.name], + ) + } + // Wohnfläche aus Alt-Feld quadratmeter nachziehen + await pool.query('UPDATE buildings SET wohnflaeche = quadratmeter WHERE wohnflaeche IS NULL') + if (rows.length) console.log(`[api] Häuser: ${rows.length} Adresse(n) in Einzelfelder zerlegt`) +} + async function warteAufDatenbank() { let letzterFehler = null for (let versuch = 1; versuch <= 30; versuch++) { @@ -1498,6 +1554,7 @@ async function start() { if (haeuserGeseedet) console.log('[api] Haus-Demo-Daten eingespielt') const kostenGeseedet = await seedeKosten() if (kostenGeseedet) console.log('[api] Kosten-Demo-Daten eingespielt') + await haeuserFelderMigrieren() setInterval(sessionsAufraeumen, 60 * 60 * 1000).unref() app.listen(PORT, () => console.log(`[api] mp-api lauscht auf :${PORT}`)) } diff --git a/server/src/seed.js b/server/src/seed.js index eab164b..e053b98 100644 --- a/server/src/seed.js +++ b/server/src/seed.js @@ -139,10 +139,15 @@ async function seedeHaeuser() { const { rows } = await pool.query('SELECT COUNT(*)::int AS n FROM buildings') if (rows[0].n > 0 || !(await demoBestandVorhanden())) return false for (const h of buildings) { + const teile = String(h.adresse).trim().split(/\s+/) + const hausnummer = teile.length > 1 ? teile.pop() : '' + const strasse = teile.join(' ') await pool.query( - `INSERT INTO buildings (name, adresse, baujahr, stockwerke, etagenhoehe, wohneinheiten, quadratmeter) - VALUES ($1,$2,$3,$4,$5,$6,$7)`, - [h.name, h.adresse, h.baujahr, h.stockwerke, h.etagenhoehe, h.wohneinheiten, h.quadratmeter], + `INSERT INTO buildings (name, adresse, strasse, hausnummer, plz, ort, lage, + baujahr, stockwerke, etagenhoehe, wohneinheiten, quadratmeter, wohnflaeche) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)`, + [h.name, h.adresse, strasse, hausnummer, '', '', '', + h.baujahr, h.stockwerke, h.etagenhoehe, h.wohneinheiten, h.quadratmeter, h.quadratmeter], ) } return true diff --git a/src/data/anleitung.ts b/src/data/anleitung.ts index 182c702..9be76f6 100644 --- a/src/data/anleitung.ts +++ b/src/data/anleitung.ts @@ -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', diff --git a/src/pages/Bestand.tsx b/src/pages/Bestand.tsx index bc822bd..f6f7be8 100644 --- a/src/pages/Bestand.tsx +++ b/src/pages/Bestand.tsx @@ -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 ( +
+

+ {titel} +

+ {children} +
+ ) +} + +function Feld({ id, label, hint, ...input }: { + id: string + label: string + hint?: string +} & React.InputHTMLAttributes) { + return ( +
+ + + {hint &&

{hint}

} +
+ ) +} + 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(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 }) { )} - + - {bearbeiten ? `Haus „${haus!.name}" bearbeiten` : 'Neues Haus anlegen'} + + {bearbeiten ? `Haus „${haus!.adresse}" bearbeiten` : 'Neues Haus anlegen'} +
-
-
- - setName(e.target.value)} /> -
-
- - setAdresse(e.target.value)} /> -
-
-
-
- - setBaujahr(e.target.value)} /> -
-
- - setStockwerke(e.target.value)} /> -
-
-
-
- - setEtagenhoehe(e.target.value)} /> -
-
- - setWohneinheiten(e.target.value)} /> -
-
- - setQuadratmeter(e.target.value)} /> -
-
+

+ Hausname und Adresszeile werden automatisch aus Straße und Hausnummer gebildet. +

+ + setStrasse(e.target.value)} /> + setHausnummer(e.target.value)} /> + setPlz(e.target.value)} /> + setOrt(e.target.value)} /> + setLage(e.target.value)} /> + + + setBaujahr(e.target.value)} /> + setStockwerke(e.target.value)} /> + setEtagenhoehe(e.target.value)} /> + setWohneinheiten(e.target.value)} /> + + + setWohnflaeche(e.target.value)} /> + setGrundstuecksflaeche(e.target.value)} /> + {fehler && (
{fehler} @@ -118,7 +149,7 @@ function HausDialog({ haus }: { haus?: Building }) {
- @@ -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 ( +
  • +
    +
    + Whg. {u.number} + + Etage {u.floor} · {u.size.toLocaleString('de-DE')} m² + +
    + + {u.status} + +
    +
    + + {u.tenant ?? '–'} + + + {fmtEUR.format(u.warmRent)} + + +
    +
  • + ) +} + +/** Fortschrittsbalken „angelegte Wohneinheiten gegen Soll". */ +function BelegungsFortschritt({ angelegt, soll }: { angelegt: number; soll: number }) { + const voll = angelegt === soll + const ueberSoll = angelegt > soll + return ( +
    +
    + + Wohneinheiten + + + {angelegt} von {soll} angelegt + +
    +
    +
    +
    + {ueberSoll && ( +

    Achtung: mehr Wohnungen angelegt als im Soll vorgesehen.

    + )} +
    + ) +} + export default function Bestand() { const { buildings, units } = useStore() + const [suche, setSuche] = useState('') + const [ansicht, setAnsicht] = useState<'karten' | 'liste'>('karten') + const [offen, setOffen] = useState(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 (
    @@ -306,30 +408,80 @@ export default function Bestand() {
    + {/* Suche + Ansichtsumschalter */} + {buildings.length > 0 && ( +
    +
    + + setSuche(e.target.value)} + aria-label="Haus suchen" + /> +
    +
    + + +
    + {suche && ( + + {gefilterteHaeuser.length} von {buildings.length} Häusern + + )} +
    + )} + {buildings.length === 0 ? (
    Noch keine Häuser angelegt – legen Sie zuerst ein Haus an, dann dessen Wohneinheiten.
    - ) : ( + ) : gefilterteHaeuser.length === 0 ? ( +
    + + Kein Haus passt zur Suche „{suche}". +
    + ) : ansicht === 'karten' ? (
    - {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 ( -
    - {/* Haus-Kopf */} +
    + {/* Haus-Kopf: Überschrift ist die Adresse */}
    -
    {h.name}
    -
    - {h.adresse} +
    {h.adresse}
    +
    + + {h.lage || '–'} + {h.grundstuecksflaeche != null && ( + + · Grundstück {h.grundstuecksflaeche.toLocaleString('de-DE')} m² + + )}
    @@ -351,32 +503,15 @@ export default function Bestand() {
    Wohnfläche
    -
    {h.quadratmeter.toLocaleString('de-DE')} m²
    +
    {h.wohnflaeche.toLocaleString('de-DE')} m²
    - {/* Belegungsfortschritt: angelegte Wohneinheiten gegen Soll */}
    -
    - - Wohneinheiten - - - {angelegt} von {h.wohneinheiten} angelegt - -
    -
    -
    -
    - {ueberSoll && ( -

    Achtung: mehr Wohnungen angelegt als im Soll vorgesehen.

    - )} +
    {/* Wohneinheiten des Hauses (1:n) */} -
    +

    Wohneinheiten ({angelegt}) @@ -391,31 +526,7 @@ export default function Bestand() { ) : (
      {wohnungen.map((u) => ( -
    • -
      -
      - Whg. {u.number} - - Etage {u.floor} · {u.size.toLocaleString('de-DE')} m² - -
      - - {u.status} - -
      -
      - - {u.tenant ?? '–'} - - - {fmtEUR.format(u.warmRent)} - - -
      -
    • + ))}
    )} @@ -424,6 +535,93 @@ export default function Bestand() { ) })}

    + ) : ( + /* ── Listenansicht ─────────────────────────────── */ +
    + + + + + + + + + + + + + + {gefilterteHaeuser.map((h) => { + const wohnungen = wohnungenJeHaus.get(h.name) ?? [] + const angelegt = wohnungen.length + const aufgeklappt = offen === h.name + return ( + + setOffen(aufgeklappt ? null : h.name)} + > + + + + + + + + + + {aufgeklappt && ( + + + + )} + + ) + })} + +
    + AdresseLageBaujahrStockwerkeWohnflächeWohneinheitenAktion
    + {aufgeklappt ? : } + {h.adresse}{h.lage || '–'}{h.baujahr}{h.stockwerke} + {h.wohnflaeche.toLocaleString('de-DE')} m² + + h.wohneinheiten + ? 'font-medium text-amber-600 dark:text-amber-400' + : 'text-slate-600 dark:text-slate-300' + } + > + {angelegt} / {h.wohneinheiten} + + e.stopPropagation()}> + +
    +
    + + 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²`} + + +
    + {angelegt === 0 ? ( +

    + Noch keine Wohnungen angelegt. +

    + ) : ( +
      + {wohnungen.map((u) => ( + + ))} +
    + )} +
    +
    )} {verwaisteWohnungen.length > 0 && ( diff --git a/src/pages/Kosten.tsx b/src/pages/Kosten.tsx index 403fc0a..4d8fd79 100644 --- a/src/pages/Kosten.tsx +++ b/src/pages/Kosten.tsx @@ -147,8 +147,8 @@ function KostenDialog({ position, vorbefuellung, onGespeichert, triggerLabel }: {buildings.map((b) => ( - - {b.name} + + {b.adresse} ))} @@ -613,8 +613,8 @@ export default function Kosten() { Alle Häuser {buildings.map((b) => ( - - {b.name} + + {b.adresse} ))} diff --git a/src/store/store.tsx b/src/store/store.tsx index 7c2a001..ec6d388 100644 --- a/src/store/store.tsx +++ b/src/store/store.tsx @@ -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 // ── Bestand: Häuser & Wohnungen (Verwaltung) ──────────── - addHaus: (h: Omit & { name: string }) => Promise - updateHaus: (name: string, h: Partial) => Promise + addHaus: (h: HausForm) => Promise + updateHaus: (name: string, h: Partial) => Promise addWohnung: (u: { building: string number: string diff --git a/src/types/index.ts b/src/types/index.ts index 1e10d74..4de5a37 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -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 + export interface Meter { id: string unitId: string | null // null = Haus-/Allgemeinzähler diff --git a/tmp/ck.txt b/tmp/ck.txt deleted file mode 100644 index 1c6a584..0000000 --- a/tmp/ck.txt +++ /dev/null @@ -1,5 +0,0 @@ -# Netscape HTTP Cookie File -# https://curl.se/docs/http-cookies.html -# This file was generated by libcurl! Edit at your own risk. - -#HttpOnly_mieterportal.hocks.eu FALSE / TRUE 1791152848 mp_session 97a602ba67b78b15c5e73bed11b39f5b5e8ad500c4227c44e9d17c68f2df2737 diff --git a/tmp/del.txt b/tmp/del.txt deleted file mode 100644 index c3f8bc3..0000000 --- a/tmp/del.txt +++ /dev/null @@ -1,2 +0,0 @@ -zr-1791124278944-zubgzl -zr-1791124279087-8qlrvl diff --git a/tmp/login.json b/tmp/login.json deleted file mode 100644 index 3fe3a02..0000000 --- a/tmp/login.json +++ /dev/null @@ -1 +0,0 @@ -{"benutzername":"j.hinterstein","passwortHash":"c57a5df4f97ed6914116e5fd1348406064834f22503d890eb571af442ac4b4c3"} \ No newline at end of file