diff --git a/kimi.md b/kimi.md index 7009cd1..0c577cf 100644 --- a/kimi.md +++ b/kimi.md @@ -189,6 +189,18 @@ tar -czf mp-dist.tar.gz -C dist . ## 8. Verlauf / offene Punkte +**Erledigt 04.10. (v1.29):** Heizungsart-Dropdown in Haus- und Wohnungsverwaltung; +Listenansicht ist jetzt Standard in allen Modulen. DB: buildings + units je +ALTER heizungsart TEXT DEFAULT ''; Server HEIZUNGSARTEN-Liste + heizungsartValid +(leer oder Listenwert), POST/PATCH /api/buildings und /api/units persistieren +das Feld, mapBuilding/mapUnit liefern es (Fallback ''). Frontend: +HEIZUNGSARTEN/Heizungsart in types (9 gängige Formen), Select „Nicht angegeben" +Sentinel '__keine' in beiden Dialogen; Wohnungs-Neuanlage übernimmt die +Heizungsart automatisch aus dem gewählten Haus (waehleHaus). Anzeige: Haus-Karte +(dl-Zeile mit Flame-Icon, nur wenn gesetzt) + Listen-Detailzeile. +Listenansicht-Default: Bestand.tsx + Zaehler.tsx ansicht-useState 'liste'. +Anleitung aktualisiert. + **Erledigt 04.10. (v1.28):** Zählerübersicht mit Suche + Listenansicht (Analogie zur Hausübersicht v1.26): Suchfeld über Nummer/Art/Haus/Wohneinheit (mit Trefferzähler), Toggle Karten/Liste; Liste = Tabelle (Art-Badge mit Icon, diff --git a/server/src/db.js b/server/src/db.js index 8d810ff..20e7b3d 100644 --- a/server/src/db.js +++ b/server/src/db.js @@ -172,6 +172,9 @@ 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); +-- v1.29: Heizungsart (Dropdown) in Haus- und Wohnungsverwaltung +ALTER TABLE buildings ADD COLUMN IF NOT EXISTS heizungsart TEXT NOT NULL DEFAULT ''; +ALTER TABLE units ADD COLUMN IF NOT EXISTS heizungsart TEXT NOT NULL DEFAULT ''; CREATE TABLE IF NOT EXISTS costs ( id TEXT PRIMARY KEY, diff --git a/server/src/index.js b/server/src/index.js index ba7f5d2..51e822f 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -156,7 +156,7 @@ async function sessionAnlegen(res, userId, req) { const mapUnit = (u) => ({ id: u.id, building: u.building, number: u.number, floor: u.floor, size: Number(u.size), coldRent: Number(u.cold_rent), warmRent: Number(u.warm_rent), - tenant: u.tenant, status: u.status, + tenant: u.tenant, status: u.status, heizungsart: u.heizungsart ?? '', }) const mapTransaction = (t) => ({ id: t.id, unitId: t.unit_id, tenant: t.tenant, month: t.month, year: t.year, @@ -194,6 +194,7 @@ const mapBuilding = (b) => ({ wohnflaeche: Number(b.wohnflaeche ?? b.quadratmeter), grundstuecksflaeche: b.grundstuecksflaeche === null || b.grundstuecksflaeche === undefined ? null : Number(b.grundstuecksflaeche), + heizungsart: b.heizungsart ?? '', }) const mapKosten = (k) => ({ id: k.id, building: k.building, unitId: k.unit_id, jahr: k.jahr, @@ -549,6 +550,14 @@ app.patch('/api/tickets/:id', auth, adminOnly, async (req, res, next) => { // ── Zählerstände (Strom / Wasser / Gas) ───────────────────── const ZAEHLER_ARTEN = ['Strom', 'Wasser', 'Gas', 'Wärme', 'Sonstiges'] +// Gängige Heizungsformen (v1.29) – Dropdown in Haus-/Wohnungsformular +const HEIZUNGSARTEN = [ + 'Gas-Zentralheizung', 'Öl-Zentralheizung', 'Fernwärme', 'Wärmepumpe', + 'Pellets-/Holzheizung', 'Nachtspeicherheizung', 'Elektro-Direktheizung', + 'Einzelraumheizung (Ofen/Kamin)', 'Sonstiges', +] +/** Heizungsart validieren: leer (nicht angegeben) oder Listenwert. */ +const heizungsartValid = (h) => !h || HEIZUNGSARTEN.includes(h) app.post('/api/meters', auth, adminOnly, async (req, res, next) => { try { @@ -1094,14 +1103,16 @@ app.post('/api/buildings', auth, adminOnly, async (req, res, next) => { const vorhanden = await pool.query('SELECT 1 FROM buildings WHERE name = $1', [name]) 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 + if (!heizungsartValid(String(b.heizungsart || '').trim())) return res.status(400).json({ fehler: 'Ungültige Heizungsart.' }) + const heizung = String(b.heizungsart || '').trim() const { rows } = await pool.query( `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 *`, + baujahr, stockwerke, etagenhoehe, wohneinheiten, wohnflaeche, grundstuecksflaeche, heizungsart) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14) 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], + Number(b.wohneinheiten), Number(b.wohnflaeche), grund, heizung], ) res.json({ building: mapBuilding(rows[0]) }) } catch (e) { next(e) } @@ -1122,18 +1133,20 @@ app.patch('/api/buildings/:name', auth, adminOnly, async (req, res, next) => { 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 + if (!heizungsartValid(String(b.heizungsart || '').trim())) return res.status(400).json({ fehler: 'Ungültige Heizungsart.' }) + const heizung = String(b.heizungsart || '').trim() const client = await pool.connect() try { await client.query('BEGIN') await client.query( `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`, + wohnflaeche = $12, grundstuecksflaeche = $13, heizungsart = $14 + WHERE name = $15`, [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], + Number(b.wohnflaeche), grund, heizung, req.params.name], ) // Haus umbenannt → Bezüge in Wohnungen und Zählern mitziehen if (neuName !== alt.name) { @@ -1168,12 +1181,14 @@ app.post('/api/units', auth, adminOnly, async (req, res, next) => { if (!Number.isFinite(coldRent) || coldRent < 0) return res.status(400).json({ fehler: 'Kaltmiete ungültig.' }) if (!Number.isFinite(warmRent) || warmRent < 0) return res.status(400).json({ fehler: 'Warmmiete ungültig.' }) const status = u.status === 'frei' ? 'frei' : 'vermietet' + if (!heizungsartValid(String(u.heizungsart || '').trim())) return res.status(400).json({ fehler: 'Ungültige Heizungsart.' }) + const heizung = String(u.heizungsart || '').trim() const id = `u-${Date.now()}` const { rows } = await pool.query( - `INSERT INTO units (id, building, number, floor, size, cold_rent, warm_rent, tenant, status) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) RETURNING *`, + `INSERT INTO units (id, building, number, floor, size, cold_rent, warm_rent, tenant, status, heizungsart) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) RETURNING *`, [id, building, String(u.number).trim(), floor, size, coldRent, warmRent, - String(u.tenant || '').trim() || null, status], + String(u.tenant || '').trim() || null, status, heizung], ) res.json({ unit: mapUnit(rows[0]) }) } catch (e) { next(e) } @@ -1198,11 +1213,13 @@ app.patch('/api/units/:id', auth, adminOnly, async (req, res, next) => { if (!Number.isFinite(coldRent) || coldRent < 0) return res.status(400).json({ fehler: 'Kaltmiete ungültig.' }) if (!Number.isFinite(warmRent) || warmRent < 0) return res.status(400).json({ fehler: 'Warmmiete ungültig.' }) const status = u.status === 'frei' ? 'frei' : 'vermietet' + if (!heizungsartValid(String(u.heizungsart || '').trim())) return res.status(400).json({ fehler: 'Ungültige Heizungsart.' }) + const heizung = String(u.heizungsart || '').trim() const { rows: neu } = await pool.query( `UPDATE units SET building = $1, number = $2, floor = $3, size = $4, cold_rent = $5, - warm_rent = $6, tenant = $7, status = $8 WHERE id = $9 RETURNING *`, + warm_rent = $6, tenant = $7, status = $8, heizungsart = $9 WHERE id = $10 RETURNING *`, [building, String(u.number).trim(), floor, size, coldRent, warmRent, - String(u.tenant || '').trim() || null, status, req.params.id], + String(u.tenant || '').trim() || null, status, heizung, req.params.id], ) res.json({ unit: mapUnit(neu[0]) }) } catch (e) { next(e) } diff --git a/src/data/anleitung.ts b/src/data/anleitung.ts index 9be76f6..7f6aaf2 100644 --- a/src/data/anleitung.ts +++ b/src/data/anleitung.ts @@ -157,15 +157,15 @@ export const anleitungAbschnitte: AnleitungAbschnitt[] = [ punkte: [ { titel: 'Haus anlegen', - 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.', + text: 'Pflichtangaben: Straße, Hausnummer, PLZ, Ort, Baujahr, Anzahl Stockwerke, Etagenhöhe, Anzahl Wohneinheiten und Wohnfläche. Optional: Lage (z. B. Stadtteil), Grundstücksfläche und Heizungsart. 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: 'Häuser suchen & Ansicht wechseln', + text: 'Die Hausübersicht öffnet standardmäßig in der Listenansicht; der Umschalter Karten/Liste wählt die Ansicht. Über das Suchfeld lassen sich Häuser nach Adresse, Ort, PLZ oder Lage filtern; in der Liste öffnet ein Klick auf eine Zeile die Wohneinheiten des Hauses.', }, { titel: 'Wohnungen zuordnen', - text: 'Jede Wohnung gehört zu einem Haus: Etage, Wohnfläche, Kalt-/Warmmiete, Mieter und Status (vermietet/frei) je Einheit pflegen. Der Bestand erscheint anschließend im Dashboard.', + text: 'Jede Wohnung gehört zu einem Haus: Etage, Wohnfläche, Kalt-/Warmmiete, Mieter, Status (vermietet/frei) und optional Heizungsart je Einheit pflegen – bei Neuanlage wird die Heizungsart automatisch aus dem Haus übernommen. Der Bestand erscheint anschließend im Dashboard.', }, ], }, diff --git a/src/pages/Bestand.tsx b/src/pages/Bestand.tsx index f6f7be8..54280a5 100644 --- a/src/pages/Bestand.tsx +++ b/src/pages/Bestand.tsx @@ -1,7 +1,7 @@ import { Fragment, useMemo, useState } from 'react' import { Building2, Home, MapPin, CalendarDays, ArrowUpDown, LayoutGrid, Ruler, Pencil, - Plus, Users, DoorOpen, Search, List, ChevronDown, ChevronRight, + Plus, Users, DoorOpen, Search, List, ChevronDown, ChevronRight, Flame, } from 'lucide-react' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' @@ -24,6 +24,7 @@ import { } from '@/components/ui/select' import { useStore, fmtEUR } from '../store/store' import type { Building, HausForm, Unit } from '../types' +import { HEIZUNGSARTEN } from '../types' /** Komma oder Punkt als Dezimaltrenner akzeptieren. */ const zahl = (s: string) => Number(s.replace(',', '.')) @@ -71,6 +72,7 @@ function HausDialog({ haus }: { haus?: Building }) { const [grundstuecksflaeche, setGrundstuecksflaeche] = useState( haus?.grundstuecksflaeche != null ? String(haus.grundstuecksflaeche).replace('.', ',') : '', ) + const [heizungsart, setHeizungsart] = useState(haus?.heizungsart ?? '') const [sende, setSende] = useState(false) const [fehler, setFehler] = useState(null) @@ -94,6 +96,7 @@ function HausDialog({ haus }: { haus?: Building }) { wohneinheiten: zahl(wohneinheiten), wohnflaeche: zahl(wohnflaeche), grundstuecksflaeche: grundstuecksflaeche.trim() ? zahl(grundstuecksflaeche) : null, + heizungsart, } const err = bearbeiten ? await updateHaus(haus!.name, payload) : await addHaus(payload) setSende(false) @@ -136,6 +139,19 @@ function HausDialog({ haus }: { haus?: Building }) { setStockwerke(e.target.value)} /> setEtagenhoehe(e.target.value)} /> setWohneinheiten(e.target.value)} /> +
+ + +

