Files
Mieterportal/src/pages/Zaehler.tsx
T

508 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useMemo, useState } from '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 {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
DialogFooter,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { useStore } from '../store/store'
import type { Meter, MeterReading, ZaehlerArt } from '../types'
const ARTEN: ZaehlerArt[] = ['Strom', 'Wasser', 'Gas', 'Wärme', 'Sonstiges']
const artIcon = (art: ZaehlerArt) => {
switch (art) {
case 'Strom': return Zap
case 'Wasser': return Droplets
case 'Gas': return Flame
default: return Gauge
}
}
const artStyle: Record<ZaehlerArt, string> = {
Strom: 'bg-amber-100 text-amber-800',
Wasser: 'bg-blue-100 text-blue-800',
Gas: 'bg-orange-100 text-orange-800',
Wärme: 'bg-red-100 text-red-700',
Sonstiges: 'bg-slate-100 text-slate-600',
}
/** Deutsche Zahlenanzeige: 48231,4 → „48.231,4" (bis zu 3 Nachkommastellen). */
const fmtStand = (n: number) =>
n.toLocaleString('de-DE', { maximumFractionDigits: 3 })
function ZaehlerDialog({ meter }: { meter?: Meter }) {
const { addZaehler, updateZaehler, units, buildings } = useStore()
const bearbeiten = Boolean(meter)
const [open, setOpen] = useState(false)
const [nummer, setNummer] = useState(meter?.nummer ?? '')
const [art, setArt] = useState<ZaehlerArt>(meter?.art ?? 'Strom')
const [stand, setStand] = useState(String(meter?.stand ?? '').replace('.', ','))
const [standAm, setStandAm] = useState(() => {
const d = new Date()
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
})
const [unitId, setUnitId] = useState<string>(meter?.unitId ?? 'haus')
const [building, setBuilding] = useState(meter?.building ?? buildings[0]?.name ?? '')
const [sende, setSende] = useState(false)
const [fehler, setFehler] = useState<string | null>(null)
const submit = async () => {
const standZahl = Number(stand.replace(',', '.'))
if (!nummer.trim() || !Number.isFinite(standZahl) || standZahl < 0 || sende) return
if (!bearbeiten && !standAm) return
setSende(true)
setFehler(null)
let err: string | null
if (bearbeiten) {
err = await updateZaehler(meter!.id, {
nummer: nummer.trim(), art, stand: standZahl,
unitId: unitId === 'haus' ? null : unitId, building,
})
} else {
err = await addZaehler({
nummer: nummer.trim(), art, stand: standZahl,
// „YYYY-MM-DD" (Datumseingabe) → „TT.MM.JJJJ" (Serverformat)
standAm: standAm.split('-').reverse().join('.'),
unitId: unitId === 'haus' ? null : unitId, building,
})
}
setSende(false)
if (err) return setFehler(err)
setOpen(false)
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
{bearbeiten ? (
<Button variant="outline" size="sm" className="gap-1.5">
<Pencil className="h-3.5 w-3.5" /> Bearbeiten
</Button>
) : (
<Button className="gap-2">
<Plus className="h-4 w-4" /> Neuer Zähler
</Button>
)}
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{bearbeiten ? `Zähler ${meter!.nummer} bearbeiten` : 'Neuen Zähler anlegen'}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="z-nr">Zählernummer</Label>
<Input
id="z-nr"
placeholder="z. B. ST-0815-4711"
value={nummer}
onChange={(e) => setNummer(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label>Zählerart</Label>
<Select value={art} onValueChange={(v) => setArt(v as ZaehlerArt)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{ARTEN.map((a) => (
<SelectItem key={a} value={a}>{a}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="z-stand">Aktueller Zählerstand</Label>
<Input
id="z-stand"
inputMode="decimal"
placeholder="z. B. 48231,4"
value={stand}
onChange={(e) => setStand(e.target.value)}
/>
</div>
{!bearbeiten && (
<div className="space-y-1.5">
<Label htmlFor="z-datum">Ablesedatum</Label>
<Input
id="z-datum"
type="date"
value={standAm}
onChange={(e) => setStandAm(e.target.value)}
/>
</div>
)}
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label>Wohneinheit</Label>
<Select value={unitId} onValueChange={setUnitId}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="haus">Allgemeinzähler (ohne Wohnung)</SelectItem>
{units.map((u) => (
<SelectItem key={u.id} value={u.id}>
{u.building}, Whg. {u.number}
</SelectItem>
))}
</SelectContent>
</Select>
{unitId !== 'haus' && (
<p className="text-xs text-slate-400">Das Haus folgt automatisch der gewählten Wohneinheit.</p>
)}
</div>
{unitId === 'haus' && (
<div className="space-y-1.5">
<Label>Haus</Label>
<Select value={building} onValueChange={setBuilding}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{buildings.map((b) => (
<SelectItem key={b.name} value={b.name}>{b.adresse}</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-slate-400">Nur nötig für Zähler ohne Wohnungsbezug.</p>
</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>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>Abbrechen</Button>
<Button onClick={submit} disabled={!nummer.trim() || !stand.trim() || (!bearbeiten && !standAm) || sende}>
{sende ? 'Wird gespeichert …' : bearbeiten ? 'Speichern' : 'Zähler anlegen'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
/** 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')
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 (
<div className="space-y-6">
<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 dark:text-slate-400">
{role === 'tenant'
? '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 />}
</div>
<div className="flex flex-wrap gap-2">
<Select value={filterHaus} onValueChange={setFilterHaus}>
<SelectTrigger className="h-9 w-auto min-w-36"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="alle">Alle Häuser</SelectItem>
{gebaeude.map((g) => (
<SelectItem key={g} value={g}>{g}</SelectItem>
))}
</SelectContent>
</Select>
<Select value={filterArt} onValueChange={(v) => setFilterArt(v as 'alle' | ZaehlerArt)}>
<SelectTrigger className="h-9 w-auto min-w-32"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="alle">Alle Arten</SelectItem>
{ARTEN.map((a) => (
<SelectItem key={a} value={a}>{a}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{visible.length === 0 ? (
<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>
) : (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
{visible.map((m) => {
const Icon = artIcon(m.art)
return (
<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 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 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>
<div className="flex items-center gap-1.5">
<Building2 className="h-3.5 w-3.5 shrink-0" />
{m.building} · {einheitLabel(m)}
</div>
<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 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>
)
})}
</div>
)}
</div>
)
}