v1.28: Zaehleruebersicht mit Suche und Listenansicht
This commit is contained in:
@@ -189,6 +189,13 @@ tar -czf mp-dist.tar.gz -C dist .
|
|||||||
|
|
||||||
## 8. Verlauf / offene Punkte
|
## 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.
|
**Erledigt 04.10. (v1.27):** Zähleranlage ohne Haus-Feld, mit Ablesedatum.
|
||||||
POST /api/meters akzeptiert standAm (Validierung via zaehlerVerlauf.parseDatum,
|
POST /api/meters akzeptiert standAm (Validierung via zaehlerVerlauf.parseDatum,
|
||||||
Default heute); stand_am + erster Verlaufseintrag (erstbefund) nutzen das Datum.
|
Default heute); stand_am + erster Verlaufseintrag (erstbefund) nutzen das Datum.
|
||||||
|
|||||||
+108
-15
@@ -1,5 +1,5 @@
|
|||||||
import { useMemo, useState } from 'react'
|
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 { Button } from '@/components/ui/button'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import {
|
import {
|
||||||
@@ -390,28 +390,32 @@ export default function Zaehler() {
|
|||||||
const { meters, units, role } = useStore()
|
const { meters, units, role } = useStore()
|
||||||
const [filterHaus, setFilterHaus] = useState('alle')
|
const [filterHaus, setFilterHaus] = useState('alle')
|
||||||
const [filterArt, setFilterArt] = useState<'alle' | ZaehlerArt>('alle')
|
const [filterArt, setFilterArt] = useState<'alle' | ZaehlerArt>('alle')
|
||||||
|
const [suche, setSuche] = useState('')
|
||||||
|
const [ansicht, setAnsicht] = useState<'karten' | 'liste'>('karten')
|
||||||
|
|
||||||
const gebaeude = useMemo(
|
const gebaeude = useMemo(
|
||||||
() => Array.from(new Set(meters.map((m) => m.building))).sort(),
|
() => Array.from(new Set(meters.map((m) => m.building))).sort(),
|
||||||
[meters],
|
[meters],
|
||||||
)
|
)
|
||||||
|
|
||||||
const visible = useMemo(
|
|
||||||
() =>
|
|
||||||
meters.filter(
|
|
||||||
(m) =>
|
|
||||||
(filterHaus === 'alle' || m.building === filterHaus) &&
|
|
||||||
(filterArt === 'alle' || m.art === filterArt),
|
|
||||||
),
|
|
||||||
[meters, filterHaus, filterArt],
|
|
||||||
)
|
|
||||||
|
|
||||||
const einheitLabel = (m: Meter) => {
|
const einheitLabel = (m: Meter) => {
|
||||||
if (!m.unitId) return 'Allgemeinzähler'
|
if (!m.unitId) return 'Allgemeinzähler'
|
||||||
const u = units.find((x) => x.id === m.unitId)
|
const u = units.find((x) => x.id === m.unitId)
|
||||||
return u ? `Whg. ${u.number}` : 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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||||
@@ -426,7 +430,17 @@ export default function Zaehler() {
|
|||||||
{role === 'admin' && <ZaehlerDialog />}
|
{role === 'admin' && <ZaehlerDialog />}
|
||||||
</div>
|
</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}>
|
<Select value={filterHaus} onValueChange={setFilterHaus}>
|
||||||
<SelectTrigger className="h-9 w-auto min-w-36"><SelectValue /></SelectTrigger>
|
<SelectTrigger className="h-9 w-auto min-w-36"><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -445,14 +459,39 @@ export default function Zaehler() {
|
|||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</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>
|
</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 dark:text-slate-400">
|
<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" />
|
<Search className="mx-auto mb-3 h-8 w-8 text-slate-300 dark:text-slate-600" />
|
||||||
Keine Zähler für diesen Filter.
|
{suche ? `Kein Zähler passt zur Suche „${suche}".` : 'Keine Zähler für diesen Filter.'}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : ansicht === 'karten' ? (
|
||||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
{visible.map((m) => {
|
{visible.map((m) => {
|
||||||
const Icon = artIcon(m.art)
|
const Icon = artIcon(m.art)
|
||||||
@@ -501,6 +540,60 @@ export default function Zaehler() {
|
|||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user