Optional

+
setWohnflaeche(e.target.value)} /> @@ -159,7 +175,7 @@ function HausDialog({ haus }: { haus?: Building }) { } function WohnungDialog({ wohnung, gebaeude, festesHaus }: { wohnung?: Unit; gebaeude: string[]; festesHaus?: string }) { - const { addWohnung, updateWohnung } = useStore() + const { addWohnung, updateWohnung, buildings } = useStore() const bearbeiten = Boolean(wohnung) const [open, setOpen] = useState(false) const [building, setBuilding] = useState(wohnung?.building ?? festesHaus ?? gebaeude[0] ?? '') @@ -170,9 +186,19 @@ function WohnungDialog({ wohnung, gebaeude, festesHaus }: { wohnung?: Unit; geba const [warmRent, setWarmRent] = useState(wohnung ? String(wohnung.warmRent).replace('.', ',') : '') const [tenant, setTenant] = useState(wohnung?.tenant ?? '') const [status, setStatus] = useState<'vermietet' | 'frei'>(wohnung?.status ?? 'frei') + const [heizungsart, setHeizungsart] = useState(wohnung?.heizungsart ?? '') const [sende, setSende] = useState(false) const [fehler, setFehler] = useState(null) + /** Bei Neuanlage: Heizungsart der Wohneinheit aus dem gewählten Haus vorbelegen. */ + const waehleHaus = (b: string) => { + setBuilding(b) + if (!bearbeiten) { + const h = buildings.find((x) => x.name === b) + if (h?.heizungsart) setHeizungsart(h.heizungsart) + } + } + const submit = async () => { if (sende) return setSende(true) @@ -186,6 +212,7 @@ function WohnungDialog({ wohnung, gebaeude, festesHaus }: { wohnung?: Unit; geba warmRent: zahl(warmRent), tenant: tenant.trim(), status, + heizungsart, } const err = bearbeiten ? await updateWohnung(wohnung!.id, payload) : await addWohnung(payload) setSende(false) @@ -220,7 +247,7 @@ function WohnungDialog({ wohnung, gebaeude, festesHaus }: { wohnung?: Unit; geba
- {gebaeude.map((g) => ( @@ -271,6 +298,25 @@ function WohnungDialog({ wohnung, gebaeude, festesHaus }: { wohnung?: Unit; geba
+
+
+ + + {!bearbeiten && ( +

+ Wird bei Neuanlage automatisch aus dem Haus übernommen. +

+ )} +
+
{fehler && (
{fehler} @@ -356,7 +402,7 @@ function BelegungsFortschritt({ angelegt, soll }: { angelegt: number; soll: numb export default function Bestand() { const { buildings, units } = useStore() const [suche, setSuche] = useState('') - const [ansicht, setAnsicht] = useState<'karten' | 'liste'>('karten') + const [ansicht, setAnsicht] = useState<'karten' | 'liste'>('liste') const [offen, setOffen] = useState(null) const gebaeudeNamen = useMemo( @@ -504,6 +550,14 @@ export default function Bestand() { Wohnfläche
{h.wohnflaeche.toLocaleString('de-DE')} m²
+ {h.heizungsart && ( + <> +
+ Heizung +
+
{h.heizungsart}
+ + )}
@@ -599,6 +653,7 @@ export default function Bestand() { {h.wohnflaeche.toLocaleString('de-DE')} m² {h.grundstuecksflaeche != null && ` · Grundstück ${h.grundstuecksflaeche.toLocaleString('de-DE')} m²`} + {h.heizungsart && ` · Heizung: ${h.heizungsart}`}
diff --git a/src/pages/Zaehler.tsx b/src/pages/Zaehler.tsx index 226d5f8..2c7f72b 100644 --- a/src/pages/Zaehler.tsx +++ b/src/pages/Zaehler.tsx @@ -391,7 +391,7 @@ export default function Zaehler() { const [filterHaus, setFilterHaus] = useState('alle') const [filterArt, setFilterArt] = useState<'alle' | ZaehlerArt>('alle') const [suche, setSuche] = useState('') - const [ansicht, setAnsicht] = useState<'karten' | 'liste'>('karten') + const [ansicht, setAnsicht] = useState<'karten' | 'liste'>('liste') const gebaeude = useMemo( () => Array.from(new Set(meters.map((m) => m.building))).sort(), diff --git a/src/store/store.tsx b/src/store/store.tsx index bfee025..7df4feb 100644 --- a/src/store/store.tsx +++ b/src/store/store.tsx @@ -150,6 +150,7 @@ interface Store extends Bootstrap { warmRent: number tenant: string status: 'vermietet' | 'frei' + heizungsart: string }) => Promise updateWohnung: (id: string, u: Partial) => Promise // ── Dokumente (Verwaltung pflegt, Mieter lesen) ───────── diff --git a/src/types/index.ts b/src/types/index.ts index 4de5a37..a033281 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -37,6 +37,8 @@ export interface Unit { warmRent: number // € tenant: string | null status: 'vermietet' | 'frei' + /** Heizungsart der Wohnung (Dropdown, '' = nicht angegeben) */ + heizungsart: string } export interface Transaction { @@ -107,6 +109,20 @@ export interface Announcement { export type ZaehlerArt = 'Strom' | 'Wasser' | 'Gas' | 'Wärme' | 'Sonstiges' +/** Gängige Heizungsformen (Dropdown in Haus- und Wohnungsformular, v1.29). */ +export const HEIZUNGSARTEN = [ + 'Gas-Zentralheizung', + 'Öl-Zentralheizung', + 'Fernwärme', + 'Wärmepumpe', + 'Pellets-/Holzheizung', + 'Nachtspeicherheizung', + 'Elektro-Direktheizung', + 'Einzelraumheizung (Ofen/Kamin)', + 'Sonstiges', +] as const +export type Heizungsart = (typeof HEIZUNGSARTEN)[number] + export interface Building { /** Interner Schlüssel – wird serverseitig aus Straße + Hausnummer abgeleitet. */ name: string @@ -124,6 +140,8 @@ export interface Building { wohneinheiten: number // Anzahl Wohneinheiten (Soll) wohnflaeche: number // Gesamtwohnfläche in m² grundstuecksflaeche: number | null // in m², optional + /** Heizungsart des Hauses (Dropdown, '' = nicht angegeben) */ + heizungsart: string } /** Formular-Daten für Neuanlage/Bearbeitung (Name/Adresse entfallen). */