v1.25: Zaehlerverlauf pro Zaehler inkl. Anomalitaetserkennung mit E-Mail-Benachrichtigung
This commit is contained in:
+10
-2
@@ -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.',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
+212
-12
@@ -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<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() {
|
||||
const { meters, units, role } = useStore()
|
||||
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>
|
||||
<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'
|
||||
? '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'}
|
||||
</p>
|
||||
</div>
|
||||
{role === 'admin' && <ZaehlerDialog />}
|
||||
@@ -230,8 +417,8 @@ export default function Zaehler() {
|
||||
</div>
|
||||
|
||||
{visible.length === 0 ? (
|
||||
<div className="rounded-2xl border border-dashed bg-white p-10 text-center text-sm text-slate-500">
|
||||
<Gauge className="mx-auto mb-3 h-8 w-8 text-slate-300" />
|
||||
<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 dark:text-slate-600" />
|
||||
Keine Zähler für diesen Filter.
|
||||
</div>
|
||||
) : (
|
||||
@@ -239,20 +426,29 @@ export default function Zaehler() {
|
||||
{visible.map((m) => {
|
||||
const Icon = artIcon(m.art)
|
||||
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-center gap-2">
|
||||
<span className="rounded-xl bg-slate-100 p-2">
|
||||
<Icon className="h-5 w-5 text-slate-600" />
|
||||
<span className="rounded-xl bg-slate-100 p-2 dark:bg-slate-800">
|
||||
<Icon className="h-5 w-5 text-slate-600 dark:text-slate-300" />
|
||||
</span>
|
||||
<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>
|
||||
{role === 'admin' && <ZaehlerDialog meter={m} />}
|
||||
</div>
|
||||
<div className="mt-4 text-3xl font-bold tracking-tight tabular-nums">
|
||||
{fmtStand(m.stand)}
|
||||
</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">
|
||||
<Hash className="h-3.5 w-3.5 shrink-0" /> {m.nummer}
|
||||
</div>
|
||||
@@ -263,9 +459,13 @@ export default function Zaehler() {
|
||||
<div className="flex items-center gap-1.5">
|
||||
<CalendarDays className="h-3.5 w-3.5 shrink-0" />
|
||||
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 className="mt-4 flex flex-wrap gap-2 border-t pt-3 dark:border-slate-800">
|
||||
<VerlaufDialog meter={m} />
|
||||
<StandEintragenDialog meter={m} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -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<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) ────────────
|
||||
addHaus: (h: Omit<Building, 'name'> & { name: string }) => 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)
|
||||
}
|
||||
},
|
||||
// ── 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 {
|
||||
|
||||
@@ -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). */
|
||||
|
||||
Reference in New Issue
Block a user