v1.28: Zaehleruebersicht mit Suche und Listenansicht

This commit is contained in:
2026-10-04 17:38:12 +02:00
parent 0ab8fb0aaf
commit e115908cd9
2 changed files with 115 additions and 15 deletions
+7
View File
@@ -189,6 +189,13 @@ tar -czf mp-dist.tar.gz -C dist .
## 8. Verlauf / offene Punkte
**Erledigt 04.10. (v1.28):** Zählerübersicht mit Suche + Listenansicht
(Analogie zur Hausübersicht v1.26): Suchfeld über Nummer/Art/Haus/Wohneinheit
(mit Trefferzähler), Toggle Karten/Liste; Liste = Tabelle (Art-Badge mit Icon,
Nummer, Haus, Wohneinheit, Stand rechts tabular-nums, Stand am, Anomalie-Icon,
Aktionen), responsive via hidden sm/md/lg. Bestehende Filter Haus/Art bleiben
und kombinieren mit der Suche.
**Erledigt 04.10. (v1.27):** Zähleranlage ohne Haus-Feld, mit Ablesedatum.
POST /api/meters akzeptiert standAm (Validierung via zaehlerVerlauf.parseDatum,
Default heute); stand_am + erster Verlaufseintrag (erstbefund) nutzen das Datum.
+108 -15
View File
@@ -1,5 +1,5 @@
import { useMemo, useState } from 'react'
import { Gauge, Zap, Droplets, Flame, Building2, Hash, CalendarDays, Pencil, Plus, History, TriangleAlert, Trash2 } from 'lucide-react'
import { Gauge, Zap, Droplets, Flame, Building2, Hash, CalendarDays, Pencil, Plus, History, TriangleAlert, Trash2, Search, List, LayoutGrid } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
@@ -390,28 +390,32 @@ export default function Zaehler() {
const { meters, units, role } = useStore()
const [filterHaus, setFilterHaus] = useState('alle')
const [filterArt, setFilterArt] = useState<'alle' | ZaehlerArt>('alle')
const [suche, setSuche] = useState('')
const [ansicht, setAnsicht] = useState<'karten' | 'liste'>('karten')
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
}
const visible = useMemo(() => {
const q = suche.trim().toLowerCase()
return meters.filter(
(m) =>
(filterHaus === 'alle' || m.building === filterHaus) &&
(filterArt === 'alle' || m.art === filterArt) &&
(!q ||
[m.nummer, m.art, m.building, einheitLabel(m)]
.some((f) => f.toLowerCase().includes(q))),
)
}, [meters, filterHaus, filterArt, suche, units])
return (
<div className="space-y-6">
<div className="flex flex-wrap items-end justify-between gap-3">
@@ -426,7 +430,17 @@ export default function Zaehler() {
{role === 'admin' && <ZaehlerDialog />}
</div>
<div className="flex flex-wrap gap-2">
<div className="flex flex-wrap items-center gap-2">
<div className="relative min-w-56 flex-1 sm:max-w-xs">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<Input
className="pl-9"
placeholder="Zähler suchen …"
value={suche}
onChange={(e) => setSuche(e.target.value)}
aria-label="Zähler suchen"
/>
</div>
<Select value={filterHaus} onValueChange={setFilterHaus}>
<SelectTrigger className="h-9 w-auto min-w-36"><SelectValue /></SelectTrigger>
<SelectContent>
@@ -445,14 +459,39 @@ export default function Zaehler() {
))}
</SelectContent>
</Select>
<div className="flex rounded-lg border p-0.5 dark:border-slate-700">
<Button
variant={ansicht === 'karten' ? 'default' : 'ghost'}
size="sm"
className="gap-1.5"
onClick={() => setAnsicht('karten')}
aria-pressed={ansicht === 'karten'}
>
<LayoutGrid className="h-3.5 w-3.5" /> Karten
</Button>
<Button
variant={ansicht === 'liste' ? 'default' : 'ghost'}
size="sm"
className="gap-1.5"
onClick={() => setAnsicht('liste')}
aria-pressed={ansicht === 'liste'}
>
<List className="h-3.5 w-3.5" /> Liste
</Button>
</div>
{suche && (
<span className="text-xs text-slate-500 dark:text-slate-400">
{visible.length} von {meters.length} Zählern
</span>
)}
</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.
<Search className="mx-auto mb-3 h-8 w-8 text-slate-300 dark:text-slate-600" />
{suche ? `Kein Zähler passt zur Suche „${suche}".` : 'Keine Zähler für diesen Filter.'}
</div>
) : (
) : ansicht === 'karten' ? (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
{visible.map((m) => {
const Icon = artIcon(m.art)
@@ -501,6 +540,60 @@ export default function Zaehler() {
)
})}
</div>
) : (
/* ── Listenansicht ─────────────────────────────── */
<div className="overflow-hidden rounded-2xl border bg-white dark:border-slate-700 dark:bg-slate-900">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-slate-50 text-left text-xs uppercase tracking-wide text-slate-500 dark:border-slate-700 dark:bg-slate-800/60 dark:text-slate-400">
<th className="px-3 py-2.5">Art</th>
<th className="px-3 py-2.5">Zählernummer</th>
<th className="hidden px-3 py-2.5 sm:table-cell">Haus</th>
<th className="hidden px-3 py-2.5 md:table-cell">Wohneinheit</th>
<th className="px-3 py-2.5 text-right">Stand</th>
<th className="hidden px-3 py-2.5 lg:table-cell">Stand am</th>
<th className="px-3 py-2.5" aria-label="Auffälligkeit" />
<th className="px-3 py-2.5 text-right">Aktion</th>
</tr>
</thead>
<tbody>
{visible.map((m) => {
const Icon = artIcon(m.art)
return (
<tr key={m.id} className="border-b last:border-0 hover:bg-slate-50 dark:border-slate-700 dark:hover:bg-slate-800/40">
<td className="px-3 py-2.5">
<Badge className={`${artStyle[m.art]} gap-1`} variant="secondary">
<Icon className="h-3 w-3" /> {m.art}
</Badge>
</td>
<td className="px-3 py-2.5 font-medium text-slate-800 dark:text-slate-100">{m.nummer}</td>
<td className="hidden px-3 py-2.5 text-slate-500 dark:text-slate-400 sm:table-cell">{m.building}</td>
<td className="hidden px-3 py-2.5 text-slate-500 dark:text-slate-400 md:table-cell">{einheitLabel(m)}</td>
<td className="px-3 py-2.5 text-right font-semibold tabular-nums text-slate-800 dark:text-slate-100">
{fmtStand(m.stand)}
</td>
<td className="hidden px-3 py-2.5 text-slate-500 dark:text-slate-400 lg:table-cell">{m.standAm}</td>
<td className="px-3 py-2.5">
{m.letzteAnomalie && (
<TriangleAlert
className="h-4 w-4 text-amber-500"
aria-label="Auffälligkeit im Verlauf"
/>
)}
</td>
<td className="px-3 py-2.5">
<div className="flex flex-wrap justify-end gap-1.5">
<VerlaufDialog meter={m} />
<StandEintragenDialog meter={m} />
{role === 'admin' && <ZaehlerDialog meter={m} />}
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
</div>
)