From ca12bc9f46f13c2bf8a2aedb9846cc6a6460739c Mon Sep 17 00:00:00 2001 From: ah-admin Date: Sun, 4 Oct 2026 16:18:26 +0200 Subject: [PATCH] v1.25: Zaehlerverlauf pro Zaehler inkl. Anomalitaetserkennung mit E-Mail-Benachrichtigung --- kimi.md | 18 +++ server/src/db.js | 17 +++ server/src/index.js | 86 +++++++++++++- server/src/zaehlerVerlauf.js | 174 +++++++++++++++++++++++++++ src/data/anleitung.ts | 12 +- src/pages/Zaehler.tsx | 224 +++++++++++++++++++++++++++++++++-- src/store/store.tsx | 32 +++++ src/types/index.ts | 14 +++ 8 files changed, 561 insertions(+), 16 deletions(-) create mode 100644 server/src/zaehlerVerlauf.js diff --git a/kimi.md b/kimi.md index 2d85206..c41568f 100644 --- a/kimi.md +++ b/kimi.md @@ -189,6 +189,24 @@ tar -czf mp-dist.tar.gz -C dist . ## 8. Verlauf / offene Punkte +**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 +(quelle erstbefund/manuel/mieter, erfasst_von, anomalie + anomalie_grund); +meters.stand bleibt aktuellster Wert (Cache). Modul server/src/zaehlerVerlauf.js: +pruefeAnomalie (Ruecklauf | Datum in Zukunft | rueckdatiert | Stillstand ≥60 Tage +| Verbrauchsspitze >4× Median-Tagesrate, nur ≥2 Intervalle + Delta ≥10), +standEintragen (insert + Mail an Verwaltung bei Anomalie, HTML-Karte mit +Button), erstbefundFehlende (Start-Einmal-Backfill bestehender Zähler). +Endpunkte: GET/POST /api/meters/:id/readings (Mieter nur eigene Einheit, +meterFuer-Helper prüft), DELETE …/readings/:rid (admin, baut meters-Cache neu). +Bootstrap: letzte_anomalie via Subquery (ORDER BY seq DESC). Frontend +src/pages/Zaehler.tsx: VerlaufDialog (Liste neueste zuerst, Auffälligkeits- +Badge, Löschen für Verwaltung), StandEintragenDialog (Mieter + Verwaltung, +Warnbox bei Anomalie statt sofortigem Schließen), Anomalie-Badge auf der Karte. +Achtung Git-Bash: Pfad-Argumente an node werden von MSYS umgewandelt – +Pfade an node-Skripte OHNE führenden / übergeben. + **Erledigt 04.10. (v1.24):** Anleitungsseite /anleitung (Seite „Anleitung" im Menü, sichtbar für Mieter und Verwaltung). Inhalte rollenbasiert in src/data/anleitung.ts (Abschnitte je gruppe 'mieter'|'verwaltung'); Verwaltung diff --git a/server/src/db.js b/server/src/db.js index ac56c07..11067bd 100644 --- a/server/src/db.js +++ b/server/src/db.js @@ -138,6 +138,23 @@ CREATE TABLE IF NOT EXISTS meters ( ); CREATE INDEX IF NOT EXISTS idx_meters_unit ON meters(unit_id); +-- Zählerverlauf (v1.25): jeder abgelesene Stand bleibt als Listeneintrag +-- bestehen; meters.stand ist jeweils der aktuellste Wert (Cache). +CREATE TABLE IF NOT EXISTS meter_readings ( + id TEXT PRIMARY KEY, + seq BIGSERIAL, -- Einfügereihenfolge (verlässliche Sortierung) + meter_id TEXT NOT NULL REFERENCES meters(id) ON DELETE CASCADE, + stand NUMERIC(12,3) NOT NULL, + stand_am TEXT NOT NULL, -- ISO yyyy-mm-dd oder de-DE + erfasst_von TEXT NOT NULL DEFAULT '', -- Name des erfassenden Nutzers + quelle TEXT NOT NULL DEFAULT 'manuel', + CHECK (quelle IN ('erstbefund','manuel','mieter')), + anomalie BOOLEAN NOT NULL DEFAULT FALSE, + anomalie_grund TEXT, -- gesetzt bei Auffälligkeit + erstellt_am TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_meter_readings_meter ON meter_readings(meter_id); + CREATE TABLE IF NOT EXISTS buildings ( name TEXT PRIMARY KEY, -- eindeutiger Hausname (z. B. "Hauptstraße 12") adresse TEXT NOT NULL, diff --git a/server/src/index.js b/server/src/index.js index 1e7dd53..4d20fe1 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -15,6 +15,7 @@ const { pool, init, sessionsAufraeumen } = require('./db') const { seed, seedeZaehler, seedeHaeuser, seedeKosten } = require('./seed') const mail = require('./mail') const { erzeugeAbrechnung } = require('./abrechnung') +const zaehlerVerlauf = require('./zaehlerVerlauf') const PORT = Number(process.env.PORT || 3000) const SESSION_DAUER_MS = 8 * 60 * 60 * 1000 @@ -181,6 +182,7 @@ const mapAnnouncement = (a) => ({ id: a.id, title: a.title, text: a.text, date: 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, + 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, @@ -353,8 +355,8 @@ app.get('/api/bootstrap', auth, async (req, res, next) => { ? 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]), + ? pool.query(`SELECT *, (SELECT anomalie FROM meter_readings r WHERE r.meter_id = meters.id ORDER BY r.seq DESC LIMIT 1) AS letzte_anomalie FROM meters ORDER BY building, nummer`) + : pool.query(`SELECT *, (SELECT anomalie FROM meter_readings r WHERE r.meter_id = meters.id ORDER BY r.seq DESC LIMIT 1) AS letzte_anomalie FROM meters WHERE unit_id = $1 ORDER BY nummer`, [einheit]), istAdmin ? pool.query('SELECT * FROM buildings ORDER BY name') : { rows: [] }, @@ -565,6 +567,11 @@ app.post('/api/meters', auth, adminOnly, async (req, res, next) => { VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING *`, [id, einheitId, haus, String(nummer).trim(), art, standZahl, deutschesDatum(), deutschesDatumZeit()], ) + // Anfangsstand als erster Verlaufseintrag + await zaehlerVerlauf.standEintragen({ + meter: rows[0], stand: standZahl, standAm: deutschesDatum(), + erfasstVon: req.user.name, quelle: 'erstbefund', + }) res.json({ meter: mapMeter(rows[0]) }) } catch (e) { next(e) } }) @@ -601,9 +608,83 @@ app.patch('/api/meters/:id', auth, adminOnly, async (req, res, next) => { [neu.nummer, neu.art, neu.stand, neu.unit_id, neu.building, standGeaendert ? deutschesDatum() : m.stand_am, deutschesDatumZeit(), req.params.id], ) + // Geänderter Stand → neuer Verlaufseintrag (inkl. Anomalieprüfung + Mail) + if (standGeaendert) { + await zaehlerVerlauf.standEintragen({ + meter: aktualisiert[0], stand: neu.stand, standAm: deutschesDatum(), + erfasstVon: req.user.name, quelle: 'manuel', + }) + } res.json({ meter: mapMeter(aktualisiert[0]) }) } catch (e) { next(e) } }) + +// ── Zählerverlauf (v1.25): Liste pro Zähler + Stand eintragen ── +async function meterFuer(req, res) { + const { rows } = await pool.query('SELECT * FROM meters WHERE id = $1', [req.params.id]) + if (!rows.length) { res.status(404).json({ fehler: 'Zähler nicht gefunden.' }); return null } + const m = rows[0] + // Mieter dürfen nur die Zähler ihrer eigenen Wohneinheit sehen/pflegen + if (req.user.rolle !== 'verwaltung' && m.unit_id !== req.user.einheit_id) { + res.status(403).json({ fehler: 'Kein Zugriff auf diesen Zähler.' }) + return null + } + return m +} + +app.get('/api/meters/:id/readings', auth, async (req, res, next) => { + try { + const m = await meterFuer(req, res) + if (!m) return + const verlauf = await zaehlerVerlauf.verlaufLaden(m.id) + res.json({ readings: verlauf.map(zaehlerVerlauf.mapReading) }) + } catch (e) { next(e) } +}) + +app.post('/api/meters/:id/readings', auth, async (req, res, next) => { + try { + const m = await meterFuer(req, res) + if (!m) return + const { stand, standAm } = req.body || {} + const standZahl = Number(stand) + if (!Number.isFinite(standZahl) || standZahl < 0) return res.status(400).json({ fehler: 'Ungültiger Zählerstand.' }) + const datum = String(standAm || '').trim() || deutschesDatum() + if (!zaehlerVerlauf.parseDatum(datum)) return res.status(400).json({ fehler: 'Ungültiges Ablesedatum.' }) + const { eintrag, anomalie, grund } = await zaehlerVerlauf.standEintragen({ + meter: m, stand: standZahl, standAm: datum, + erfasstVon: req.user.name, quelle: req.user.rolle === 'verwaltung' ? 'manuel' : 'mieter', + }) + // meters.stand bleibt der aktuellste Wert (Cache für die Übersicht) + const { rows: aktualisiert } = await pool.query( + `UPDATE meters SET stand = $1, stand_am = $2, geaendert_am = $3 WHERE id = $4 RETURNING *`, + [standZahl, datum, deutschesDatumZeit(), m.id], + ) + res.json({ eintrag, anomalie, grund, meter: mapMeter(aktualisiert[0]) }) + } catch (e) { next(e) } +}) + +// Einzelnen Verlaufseintrag löschen (Korrektur falscher Erfassungen, Verwaltung) +app.delete('/api/meters/:id/readings/:rid', auth, adminOnly, async (req, res, next) => { + try { + const { rows } = await pool.query( + 'DELETE FROM meter_readings WHERE id = $1 AND meter_id = $2 RETURNING *', + [req.params.rid, req.params.id], + ) + if (!rows.length) return res.status(404).json({ fehler: 'Eintrag nicht gefunden.' }) + // meters-Cache aus dem neuesten verbleibenden Eintrag neu aufbauen + const { rows: letzter } = await pool.query( + 'SELECT stand, stand_am FROM meter_readings WHERE meter_id = $1 ORDER BY seq DESC LIMIT 1', + [req.params.id], + ) + if (letzter.length) { + await pool.query( + 'UPDATE meters SET stand = $1, stand_am = $2, geaendert_am = $3 WHERE id = $4', + [letzter[0].stand, letzter[0].stand_am, deutschesDatumZeit(), req.params.id], + ) + } + res.json({ ok: true }) + } catch (e) { next(e) } +}) // ── Dokumente (Verwaltung) ─────────────────────────────────── const DOKUMENT_KATEGORIEN = ['Mietvertrag', 'Nebenkostenabrechnung', 'Hausordnung', 'Protokoll', 'Sonstiges'] @@ -1412,6 +1493,7 @@ async function start() { 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') + await zaehlerVerlauf.erstbefundFehlende() const haeuserGeseedet = await seedeHaeuser() if (haeuserGeseedet) console.log('[api] Haus-Demo-Daten eingespielt') const kostenGeseedet = await seedeKosten() diff --git a/server/src/zaehlerVerlauf.js b/server/src/zaehlerVerlauf.js new file mode 100644 index 0000000..8f69f05 --- /dev/null +++ b/server/src/zaehlerVerlauf.js @@ -0,0 +1,174 @@ +/** Zählerverlauf + Anomalitätserkennung (v1.25). + * + * Jeder erfasste Zählerstand wird in meter_readings als eigener Eintrag + * abgelegt; meters.stand bleibt der aktuellste Wert (Cache für die Übersicht). + * + * Anomalitäten (je Eintrag in anomalie_grund vermerkt, Verwaltung per E-Mail + * benachrichtigt): + * - Ruecklauf neuer Stand liegt unter dem letzten Stand + * - Datum in Zukunft Ablesedatum liegt nach heute + * - Rueckdatiert Ablesedatum liegt vor dem vorherigen Eintrag + * - Stillstand gleicher Stand, ≥ 60 Tage seit dem Vorgaenger + * - Verbrauchsspitze Tagesverbrauch > 4× dem bisherigen Median + * (nur bei ≥ 2 vergangenen Intervallen, Mindestdelta 10) + */ + +const mail = require('./mail') + +const deutschesDatum = () => new Date().toLocaleDateString('de-DE') +const deutschesDatumZeit = () => new Date().toLocaleString('de-DE') + +/** Parst 'dd.mm.yyyy' oder 'yyyy-mm-dd' zu Date (UTC-Mittag, robust). */ +function parseDatum(s) { + if (!s) return null + const iso = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(s).trim()) + if (iso) return new Date(Date.UTC(Number(iso[1]), Number(iso[2]) - 1, Number(iso[3]), 12)) + const de = /^(\d{1,2})\.(\d{1,2})\.(\d{4})$/.exec(String(s).trim()) + if (de) return new Date(Date.UTC(Number(de[3]), Number(de[2]) - 1, Number(de[1]), 12)) + const fallback = new Date(s) + return Number.isNaN(fallback.getTime()) ? null : fallback +} + +const tageZwischen = (a, b) => Math.round((b - a) / 86400000) +const runde = (n, stellen = 1) => Math.round(n * 10 ** stellen) / 10 ** stellen + +/** Median eines numerischen Arrays (leer → null). */ +function median(werte) { + if (!werte.length) return null + const s = [...werte].sort((a, b) => a - b) + const mitte = Math.floor(s.length / 2) + return s.length % 2 ? s[mitte] : (s[mitte - 1] + s[mitte]) / 2 +} + +/** Wertet einen neuen Stand gegen den bisherigen Verlauf aus. */ +function pruefeAnomalie(verlauf, neuerStand, standAm) { + // Verlauf: [{stand, stand_am}] – älteste zuerst, ohne den neuen Wert. + if (!verlauf.length) return { anomalie: false, grund: null } + + const letzter = verlauf[verlauf.length - 1] + const neuerTag = parseDatum(standAm) + const letzterTag = parseDatum(letzter.stand_am) + const heute = new Date() + const heuteUtc = new Date(Date.UTC(heute.getFullYear(), heute.getMonth(), heute.getDate(), 12)) + + if (neuerTag) { + if (neuerTag > heuteUtc) { + return { anomalie: true, grund: 'Ablesedatum liegt in der Zukunft' } + } + if (letzterTag && neuerTag < letzterTag) { + return { anomalie: true, grund: `Ablesedatum (${standAm}) liegt vor dem vorherigen Eintrag (${letzter.stand_am})` } + } + } + + const delta = neuerStand - Number(letzter.stand) + if (delta < 0) { + return { + anomalie: true, + grund: `Rückläufiger Stand: ${runde(neuerStand)} liegt um ${runde(-delta)} unter dem letzten Stand ${runde(Number(letzter.stand))}`, + } + } + + const tage = neuerTag && letzterTag ? Math.max(1, tageZwischen(letzterTag, neuerTag)) : null + if (delta === 0 && tage !== null && tage >= 60) { + return { anomalie: true, grund: `Kein Verbrauch seit ${tage} Tagen (Stand unverändert: ${runde(neuerStand)})` } + } + + // Verbrauchsspitze: Tagesraten der bisherigen Intervalle, dann Median. + const raten = [] + for (let i = 1; i < verlauf.length; i++) { + const von = parseDatum(verlauf[i - 1].stand_am) + const bis = parseDatum(verlauf[i].stand_am) + const t = von && bis ? tageZwischen(von, bis) : 0 + const d = Number(verlauf[i].stand) - Number(verlauf[i - 1].stand) + if (t > 0 && d > 0) raten.push(d / t) + } + const med = median(raten) + if (med !== null && tage !== null && delta >= 10) { + const rate = delta / tage + if (rate > 4 * med) { + return { + anomalie: true, + grund: `Auffällig hoher Verbrauch: ca. ${runde(rate)} Einheiten/Tag, üblich sind ca. ${runde(med)} (4-facher Mittelwert überschritten)`, + } + } + } + + return { anomalie: false, grund: null } +} + +/** Lädt den Verlauf eines Zählers (älteste zuerst). */ +async function verlaufLaden(meterId) { + const { rows } = await require('./db').pool.query( + 'SELECT * FROM meter_readings WHERE meter_id = $1 ORDER BY seq', + [meterId], + ) + return rows +} + +const mapReading = (r) => ({ + id: r.id, + stand: Number(r.stand), + standAm: r.stand_am, + erfasstVon: r.erfasst_von, + quelle: r.quelle, + anomalie: r.anomalie, + anomalieGrund: r.anomalie_grund ?? null, + erstelltAm: r.erstellt_am, +}) + +/** Trägt einen Stand in den Verlauf ein, prüft auf Anomalien und + * benachrichtigt bei Auffälligkeit die Verwaltung per E-Mail. + * Gibt { eintrag, anomalie, grund } zurück. */ +async function standEintragen({ meter, stand, standAm, erfasstVon, quelle }) { + const pool = require('./db').pool + const verlauf = await verlaufLaden(meter.id) + const { anomalie, grund } = pruefeAnomalie(verlauf, stand, standAm) + const { rows } = await pool.query( + `INSERT INTO meter_readings (id, meter_id, stand, stand_am, erfasst_von, quelle, anomalie, anomalie_grund, erstellt_am) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) RETURNING *`, + [`zr-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, meter.id, stand, standAm, + erfasstVon, quelle, anomalie, grund, deutschesDatumZeit()], + ) + if (anomalie) { + const html = mail.baueHtmlMail({ + titel: 'Auffälligkeit im Zählerverlauf', + untertitel: 'Automatische Anomalitätserkennung', + einleitung: `Der Zähler ${meter.nummer} (${meter.art}) hat einen auffälligen Verlaufseintrag erhalten. Bitte prüfen Sie den erfassten Stand.`, + statusBox: { text: grund, hintergrund: '#fef2f2', farbe: '#b91c1c' }, + zeilen: [ + ['Zähler', `${meter.nummer} (${meter.art})`], + ['Haus', meter.building], + ['Neuer Stand', String(stand)], + ['Ablesedatum', standAm], + ['Erfasst von', erfasstVon], + ], + aktionenHtml: mail.buttonHtml('Zum Zähler-Modul', `${process.env.PORTAL_URL || 'https://mieterportal.hocks.eu'}/zaehler`), + }) + const text = + `Auffälligkeit beim Zähler ${meter.nummer} (${meter.art}), Haus ${meter.building}:\n` + + `${grund}\n\nNeuer Stand: ${stand} (${standAm}), erfasst von ${erfasstVon}.\n` + + `${process.env.PORTAL_URL || 'https://mieterportal.hocks.eu'}/zaehler` + mail.benachrichtigeVerwaltung(`Zähler ${meter.nummer}: ${grund}`, text, html) + .catch(() => {}) + } + return { eintrag: mapReading(rows[0]), anomalie, grund } +} + +/** Einmal-Rückbau: Bestehende Zähler ohne Verlauf erhalten einen Erstbefund. */ +async function erstbefundFehlende() { + const pool = require('./db').pool + const { rows } = await pool.query( + `SELECT m.* FROM meters m + WHERE NOT EXISTS (SELECT 1 FROM meter_readings r WHERE r.meter_id = m.id)`, + ) + for (const m of rows) { + await pool.query( + `INSERT INTO meter_readings (id, meter_id, stand, stand_am, erfasst_von, quelle, erstellt_am) + VALUES ($1,$2,$3,$4,$5,'erstbefund',$6)`, + [`zr-init-${m.id}`, m.id, m.stand, m.stand_am, 'System (Übernahme)', m.geaendert_am], + ) + } + if (rows.length) console.log(`[api] Zählerverlauf: ${rows.length} Erstbefund(e) ergänzt`) +} + +module.exports = { pruefeAnomalie, verlaufLaden, standEintragen, erstbefundFehlende, mapReading, parseDatum } diff --git a/src/data/anleitung.ts b/src/data/anleitung.ts index 628abe5..182c702 100644 --- a/src/data/anleitung.ts +++ b/src/data/anleitung.ts @@ -95,7 +95,11 @@ export const anleitungAbschnitte: AnleitungAbschnitt[] = [ punkte: [ { titel: 'Stände eintragen', - text: 'Unter „Zählerstände“ sehen Sie die Strom-, Wasser- und Gaszähler Ihrer Wohnung und tragen neue Stände mit Datum ein.', + text: 'Unter „Zählerstände“ sehen Sie die Strom-, Wasser- und Gaszähler Ihrer Wohnung. Über „Stand eintragen“ erfassen Sie einen neuen Stand mit Ablesedatum.', + }, + { + titel: 'Verlauf ansehen', + text: 'Der Button „Verlauf“ zeigt alle bisher erfassten Stände Ihrer Zähler als Liste.', }, ], }, @@ -207,7 +211,11 @@ export const anleitungAbschnitte: AnleitungAbschnitt[] = [ }, { titel: 'Zähler pflegen', - text: 'Zähler je Wohnung mit Zählernummer und Zählerart anlegen und die von Mietern erfassten Stände prüfen.', + text: 'Zähler je Wohnung mit Zählernummer und Zählerart anlegen. Mieter und Verwaltung erfassen neue Stände mit Ablesedatum – jeder Stand bleibt als Verlaufseintrag bestehen.', + }, + { + titel: 'Anomalitätserkennung', + text: 'Das Portal prüft jeden neuen Eintrag automatisch (rückläufige Stände, Stillstand über 60 Tage, ungewöhnlich hoher Verbrauch, fehlerhafte Daten). Bei Auffälligkeiten wird die Verwaltung umgehend per E-Mail benachrichtigt und der Eintrag im Verlauf markiert.', }, ], }, diff --git a/src/pages/Zaehler.tsx b/src/pages/Zaehler.tsx index dfde976..1d12fda 100644 --- a/src/pages/Zaehler.tsx +++ b/src/pages/Zaehler.tsx @@ -1,5 +1,5 @@ import { useMemo, useState } from 'react' -import { Gauge, Zap, Droplets, Flame, Building2, Hash, CalendarDays, Pencil, Plus } from 'lucide-react' +import { Gauge, Zap, Droplets, Flame, Building2, Hash, CalendarDays, Pencil, Plus, History, TriangleAlert, Trash2 } from 'lucide-react' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' import { @@ -20,7 +20,7 @@ import { SelectValue, } from '@/components/ui/select' import { useStore } from '../store/store' -import type { Meter, ZaehlerArt } from '../types' +import type { Meter, MeterReading, ZaehlerArt } from '../types' const ARTEN: ZaehlerArt[] = ['Strom', 'Wasser', 'Gas', 'Wärme', 'Sonstiges'] @@ -168,6 +168,193 @@ function ZaehlerDialog({ meter }: { meter?: Meter }) { ) } +/** Verlauf eines Zählers: alle erfassten Stände als Liste (neueste zuerst). */ +function VerlaufDialog({ meter }: { meter: Meter }) { + const { zaehlerVerlauf, zaehlerEintragLoeschen, role } = useStore() + const [open, setOpen] = useState(false) + const [liste, setListe] = useState(null) + const [lade, setLade] = useState(false) + + const laden = async () => { + setLade(true) + try { + setListe(await zaehlerVerlauf(meter.id)) + } finally { + setLade(false) + } + } + + const oeffnen = (o: boolean) => { + setOpen(o) + if (o) { setListe(null); laden() } + } + + const loeschen = async (eintragId: string) => { + const err = await zaehlerEintragLoeschen(meter.id, eintragId) + if (!err) await laden() + } + + const quelleLabel = (q: MeterReading['quelle']) => + q === 'mieter' ? 'Mieter' : q === 'erstbefund' ? 'System' : 'Verwaltung' + + return ( + + + + + + + + Verlauf · Zähler {meter.nummer} + ({meter.art}, {meter.building}) + + + {lade || liste === null ? ( +

