v1.26: Haeuser ohne Namensfeld, Adresse in Einzelfelder, Flaechen/Lage, Suche + Listenansicht
This commit is contained in:
@@ -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<name|adresse>.
|
||||
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
|
||||
|
||||
@@ -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,
|
||||
|
||||
+77
-20
@@ -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}`))
|
||||
}
|
||||
|
||||
+8
-3
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -1,2 +0,0 @@
|
||||
zr-1791124278944-zubgzl
|
||||
zr-1791124279087-8qlrvl
|
||||
@@ -1 +0,0 @@
|
||||
{"benutzername":"j.hinterstein","passwortHash":"c57a5df4f97ed6914116e5fd1348406064834f22503d890eb571af442ac4b4c3"}
|
||||
Reference in New Issue
Block a user