From a7b959b9fb567b70c5c31e37e07be12277a07038 Mon Sep 17 00:00:00 2001 From: ah-admin Date: Sun, 4 Oct 2026 10:26:34 +0200 Subject: [PATCH] =?UTF-8?q?v1.9:=20Z=C3=A4hler-Modul=20(Strom/Wasser/Gas)?= =?UTF-8?q?=20+=20Registrierungs-Mail=20an=20Verwaltung=20+=20Vorname/Nach?= =?UTF-8?q?name=20im=20Formular?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- kimi.md | 15 ++ server/src/db.js | 12 ++ server/src/index.js | 103 ++++++++++++- server/src/seed-data.js | 18 ++- server/src/seed.js | 28 +++- src/App.tsx | 2 + src/components/AppLayout.tsx | 2 + src/pages/Login.tsx | 28 ++-- src/pages/Zaehler.tsx | 276 +++++++++++++++++++++++++++++++++++ src/store/store.tsx | 35 +++++ src/types/index.ts | 13 ++ 11 files changed, 518 insertions(+), 14 deletions(-) create mode 100644 src/pages/Zaehler.tsx diff --git a/kimi.md b/kimi.md index 261b655..c10ee89 100644 --- a/kimi.md +++ b/kimi.md @@ -189,6 +189,21 @@ tar -czf mp-dist.tar.gz -C dist . ## 8. Verlauf / offene Punkte +**Erledigt 04.10. (v1.9):** Zähler-Modul + Registrierungs-Mail + getrennter Name. +Neue Tabelle `meters` (Zählernummer, Art Strom/Wasser/Gas/Wärme/Sonstiges, +Stand, Stand-Datum, Wohneinheit nullable, Haus, Pflege-Zeitstempel) mit +idempotentem Demo-Seed (`seedeZaehler()` läuft bei jedem Start, füllt nur bei +leerer Tabelle – auch nachträglich auf Live-DB). API: POST/PATCH /api/meters +(adminOnly), Bootstrap liefert meters (Admin: alle, Mieter: nur eigene Wohnung); +Haus wird aus der Wohneinheit abgeleitet, bei Allgemeinzählern direkt gepflegt. +Frontend: neue Seite /zaehler (Kartenraster, Filter nach Haus + Art, Admin- +Dialog zum Anlegen/Bearbeiten), Nav-Eintrag mit Gauge-Icon, Store-Aktionen +addZaehler/updateZaehler. Registrierung: neuer Mieter löst HTML-Mail an die +Verwaltung aus (Name/Benutzername/E-Mail/Wohnung, Button zur Benutzerverwaltung); +Formular jetzt mit getrennten Feldern Vorname/Nachname + Benutzername. +E2E verifiziert: Zähler-CRUD + Rechte (403 Mieter), Registrierung inkl. +Mail-Zustellung an beide Verwaltungspostfächer, Umlaute sauber. + **Erledigt 02.10. (v1.8):** Schadensmeldungen starten jetzt automatisch eine Unterhaltung (Thread „Schadensmeldung: …" mit erster Nachricht inkl. Kategorie/ Priorität, ungelesen für die Gegenseite) – Antworten darauf lösen über diff --git a/server/src/db.js b/server/src/db.js index 6b8f45a..b6ef406 100644 --- a/server/src/db.js +++ b/server/src/db.js @@ -115,6 +115,18 @@ CREATE TABLE IF NOT EXISTS sessions ( expires TIMESTAMPTZ NOT NULL ); CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id); + +CREATE TABLE IF NOT EXISTS meters ( + id TEXT PRIMARY KEY, + unit_id TEXT REFERENCES units(id), -- NULL = Haus-/Allgemeinzähler + building TEXT NOT NULL, + nummer TEXT NOT NULL, -- Zählernummer + art TEXT NOT NULL CHECK (art IN ('Strom','Wasser','Gas','Wärme','Sonstiges')), + stand NUMERIC(12,3) NOT NULL, + stand_am TEXT NOT NULL, -- Datum des abgelesenen Standes + geaendert_am TEXT NOT NULL -- letzte Pflege durch die Verwaltung +); +CREATE INDEX IF NOT EXISTS idx_meters_unit ON meters(unit_id); ` async function init() { diff --git a/server/src/index.js b/server/src/index.js index 96868dc..cc9ca57 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -9,7 +9,7 @@ const express = require('express') const crypto = require('crypto') const { pool, init, sessionsAufraeumen } = require('./db') -const { seed } = require('./seed') +const { seed, seedeZaehler } = require('./seed') const mail = require('./mail') const PORT = Number(process.env.PORT || 3000) @@ -111,6 +111,10 @@ const mapThread = (t) => ({ }) const mapCode = (c) => ({ code: c.code, einheitId: c.einheit_id, erstelltAm: c.erstellt_am, verwendetAm: c.verwendet_am ?? undefined }) const mapAnnouncement = (a) => ({ id: a.id, title: a.title, text: a.text, date: a.date, tag: a.tag }) +const mapMeter = (m) => ({ + id: m.id, unitId: m.unit_id, building: m.building, nummer: m.nummer, art: m.art, + stand: Number(m.stand), standAm: m.stand_am, geaendertAm: m.geaendert_am, +}) const deutschesDatum = () => new Date().toLocaleDateString('de-DE') const deutschesDatumZeit = () => new Date().toLocaleString('de-DE') @@ -181,6 +185,30 @@ app.post('/api/auth/register', async (req, res, next) => { String(email || '').trim(), codeRows[0].einheit_id, deutschesDatum()], ) await sessionAnlegen(res, id, req) + // Verwaltung über die neue Registrierung benachrichtigen (fire-and-forget) + const { rows: einheitRows } = await pool.query('SELECT * FROM units WHERE id = $1', [codeRows[0].einheit_id]) + const label = mail.einheitLabel(mapUnit(einheitRows[0])) + const benutzernameNeu = String(benutzername || '').trim() + const nameNeu = String(name || '').trim() + const emailNeu = String(email || '').trim() + const betreff = `Neue Mieter-Registrierung: ${nameNeu} (${label})` + const text = + `Im Mieterportal hat sich ein neuer Mieter registriert.\n\n` + + `Name: ${nameNeu}\nBenutzername: ${benutzernameNeu}\nE-Mail: ${emailNeu}\nWohnung: ${label}\nRegistriert am: ${deutschesDatumZeit()}` + const html = mail.baueHtmlMail({ + titel: 'Neue Mieter-Registrierung', + untertitel: label, + einleitung: `Im Mieterportal hat sich ein neuer Mieter registriert:`, + zeilen: [ + ['Name', nameNeu], + ['Benutzername', benutzernameNeu], + ['E-Mail', emailNeu], + ['Wohnung', label], + ['Registriert am', deutschesDatumZeit()], + ], + aktionenHtml: mail.buttonHtml('Zur Benutzerverwaltung', `${process.env.PORTAL_URL || 'https://mieterportal.hocks.eu'}/benutzer`), + }) + mail.benachrichtigeVerwaltung(betreff, text, html) res.json({ benutzer: oeffentlicherBenutzer(rows[0]) }) } catch (e) { next(e) } }) @@ -190,7 +218,7 @@ app.get('/api/bootstrap', auth, async (req, res, next) => { try { const istAdmin = req.user.rolle === 'verwaltung' const einheit = req.user.einheit_id - const [units, transactions, documents, tickets, threads, messages, announcements, users, codes] = + const [units, transactions, documents, tickets, threads, messages, announcements, users, codes, meters] = await Promise.all([ pool.query('SELECT * FROM units ORDER BY id'), istAdmin @@ -215,6 +243,9 @@ app.get('/api/bootstrap', auth, async (req, res, next) => { istAdmin ? pool.query('SELECT * FROM registration_codes ORDER BY erstellt_am DESC') : { rows: [] }, + istAdmin + ? pool.query('SELECT * FROM meters ORDER BY building, nummer') + : pool.query('SELECT * FROM meters WHERE unit_id = $1 ORDER BY nummer', [einheit]), ]) // Threads mit ihren Nachrichten zusammenführen const nachrichtenProThread = {} @@ -234,6 +265,7 @@ app.get('/api/bootstrap', auth, async (req, res, next) => { announcements: announcements.rows.map(mapAnnouncement), benutzer: users.rows.map(oeffentlicherBenutzer), codes: codes.rows.map(mapCode), + meters: meters.rows.map(mapMeter), }) } catch (e) { next(e) } }) @@ -338,6 +370,71 @@ app.patch('/api/tickets/:id', auth, adminOnly, async (req, res, next) => { } catch (e) { next(e) } }) +// ── Zählerstände (Strom / Wasser / Gas) ───────────────────── +const ZAEHLER_ARTEN = ['Strom', 'Wasser', 'Gas', 'Wärme', 'Sonstiges'] + +app.post('/api/meters', auth, adminOnly, async (req, res, next) => { + try { + const { nummer, art, stand, unitId, building } = req.body || {} + if (!String(nummer || '').trim()) return res.status(400).json({ fehler: 'Zählernummer fehlt.' }) + if (!ZAEHLER_ARTEN.includes(art)) return res.status(400).json({ fehler: 'Ungültige Zählerart.' }) + const standZahl = Number(stand) + if (!Number.isFinite(standZahl) || standZahl < 0) return res.status(400).json({ fehler: 'Ungültiger Zählerstand.' }) + // Wohnung angegeben → Haus aus der Wohnung ableiten; sonst muss das Haus im Body stehen + let einheitId = unitId ? String(unitId) : null + let haus = String(building || '').trim() + if (einheitId) { + const { rows: u } = await pool.query('SELECT building FROM units WHERE id = $1', [einheitId]) + if (!u.length) return res.status(400).json({ fehler: 'Wohnung unbekannt.' }) + haus = u[0].building + } else if (!haus) { + return res.status(400).json({ fehler: 'Haus fehlt (bei Zählern ohne Wohnung).' }) + } + const id = `z-${Date.now()}` + const { rows } = await pool.query( + `INSERT INTO meters (id, unit_id, building, nummer, art, stand, stand_am, geaendert_am) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING *`, + [id, einheitId, haus, String(nummer).trim(), art, standZahl, deutschesDatum(), deutschesDatumZeit()], + ) + res.json({ meter: mapMeter(rows[0]) }) + } catch (e) { next(e) } +}) + +app.patch('/api/meters/:id', auth, adminOnly, async (req, res, next) => { + try { + const b = req.body || {} + const { rows } = await pool.query('SELECT * FROM meters WHERE id = $1', [req.params.id]) + if (!rows.length) return res.status(404).json({ fehler: 'Zähler nicht gefunden.' }) + const m = rows[0] + const neu = { + nummer: b.nummer !== undefined ? String(b.nummer).trim() : m.nummer, + art: b.art !== undefined ? String(b.art) : m.art, + stand: b.stand !== undefined ? Number(b.stand) : Number(m.stand), + unit_id: b.unitId !== undefined ? (b.unitId || null) : m.unit_id, + building: m.building, + } + if (!neu.nummer) return res.status(400).json({ fehler: 'Zählernummer darf nicht leer sein.' }) + if (!ZAEHLER_ARTEN.includes(neu.art)) return res.status(400).json({ fehler: 'Ungültige Zählerart.' }) + if (!Number.isFinite(neu.stand) || neu.stand < 0) return res.status(400).json({ fehler: 'Ungültiger Zählerstand.' }) + if (neu.unit_id) { + const { rows: u } = await pool.query('SELECT building FROM units WHERE id = $1', [neu.unit_id]) + if (!u.length) return res.status(400).json({ fehler: 'Wohnung unbekannt.' }) + neu.building = u[0].building // Haus folgt immer der Wohnung + } else if (b.building !== undefined && String(b.building).trim()) { + neu.building = String(b.building).trim() + } + // Stand-Datum nur neu setzen, wenn sich der Stand ändert + const standGeaendert = b.stand !== undefined && neu.stand !== Number(m.stand) + const { rows: aktualisiert } = await pool.query( + `UPDATE meters SET nummer = $1, art = $2, stand = $3, unit_id = $4, building = $5, + stand_am = $6, geaendert_am = $7 + WHERE id = $8 RETURNING *`, + [neu.nummer, neu.art, neu.stand, neu.unit_id, neu.building, + standGeaendert ? deutschesDatum() : m.stand_am, deutschesDatumZeit(), req.params.id], + ) + res.json({ meter: mapMeter(aktualisiert[0]) }) + } catch (e) { next(e) } +}) // ── Nachrichten (Threads) ──────────────────────────────────── app.post('/api/threads', auth, async (req, res, next) => { try { @@ -544,6 +641,8 @@ async function start() { await warteAufDatenbank() const geseedet = await seed() if (geseedet) console.log('[api] Demo-Daten eingespielt (50 Wohnungen, 552 Buchungen, …)') + const zaehlerGeseedet = await seedeZaehler() + if (zaehlerGeseedet) console.log('[api] Zähler-Demo-Daten eingespielt') setInterval(sessionsAufraeumen, 60 * 60 * 1000).unref() app.listen(PORT, () => console.log(`[api] mp-api lauscht auf :${PORT}`)) } diff --git a/server/src/seed-data.js b/server/src/seed-data.js index 89ec9f7..14babe4 100644 --- a/server/src/seed-data.js +++ b/server/src/seed-data.js @@ -152,4 +152,20 @@ const announcements = [ { id: 'a-4', title: 'Nebenkostenabrechnungen 2025 versandt', text: 'Alle Abrechnungen liegen digital im Dokumentenbereich vor. Nachzahlungen sind bis 31.10.2026 überwiesen.', date: '31.05.2026', tag: 'Abrechnung' }, ] -module.exports = { users, registrationCodes, tickets, documents, threads, announcements } +const meters = [ + // Hauptstraße 12 (hs) – Wohnung hs-7 (Anna Berger) + { id: 'z-1', unit_id: 'hs-7', building: 'Hauptstraße 12', nummer: 'ST-0815-4711', art: 'Strom', stand: 48231.4, stand_am: '01.09.2026', geaendert_am: '01.09.2026, 10:15' }, + { id: 'z-2', unit_id: 'hs-7', building: 'Hauptstraße 12', nummer: 'WA-0815-1024', art: 'Wasser', stand: 903.812, stand_am: '01.09.2026', geaendert_am: '01.09.2026, 10:16' }, + { id: 'z-3', unit_id: 'hs-7', building: 'Hauptstraße 12', nummer: 'GA-0815-3387', art: 'Gas', stand: 22114.9, stand_am: '01.09.2026', geaendert_am: '01.09.2026, 10:17' }, + // Ringweg 3 (rw) – Wohnung rw-4 (Jonas Klein) + { id: 'z-4', unit_id: 'rw-4', building: 'Ringweg 3', nummer: 'ST-2201-8842', art: 'Strom', stand: 31877.2, stand_am: '15.09.2026', geaendert_am: '15.09.2026, 09:40' }, + { id: 'z-5', unit_id: 'rw-4', building: 'Ringweg 3', nummer: 'WA-2201-5518', art: 'Wasser', stand: 412.095, stand_am: '15.09.2026', geaendert_am: '15.09.2026, 09:41' }, + // Am Park 5 (ap) – Wohnung ap-2 + { id: 'z-6', unit_id: 'ap-2', building: 'Am Park 5', nummer: 'ST-3307-2210', art: 'Strom', stand: 15002.7, stand_am: '20.09.2026', geaendert_am: '20.09.2026, 14:05' }, + { id: 'z-7', unit_id: 'ap-2', building: 'Am Park 5', nummer: 'GA-3307-0904', art: 'Gas', stand: 9812.55, stand_am: '20.09.2026', geaendert_am: '20.09.2026, 14:06' }, + // Haus-/Allgemeinzähler ohne Wohnungsbezug + { id: 'z-8', unit_id: null, building: 'Hauptstraße 12', nummer: 'WA-HAUS-0001', art: 'Wasser', stand: 12044.6, stand_am: '01.09.2026', geaendert_am: '01.09.2026, 10:20' }, + { id: 'z-9', unit_id: null, building: 'Ringweg 3', nummer: 'ST-HAUS-0007', art: 'Strom', stand: 88420.3, stand_am: '15.09.2026', geaendert_am: '15.09.2026, 09:45' }, +] + +module.exports = { users, registrationCodes, tickets, documents, threads, announcements, meters } diff --git a/server/src/seed.js b/server/src/seed.js index eb22338..a2f59eb 100644 --- a/server/src/seed.js +++ b/server/src/seed.js @@ -5,7 +5,7 @@ */ const { pool } = require('./db') -const { users, registrationCodes, tickets, documents, threads, announcements } = require('./seed-data') +const { users, registrationCodes, tickets, documents, threads, announcements, meters } = require('./seed-data') const generated = require('../seed/generated.json') async function seed() { @@ -87,6 +87,14 @@ async function seed() { ) } + for (const z of meters) { + await client.query( + `INSERT INTO meters (id, unit_id, building, nummer, art, stand, stand_am, geaendert_am) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8)`, + [z.id, z.unit_id, z.building, z.nummer, z.art, z.stand, z.stand_am, z.geaendert_am], + ) + } + await client.query('COMMIT') return true } catch (e) { @@ -98,3 +106,21 @@ async function seed() { } module.exports = { seed } + +/** Zähler-Demo-Daten: idempotent – läuft bei jedem Start und füllt die + * Zähler-Tabelle nur, wenn sie leer ist (auch nachträglich auf bestehenden + * Datenbanken, z. B. nach dem v1.9-Update). */ +async function seedeZaehler() { + const { rows } = await pool.query('SELECT COUNT(*)::int AS n FROM meters') + if (rows[0].n > 0) return false + for (const z of meters) { + await pool.query( + `INSERT INTO meters (id, unit_id, building, nummer, art, stand, stand_am, geaendert_am) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8)`, + [z.id, z.unit_id, z.building, z.nummer, z.art, z.stand, z.stand_am, z.geaendert_am], + ) + } + return true +} + +module.exports = { seed, seedeZaehler } diff --git a/src/App.tsx b/src/App.tsx index e58ed68..56ae332 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,6 +8,7 @@ import Payments from './pages/Payments' import Tickets from './pages/Tickets' import Documents from './pages/Documents' import Messages from './pages/Messages' +import Zaehler from './pages/Zaehler' import BenutzerVerwaltung from './pages/BenutzerVerwaltung' /** Fängt Render-Fehler ab – statt schwarzem Bildschirm: Hinweis + Reload. */ @@ -80,6 +81,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/components/AppLayout.tsx b/src/components/AppLayout.tsx index 6ebc393..19ce239 100644 --- a/src/components/AppLayout.tsx +++ b/src/components/AppLayout.tsx @@ -8,6 +8,7 @@ import { UserCog, LogOut, Building2, + Gauge, } from 'lucide-react' import { useStore } from '../store/store' import ThemeToggle from './ThemeToggle' @@ -17,6 +18,7 @@ const navItems = [ { to: '/', label: 'Dashboard', icon: LayoutDashboard, end: true }, { to: '/mietkonto', label: 'Mietkonto & Zahlungen', icon: Wallet }, { to: '/schadensmeldungen', label: 'Schadensmeldungen', icon: Wrench }, + { to: '/zaehler', label: 'Zählerstände', icon: Gauge }, { to: '/dokumente', label: 'Dokumente', icon: FileText }, { to: '/nachrichten', label: 'Nachrichten', icon: MessagesSquare }, ] diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx index 149d456..425c2c9 100644 --- a/src/pages/Login.tsx +++ b/src/pages/Login.tsx @@ -32,7 +32,8 @@ export default function Login() { // Registrierung const [code, setCode] = useState('') - const [rName, setRName] = useState('') + const [rVorname, setRVorname] = useState('') + const [rNachname, setRNachname] = useState('') const [rEmail, setREmail] = useState('') const [rBn, setRBn] = useState('') const [rPw, setRPw] = useState('') @@ -53,8 +54,9 @@ export default function Login() { setFehler(null) if (rPw.length < 4) return setFehler('Das Passwort muss mindestens 4 Zeichen haben.') if (rPw !== rPw2) return setFehler('Die Passwörter stimmen nicht überein.') + if (!rVorname.trim() || !rNachname.trim()) return setFehler('Bitte Vor- und Nachname angeben.') setLade(true) - const err = await registrieren(code, rBn, rName, rEmail, rPw) + const err = await registrieren(code, rBn, `${rVorname.trim()} ${rNachname.trim()}`, rEmail, rPw) setLade(false) if (err) setFehler(err) else navigate('/') @@ -198,17 +200,23 @@ export default function Login() {
- - setRName(e.target.value)} placeholder="Vor- und Nachname" required /> + + setRVorname(e.target.value)} placeholder="Max" autoComplete="given-name" required />
- - setREmail(e.target.value)} required /> + + setRNachname(e.target.value)} placeholder="Mustermann" autoComplete="family-name" required />
-
- - setRBn(e.target.value)} autoComplete="username" required /> +
+
+ + setREmail(e.target.value)} autoComplete="email" required /> +
+
+ + setRBn(e.target.value)} autoComplete="username" required /> +
@@ -227,7 +235,7 @@ export default function Login() { )} + ) : ( + + )} + + + + {bearbeiten ? `Zähler ${meter!.nummer} bearbeiten` : 'Neuen Zähler anlegen'} + +
+
+
+ + setNummer(e.target.value)} + /> +
+
+ + +
+
+
+ + setStand(e.target.value)} + /> +
+
+
+ + +
+
+ + setBuilding(e.target.value)} + disabled={unitId !== 'haus'} + title={unitId !== 'haus' ? 'Das Haus wird aus der Wohneinheit übernommen.' : undefined} + /> +
+
+ {fehler && ( +
+ {fehler} +
+ )} +
+ + + + +
+ + ) +} + +export default function Zaehler() { + const { meters, units, role } = useStore() + const [filterHaus, setFilterHaus] = useState('alle') + const [filterArt, setFilterArt] = useState<'alle' | ZaehlerArt>('alle') + + const gebaeude = useMemo( + () => Array.from(new Set(meters.map((m) => m.building))).sort(), + [meters], + ) + + const visible = useMemo( + () => + meters.filter( + (m) => + (filterHaus === 'alle' || m.building === filterHaus) && + (filterArt === 'alle' || m.art === filterArt), + ), + [meters, filterHaus, filterArt], + ) + + const einheitLabel = (m: Meter) => { + if (!m.unitId) return 'Allgemeinzähler' + const u = units.find((x) => x.id === m.unitId) + return u ? `Whg. ${u.number}` : m.unitId + } + + return ( +
+
+
+

Zählerstände

+

+ {role === 'tenant' + ? 'Ihre Zähler: Strom, Wasser und Gas auf einen Blick' + : 'Alle Zähler des Bestands – Stände pflegen und Wohnungen zuordnen'} +

+
+ {role === 'admin' && } +
+ +
+ + +
+ + {visible.length === 0 ? ( +
+ + Keine Zähler für diesen Filter. +
+ ) : ( +
+ {visible.map((m) => { + const Icon = artIcon(m.art) + return ( +
+
+
+ + + + {m.art} +
+ {role === 'admin' && } +
+
+ {fmtStand(m.stand)} +
+
+
+ {m.nummer} +
+
+ + {m.building} · {einheitLabel(m)} +
+
+ + Stand: {m.standAm} + (gepflegt: {m.geaendertAm}) +
+
+
+ ) + })} +
+ )} +
+ ) +} diff --git a/src/store/store.tsx b/src/store/store.tsx index 4fb74bc..6b1857d 100644 --- a/src/store/store.tsx +++ b/src/store/store.tsx @@ -29,6 +29,8 @@ import type { RegistrierungsCode, Unit, Announcement, + Meter, + ZaehlerArt, } from '../types' import { apiGet, apiPost, apiPatch, ApiFehler } from '../lib/api' import { sha256, DEMO_PASSWORT } from '../lib/auth' @@ -46,6 +48,7 @@ interface Bootstrap { announcements: Announcement[] benutzer: Benutzer[] codes: RegistrierungsCode[] + meters: Meter[] } const LEER: Bootstrap = { @@ -57,6 +60,7 @@ const LEER: Bootstrap = { announcements: [], benutzer: [], codes: [], + meters: [], } interface Store extends Bootstrap { @@ -91,6 +95,18 @@ interface Store extends Bootstrap { setPasswort: (id: string, passwort: string) => Promise weiseEinheitZu: (id: string, einheitId: string | null) => Promise generiereCodes: (anzahl: number, einheitId: string | null) => Promise + // ── Zählerstände (Verwaltung pflegt, Mieter lesen) ────── + addZaehler: (z: { + nummer: string + art: ZaehlerArt + stand: number + unitId: string | null + building: string + }) => Promise + updateZaehler: ( + id: string, + z: Partial<{ nummer: string; art: ZaehlerArt; stand: number; unitId: string | null; building: string }>, + ) => Promise } const StoreContext = createContext(null) @@ -259,6 +275,25 @@ export function StoreProvider({ children }: { children: ReactNode }) { await ladeAlles() return codes }, + // ── Zählerstände ──────────────────────────────────────── + async addZaehler(z) { + try { + const { meter } = await apiPost<{ meter: Meter }>('/api/meters', z) + setDaten((d) => ({ ...d, meters: [...d.meters, meter] })) + return null + } catch (e) { + return fehlerNachricht(e) + } + }, + async updateZaehler(id, z) { + try { + const { meter } = await apiPatch<{ meter: Meter }>(`/api/meters/${id}`, z) + setDaten((d) => ({ ...d, meters: d.meters.map((m) => (m.id === id ? meter : m)) })) + return null + } catch (e) { + return fehlerNachricht(e) + } + }, }), [daten, bereit, me, role, meineEinheit, ladeAlles]) return {children} diff --git a/src/types/index.ts b/src/types/index.ts index 45df05f..4e34926 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -95,3 +95,16 @@ export interface Announcement { date: string tag: string } + +export type ZaehlerArt = 'Strom' | 'Wasser' | 'Gas' | 'Wärme' | 'Sonstiges' + +export interface Meter { + id: string + unitId: string | null // null = Haus-/Allgemeinzähler + building: string + nummer: string + art: ZaehlerArt + stand: number + standAm: string + geaendertAm: string +}