v1.25: Zaehlerverlauf pro Zaehler inkl. Anomalitaetserkennung mit E-Mail-Benachrichtigung
This commit is contained in:
@@ -189,6 +189,24 @@ tar -czf mp-dist.tar.gz -C dist .
|
|||||||
|
|
||||||
## 8. Verlauf / offene Punkte
|
## 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
|
**Erledigt 04.10. (v1.24):** Anleitungsseite /anleitung (Seite „Anleitung" im
|
||||||
Menü, sichtbar für Mieter und Verwaltung). Inhalte rollenbasiert in
|
Menü, sichtbar für Mieter und Verwaltung). Inhalte rollenbasiert in
|
||||||
src/data/anleitung.ts (Abschnitte je gruppe 'mieter'|'verwaltung'); Verwaltung
|
src/data/anleitung.ts (Abschnitte je gruppe 'mieter'|'verwaltung'); Verwaltung
|
||||||
|
|||||||
@@ -138,6 +138,23 @@ CREATE TABLE IF NOT EXISTS meters (
|
|||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_meters_unit ON meters(unit_id);
|
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 (
|
CREATE TABLE IF NOT EXISTS buildings (
|
||||||
name TEXT PRIMARY KEY, -- eindeutiger Hausname (z. B. "Hauptstraße 12")
|
name TEXT PRIMARY KEY, -- eindeutiger Hausname (z. B. "Hauptstraße 12")
|
||||||
adresse TEXT NOT NULL,
|
adresse TEXT NOT NULL,
|
||||||
|
|||||||
+84
-2
@@ -15,6 +15,7 @@ const { pool, init, sessionsAufraeumen } = require('./db')
|
|||||||
const { seed, seedeZaehler, seedeHaeuser, seedeKosten } = require('./seed')
|
const { seed, seedeZaehler, seedeHaeuser, seedeKosten } = require('./seed')
|
||||||
const mail = require('./mail')
|
const mail = require('./mail')
|
||||||
const { erzeugeAbrechnung } = require('./abrechnung')
|
const { erzeugeAbrechnung } = require('./abrechnung')
|
||||||
|
const zaehlerVerlauf = require('./zaehlerVerlauf')
|
||||||
|
|
||||||
const PORT = Number(process.env.PORT || 3000)
|
const PORT = Number(process.env.PORT || 3000)
|
||||||
const SESSION_DAUER_MS = 8 * 60 * 60 * 1000
|
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) => ({
|
const mapMeter = (m) => ({
|
||||||
id: m.id, unitId: m.unit_id, building: m.building, nummer: m.nummer, art: m.art,
|
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,
|
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) => ({
|
const mapBuilding = (b) => ({
|
||||||
name: b.name, adresse: b.adresse, baujahr: b.baujahr, stockwerke: b.stockwerke,
|
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')
|
? pool.query('SELECT * FROM registration_codes ORDER BY erstellt_am DESC')
|
||||||
: { rows: [] },
|
: { rows: [] },
|
||||||
istAdmin
|
istAdmin
|
||||||
? pool.query('SELECT * 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 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 WHERE unit_id = $1 ORDER BY nummer`, [einheit]),
|
||||||
istAdmin
|
istAdmin
|
||||||
? pool.query('SELECT * FROM buildings ORDER BY name')
|
? pool.query('SELECT * FROM buildings ORDER BY name')
|
||||||
: { rows: [] },
|
: { 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 *`,
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING *`,
|
||||||
[id, einheitId, haus, String(nummer).trim(), art, standZahl, deutschesDatum(), deutschesDatumZeit()],
|
[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]) })
|
res.json({ meter: mapMeter(rows[0]) })
|
||||||
} catch (e) { next(e) }
|
} 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,
|
[neu.nummer, neu.art, neu.stand, neu.unit_id, neu.building,
|
||||||
standGeaendert ? deutschesDatum() : m.stand_am, deutschesDatumZeit(), req.params.id],
|
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]) })
|
res.json({ meter: mapMeter(aktualisiert[0]) })
|
||||||
} catch (e) { next(e) }
|
} 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) ───────────────────────────────────
|
// ── Dokumente (Verwaltung) ───────────────────────────────────
|
||||||
const DOKUMENT_KATEGORIEN = ['Mietvertrag', 'Nebenkostenabrechnung', 'Hausordnung', 'Protokoll', 'Sonstiges']
|
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, …)')
|
if (geseedet) console.log('[api] Demo-Daten eingespielt (50 Wohnungen, 552 Buchungen, …)')
|
||||||
const zaehlerGeseedet = await seedeZaehler()
|
const zaehlerGeseedet = await seedeZaehler()
|
||||||
if (zaehlerGeseedet) console.log('[api] Zähler-Demo-Daten eingespielt')
|
if (zaehlerGeseedet) console.log('[api] Zähler-Demo-Daten eingespielt')
|
||||||
|
await zaehlerVerlauf.erstbefundFehlende()
|
||||||
const haeuserGeseedet = await seedeHaeuser()
|
const haeuserGeseedet = await seedeHaeuser()
|
||||||
if (haeuserGeseedet) console.log('[api] Haus-Demo-Daten eingespielt')
|
if (haeuserGeseedet) console.log('[api] Haus-Demo-Daten eingespielt')
|
||||||
const kostenGeseedet = await seedeKosten()
|
const kostenGeseedet = await seedeKosten()
|
||||||
|
|||||||
@@ -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 }
|
||||||
+10
-2
@@ -95,7 +95,11 @@ export const anleitungAbschnitte: AnleitungAbschnitt[] = [
|
|||||||
punkte: [
|
punkte: [
|
||||||
{
|
{
|
||||||
titel: 'Stände eintragen',
|
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',
|
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.',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
+212
-12
@@ -1,5 +1,5 @@
|
|||||||
import { useMemo, useState } from 'react'
|
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 { Button } from '@/components/ui/button'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import {
|
import {
|
||||||
@@ -20,7 +20,7 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select'
|
} from '@/components/ui/select'
|
||||||
import { useStore } from '../store/store'
|
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']
|
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<MeterReading[] | null>(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 (
|
||||||
|
<Dialog open={open} onOpenChange={oeffnen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="outline" size="sm" className="gap-1.5">
|
||||||
|
<History className="h-3.5 w-3.5" /> Verlauf
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
Verlauf · Zähler {meter.nummer}
|
||||||
|
<span className="ml-2 text-sm font-normal text-slate-500">({meter.art}, {meter.building})</span>
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
{lade || liste === null ? (
|
||||||
|
<p className="py-6 text-center text-sm text-slate-500">Verlauf wird geladen …</p>
|
||||||
|
) : liste.length === 0 ? (
|
||||||
|
<p className="py-6 text-center text-sm text-slate-500">Noch keine Einträge.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{[...liste].reverse().map((r) => (
|
||||||
|
<li
|
||||||
|
key={r.id}
|
||||||
|
className={`rounded-xl border px-3 py-2 ${
|
||||||
|
r.anomalie
|
||||||
|
? 'border-amber-300 bg-amber-50 dark:border-amber-800 dark:bg-amber-950/40'
|
||||||
|
: 'bg-slate-50/60 dark:bg-slate-800/60'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="font-semibold tabular-nums text-slate-800 dark:text-slate-100">
|
||||||
|
{fmtStand(r.stand)}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<Badge variant="secondary" className="bg-slate-200 text-slate-600 dark:bg-slate-700 dark:text-slate-300">
|
||||||
|
{quelleLabel(r.quelle)}
|
||||||
|
</Badge>
|
||||||
|
{r.anomalie && (
|
||||||
|
<Badge variant="secondary" className="gap-1 bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200">
|
||||||
|
<TriangleAlert className="h-3 w-3" /> Auffälligkeit
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{role === 'admin' && (
|
||||||
|
<button
|
||||||
|
onClick={() => loeschen(r.id)}
|
||||||
|
title="Eintrag löschen (Korrektur falscher Erfassung)"
|
||||||
|
className="rounded p-1 text-slate-400 hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-950"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
Ablesedatum: {r.standAm}
|
||||||
|
{r.erfasstVon && <span> · erfasst von {r.erfasstVon}</span>}
|
||||||
|
</div>
|
||||||
|
{r.anomalie && r.anomalieGrund && (
|
||||||
|
<p className="mt-1 text-xs text-amber-700 dark:text-amber-300">{r.anomalieGrund}</p>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-slate-400 dark:text-slate-500">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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<string | null>(null)
|
||||||
|
const [warnung, setWarnung] = useState<string | null>(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 (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="outline" size="sm" className="gap-1.5">
|
||||||
|
<Plus className="h-3.5 w-3.5" /> Stand eintragen
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
Stand eintragen · Zähler {meter.nummer}
|
||||||
|
<span className="ml-2 text-sm font-normal text-slate-500">(akt. Stand: {fmtStand(meter.stand)})</span>
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="zr-stand">Neuer Zählerstand</Label>
|
||||||
|
<Input
|
||||||
|
id="zr-stand"
|
||||||
|
inputMode="decimal"
|
||||||
|
placeholder={String(meter.stand).replace('.', ',')}
|
||||||
|
value={stand}
|
||||||
|
onChange={(e) => setStand(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="zr-datum">Ablesedatum</Label>
|
||||||
|
<Input
|
||||||
|
id="zr-datum"
|
||||||
|
type="date"
|
||||||
|
value={datum}
|
||||||
|
onChange={(e) => setDatum(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{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}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{warnung && (
|
||||||
|
<div className="rounded-lg bg-amber-50 px-3 py-2 text-sm text-amber-800 dark:bg-amber-950 dark:text-amber-200" role="alert">
|
||||||
|
{warnung}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setOpen(false)}>Schließen</Button>
|
||||||
|
<Button onClick={submit} disabled={!stand.trim() || !datum || sende}>
|
||||||
|
{sende ? 'Wird gespeichert …' : 'Stand speichern'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function Zaehler() {
|
export default function Zaehler() {
|
||||||
const { meters, units, role } = useStore()
|
const { meters, units, role } = useStore()
|
||||||
const [filterHaus, setFilterHaus] = useState('alle')
|
const [filterHaus, setFilterHaus] = useState('alle')
|
||||||
@@ -199,10 +386,10 @@ export default function Zaehler() {
|
|||||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight">Zählerstände</h1>
|
<h1 className="text-2xl font-bold tracking-tight">Zählerstände</h1>
|
||||||
<p className="text-sm text-slate-500">
|
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||||
{role === 'tenant'
|
{role === 'tenant'
|
||||||
? 'Ihre Zähler: Strom, Wasser und Gas auf einen Blick'
|
? 'Ihre Zähler: Strom, Wasser und Gas auf einen Blick – Stände eintragen und Verlauf ansehen'
|
||||||
: 'Alle Zähler des Bestands – Stände pflegen und Wohnungen zuordnen'}
|
: 'Alle Zähler des Bestands – Verlauf prüfen, Auffälligkeiten erkennen und Wohnungen zuordnen'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{role === 'admin' && <ZaehlerDialog />}
|
{role === 'admin' && <ZaehlerDialog />}
|
||||||
@@ -230,8 +417,8 @@ export default function Zaehler() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{visible.length === 0 ? (
|
{visible.length === 0 ? (
|
||||||
<div className="rounded-2xl border border-dashed bg-white p-10 text-center text-sm text-slate-500">
|
<div className="rounded-2xl border border-dashed bg-white p-10 text-center text-sm text-slate-500 dark:text-slate-400">
|
||||||
<Gauge className="mx-auto mb-3 h-8 w-8 text-slate-300" />
|
<Gauge className="mx-auto mb-3 h-8 w-8 text-slate-300 dark:text-slate-600" />
|
||||||
Keine Zähler für diesen Filter.
|
Keine Zähler für diesen Filter.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -239,20 +426,29 @@ export default function Zaehler() {
|
|||||||
{visible.map((m) => {
|
{visible.map((m) => {
|
||||||
const Icon = artIcon(m.art)
|
const Icon = artIcon(m.art)
|
||||||
return (
|
return (
|
||||||
<div key={m.id} className="rounded-2xl border bg-white p-5 shadow-sm">
|
<div key={m.id} className="flex flex-col rounded-2xl border bg-white p-5 shadow-sm">
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="rounded-xl bg-slate-100 p-2">
|
<span className="rounded-xl bg-slate-100 p-2 dark:bg-slate-800">
|
||||||
<Icon className="h-5 w-5 text-slate-600" />
|
<Icon className="h-5 w-5 text-slate-600 dark:text-slate-300" />
|
||||||
</span>
|
</span>
|
||||||
<Badge className={artStyle[m.art]} variant="secondary">{m.art}</Badge>
|
<Badge className={artStyle[m.art]} variant="secondary">{m.art}</Badge>
|
||||||
|
{m.letzteAnomalie && (
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
title="Der neueste Verlaufseintrag ist auffällig – Details im Verlauf"
|
||||||
|
className="gap-1 bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200"
|
||||||
|
>
|
||||||
|
<TriangleAlert className="h-3 w-3" /> Auffälligkeit
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{role === 'admin' && <ZaehlerDialog meter={m} />}
|
{role === 'admin' && <ZaehlerDialog meter={m} />}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4 text-3xl font-bold tracking-tight tabular-nums">
|
<div className="mt-4 text-3xl font-bold tracking-tight tabular-nums">
|
||||||
{fmtStand(m.stand)}
|
{fmtStand(m.stand)}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 space-y-1.5 text-xs text-slate-500">
|
<div className="mt-3 space-y-1.5 text-xs text-slate-500 dark:text-slate-400">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<Hash className="h-3.5 w-3.5 shrink-0" /> {m.nummer}
|
<Hash className="h-3.5 w-3.5 shrink-0" /> {m.nummer}
|
||||||
</div>
|
</div>
|
||||||
@@ -263,9 +459,13 @@ export default function Zaehler() {
|
|||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<CalendarDays className="h-3.5 w-3.5 shrink-0" />
|
<CalendarDays className="h-3.5 w-3.5 shrink-0" />
|
||||||
Stand: {m.standAm}
|
Stand: {m.standAm}
|
||||||
<span className="text-slate-400">(gepflegt: {m.geaendertAm})</span>
|
<span className="text-slate-400 dark:text-slate-500">(gepflegt: {m.geaendertAm})</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mt-4 flex flex-wrap gap-2 border-t pt-3 dark:border-slate-800">
|
||||||
|
<VerlaufDialog meter={m} />
|
||||||
|
<StandEintragenDialog meter={m} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import type {
|
|||||||
Unit,
|
Unit,
|
||||||
Announcement,
|
Announcement,
|
||||||
Meter,
|
Meter,
|
||||||
|
MeterReading,
|
||||||
ZaehlerArt,
|
ZaehlerArt,
|
||||||
Building,
|
Building,
|
||||||
KostenItem,
|
KostenItem,
|
||||||
@@ -128,6 +129,13 @@ interface Store extends Bootstrap {
|
|||||||
id: string,
|
id: string,
|
||||||
z: Partial<{ nummer: string; art: ZaehlerArt; stand: number; unitId: string | null; building: string }>,
|
z: Partial<{ nummer: string; art: ZaehlerArt; stand: number; unitId: string | null; building: string }>,
|
||||||
) => Promise<string | null>
|
) => Promise<string | null>
|
||||||
|
// ── Zählerverlauf (v1.25) ─────────────────────────────────
|
||||||
|
/** Verlauf eines Zählers laden (älteste zuerst) */
|
||||||
|
zaehlerVerlauf: (meterId: string) => Promise<MeterReading[]>
|
||||||
|
/** 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<string | null>
|
||||||
// ── Bestand: Häuser & Wohnungen (Verwaltung) ────────────
|
// ── Bestand: Häuser & Wohnungen (Verwaltung) ────────────
|
||||||
addHaus: (h: Omit<Building, 'name'> & { name: string }) => Promise<string | null>
|
addHaus: (h: Omit<Building, 'name'> & { name: string }) => Promise<string | null>
|
||||||
updateHaus: (name: string, h: Partial<Building>) => Promise<string | null>
|
updateHaus: (name: string, h: Partial<Building>) => Promise<string | null>
|
||||||
@@ -395,6 +403,30 @@ export function StoreProvider({ children }: { children: ReactNode }) {
|
|||||||
return fehlerNachricht(e)
|
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 ─────────────────────────
|
// ── Bestand: Häuser & Wohnungen ─────────────────────────
|
||||||
async addHaus(h) {
|
async addHaus(h) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -126,6 +126,20 @@ export interface Meter {
|
|||||||
stand: number
|
stand: number
|
||||||
standAm: string
|
standAm: string
|
||||||
geaendertAm: 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). */
|
/** Kostenposition des Steuermoduls (umlegbar / nicht umlegbar). */
|
||||||
|
|||||||
Reference in New Issue
Block a user