508 lines
20 KiB
TypeScript
508 lines
20 KiB
TypeScript
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>
|
||
)
|
||
}
|