Verlauf wird geladen …

+ ) : liste.length === 0 ? ( +

Noch keine Einträge.

+ ) : ( +
    + {[...liste].reverse().map((r) => ( +
  • +
    + + {fmtStand(r.stand)} + + + + {quelleLabel(r.quelle)} + + {r.anomalie && ( + + Auffälligkeit + + )} + {role === 'admin' && ( + + )} + +
    +
    + Ablesedatum: {r.standAm} + {r.erfasstVon && · erfasst von {r.erfasstVon}} +
    + {r.anomalie && r.anomalieGrund && ( +

    {r.anomalieGrund}

    + )} +
  • + ))} +
+ )} +

+ Auffälligkeiten (z. B. rückläufige Stände oder ungewöhnlich hoher Verbrauch) markiert das Portal + automatisch und benachrichtigt die Verwaltung per E-Mail. +

+
+
+ ) +} + +/** Neuen Zählerstand erfassen – für Mieter (eigene Zähler) und Verwaltung. */ +function StandEintragenDialog({ meter }: { meter: Meter }) { + const { zaehlerstandEintragen } = useStore() + const [open, setOpen] = useState(false) + const [stand, setStand] = useState('') + const [datum, setDatum] = useState(() => new Date().toISOString().slice(0, 10)) + const [sende, setSende] = useState(false) + const [fehler, setFehler] = useState(null) + const [warnung, setWarnung] = useState(null) + + const submit = async () => { + const standZahl = Number(stand.replace(',', '.')) + if (!Number.isFinite(standZahl) || standZahl < 0 || !datum || sende) return + setSende(true) + setFehler(null) + setWarnung(null) + const r = await zaehlerstandEintragen(meter.id, standZahl, datum) + setSende(false) + if (r.fehler) return setFehler(r.fehler) + if (r.anomalie) { + // Auffälligkeit: Eintrag bleibt bestehen, Verwaltung wurde per E-Mail informiert + setWarnung(`Auffälligkeit erkannt: ${r.grund ?? 'unbekannt'}. Die Verwaltung wurde per E-Mail benachrichtigt.`) + } else { + setOpen(false) + } + } + + return ( + + + + + + + + Stand eintragen · Zähler {meter.nummer} + (akt. Stand: {fmtStand(meter.stand)}) + + +
+
+
+ + setStand(e.target.value)} + /> +
+
+ + setDatum(e.target.value)} + /> +
+
+ {fehler && ( +
+ {fehler} +
+ )} + {warnung && ( +
+ {warnung} +
+ )} +
+ + + + +
+
+ ) +} + export default function Zaehler() { const { meters, units, role } = useStore() const [filterHaus, setFilterHaus] = useState('alle') @@ -199,10 +386,10 @@ export default function Zaehler() {

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'} + ? 'Ihre Zähler: Strom, Wasser und Gas auf einen Blick – Stände eintragen und Verlauf ansehen' + : 'Alle Zähler des Bestands – Verlauf prüfen, Auffälligkeiten erkennen und Wohnungen zuordnen'}

{role === 'admin' && } @@ -230,8 +417,8 @@ export default function Zaehler() {
{visible.length === 0 ? ( -
- +
+ Keine Zähler für diesen Filter.
) : ( @@ -239,20 +426,29 @@ export default function Zaehler() { {visible.map((m) => { const Icon = artIcon(m.art) return ( -
+
- - + + {m.art} + {m.letzteAnomalie && ( + + Auffälligkeit + + )}
{role === 'admin' && }
{fmtStand(m.stand)}
-
+
{m.nummer}
@@ -263,9 +459,13 @@ export default function Zaehler() {
Stand: {m.standAm} - (gepflegt: {m.geaendertAm}) + (gepflegt: {m.geaendertAm})
+
+ + +
) })} diff --git a/src/store/store.tsx b/src/store/store.tsx index cee8759..7c2a001 100644 --- a/src/store/store.tsx +++ b/src/store/store.tsx @@ -30,6 +30,7 @@ import type { Unit, Announcement, Meter, + MeterReading, ZaehlerArt, Building, KostenItem, @@ -128,6 +129,13 @@ interface Store extends Bootstrap { id: string, z: Partial<{ nummer: string; art: ZaehlerArt; stand: number; unitId: string | null; building: string }>, ) => Promise + // ── Zählerverlauf (v1.25) ───────────────────────────────── + /** Verlauf eines Zählers laden (älteste zuerst) */ + zaehlerVerlauf: (meterId: string) => Promise + /** Neuen Stand erfassen (Mieter eigene Zähler, Verwaltung alle) */ + zaehlerstandEintragen: (meterId: string, stand: number, standAm: string) => Promise<{ fehler: string | null; anomalie?: boolean; grund?: string | null }> + /** 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 @@ -395,6 +403,30 @@ export function StoreProvider({ children }: { children: ReactNode }) { return fehlerNachricht(e) } }, + // ── Zählerverlauf (v1.25) ─────────────────────────────── + async zaehlerVerlauf(meterId) { + const { readings } = await apiGet<{ readings: MeterReading[] }>(`/api/meters/${meterId}/readings`) + return readings + }, + async zaehlerstandEintragen(meterId, stand, standAm) { + try { + const r = await apiPost<{ meter: Meter; anomalie: boolean; grund: string | null }>( + `/api/meters/${meterId}/readings`, { stand, standAm }, + ) + setDaten((d) => ({ ...d, meters: d.meters.map((m) => (m.id === meterId ? r.meter : m)) })) + return { fehler: null, anomalie: r.anomalie, grund: r.grund } + } catch (e) { + return { fehler: fehlerNachricht(e) } + } + }, + async zaehlerEintragLoeschen(meterId, eintragId) { + try { + await apiDelete(`/api/meters/${meterId}/readings/${eintragId}`) + return null + } catch (e) { + return fehlerNachricht(e) + } + }, // ── Bestand: Häuser & Wohnungen ───────────────────────── async addHaus(h) { try { diff --git a/src/types/index.ts b/src/types/index.ts index 06de0a8..1e10d74 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -126,6 +126,20 @@ export interface Meter { stand: number standAm: string geaendertAm: string + /** true, wenn der neueste Verlaufseintrag eine Anomalie ist */ + letzteAnomalie?: boolean +} + +/** Eintrag im Zählerverlauf (v1.25) */ +export interface MeterReading { + id: string + stand: number + standAm: string + erfasstVon: string + quelle: 'erstbefund' | 'manuel' | 'mieter' + anomalie: boolean + anomalieGrund: string | null + erstelltAm: string } /** Kostenposition des Steuermoduls (umlegbar / nicht umlegbar). */