feat(objekte): Liste nach Standort gruppiert, Desktop-Tabelle statt Karten
CI / backend-tests (push) Failing after 2m6s
CI / frontend-build (push) Successful in 21s

Objekte-Übersicht war eine flache Karten-Liste ohne Struktur. Jetzt nach
Standort gruppiert (Pflichtfeld, deckt immer alle Objekte ab) mit
Gruppenüberschrift + Anzahl. Ab 900px Breite Tabellen-Ansicht (Code/Name/
Status/letzte Kontrolle/Akte-Link) für mehr Objekte auf einen Blick,
darunter weiterhin die Karten-Ansicht für mobil - beide liegen im DOM,
CSS-Media-Query schaltet um statt JS-Breitenerkennung.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVgbozhYmuEhiEJHffRXCV
This commit is contained in:
2026-09-08 01:07:18 +02:00
co-authored by Claude Sonnet 5
parent 707701d897
commit fbb9903db4
2 changed files with 127 additions and 30 deletions
+77 -6
View File
@@ -1,15 +1,18 @@
import { useEffect, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { Link, useNavigate, useSearchParams } from "react-router-dom"; import { Link, useNavigate, useSearchParams } from "react-router-dom";
import { apiRequest, ApiError } from "../api/client"; import { apiRequest, ApiError } from "../api/client";
import type { Objekt } from "../api/types"; import type { Objekt, Standort } from "../api/types";
import { BarcodeScanner } from "../components/BarcodeScanner"; import { BarcodeScanner } from "../components/BarcodeScanner";
const OHNE_STANDORT = "Ohne Standort";
// Objekt-Code-Scan springt direkt zur Akte, sonst füllt er dasselbe Textfeld // Objekt-Code-Scan springt direkt zur Akte, sonst füllt er dasselbe Textfeld
// wie die manuelle Suche (Kamera fällt bei Etikettschaden auf Tippen zurück). // wie die manuelle Suche (Kamera fällt bei Etikettschaden auf Tippen zurück).
export function ObjektListPage() { export function ObjektListPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const [objekte, setObjekte] = useState<Objekt[]>([]); const [objekte, setObjekte] = useState<Objekt[]>([]);
const [standorte, setStandorte] = useState<Standort[]>([]);
const [suche, setSuche] = useState(""); const [suche, setSuche] = useState("");
const [ladefehler, setLadefehler] = useState<string | null>(null); const [ladefehler, setLadefehler] = useState<string | null>(null);
const [laedt, setLaedt] = useState(true); const [laedt, setLaedt] = useState(true);
@@ -27,8 +30,11 @@ export function ObjektListPage() {
}, []); }, []);
useEffect(() => { useEffect(() => {
apiRequest<Objekt[]>("/objekte") Promise.all([apiRequest<Objekt[]>("/objekte"), apiRequest<Standort[]>("/standorte")])
.then(setObjekte) .then(([o, s]) => {
setObjekte(o);
setStandorte(s);
})
.catch(() => setLadefehler("Objektliste konnte nicht geladen werden.")) .catch(() => setLadefehler("Objektliste konnte nicht geladen werden."))
.finally(() => setLaedt(false)); .finally(() => setLaedt(false));
}, []); }, []);
@@ -47,12 +53,32 @@ export function ObjektListPage() {
} }
} }
const standortName = useMemo(() => {
const map = new Map<number, string>();
for (const s of standorte) map.set(s.id, s.name);
return map;
}, [standorte]);
const gefiltert = objekte.filter( const gefiltert = objekte.filter(
(o) => (o) =>
o.name.toLowerCase().includes(suche.toLowerCase()) || o.name.toLowerCase().includes(suche.toLowerCase()) ||
o.code.toLowerCase().includes(suche.toLowerCase()) o.code.toLowerCase().includes(suche.toLowerCase())
); );
// Standort ist bei jedem Objekt gesetzt (Pflichtfeld) - Gruppierung ist
// damit immer vollständig, keine "sonstige" Sammel-Gruppe nötig
// (Fahrzeug/Bereich wären dafür ungeeignet, da oft NULL bzw. nur indirekt
// über Objekttyp verfügbar).
const gruppen = useMemo(() => {
const nachStandort = new Map<string, Objekt[]>();
for (const o of gefiltert) {
const name = standortName.get(o.standort_id) ?? OHNE_STANDORT;
if (!nachStandort.has(name)) nachStandort.set(name, []);
nachStandort.get(name)!.push(o);
}
return [...nachStandort.entries()].sort(([a], [b]) => a.localeCompare(b, "de"));
}, [gefiltert, standortName]);
return ( return (
<main className="page"> <main className="page">
<h1>Objekte</h1> <h1>Objekte</h1>
@@ -77,8 +103,51 @@ export function ObjektListPage() {
{objekte.length === 0 ? "Noch keine Objekte angelegt." : "Keine Treffer."} {objekte.length === 0 ? "Noch keine Objekte angelegt." : "Keine Treffer."}
</p> </p>
)} )}
<ul className="card-list"> {gruppen.map(([standort, gruppenObjekte]) => (
{gefiltert.map((objekt) => ( <section key={standort} className="objekte-gruppe">
<h2 className="objekte-gruppe-titel">
{standort} <span className="text-muted">({gruppenObjekte.length})</span>
</h2>
<div className="objekte-tabellen-ansicht">
<table className="table">
<thead>
<tr>
<th>Code</th>
<th>Name</th>
<th>Status</th>
<th>Letzte Kontrolle</th>
<th />
</tr>
</thead>
<tbody>
{gruppenObjekte.map((objekt) => (
<tr key={objekt.id}>
<td>{objekt.code}</td>
<td>
<Link to={`/objekte/${objekt.id}/kontrolle`}>{objekt.name}</Link>
</td>
<td>
<span className="badge badge-neutral">{objekt.status}</span>
</td>
<td>
{objekt.letzte_kontrolle_am
? new Date(objekt.letzte_kontrolle_am).toLocaleDateString("de-DE")
: "Noch nie"}
</td>
<td>
<Link to={`/akte/objekt/${objekt.id}`} className="text-muted">
Akte
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
<ul className="card-list objekte-karten-ansicht">
{gruppenObjekte.map((objekt) => (
<li key={objekt.id}> <li key={objekt.id}>
<Link to={`/objekte/${objekt.id}/kontrolle`} className="card card-link"> <Link to={`/objekte/${objekt.id}/kontrolle`} className="card card-link">
<div className="card-title-row"> <div className="card-title-row">
@@ -103,6 +172,8 @@ export function ObjektListPage() {
</li> </li>
))} ))}
</ul> </ul>
</section>
))}
</main> </main>
); );
} }
+26
View File
@@ -696,6 +696,32 @@ select.input:focus {
background: var(--color-surface-sunken); background: var(--color-surface-sunken);
} }
/* Objekte-Liste: nach Standort gruppiert, ab 900px Tabelle statt Karten
(mehr Objekte auf einen Blick am Desktop) - beide Ansichten liegen im DOM,
CSS schaltet um statt JS-Breite-Erkennung (kein Hydration-/Resize-Zittern). */
.objekte-gruppe {
margin-bottom: 1.75rem;
}
.objekte-gruppe-titel {
font-size: 1rem;
margin-bottom: 0.6rem;
}
.objekte-tabellen-ansicht {
display: none;
}
@media (min-width: 900px) {
.objekte-tabellen-ansicht {
display: block;
}
.objekte-karten-ansicht {
display: none;
}
}
/* -- Dashboard-Kacheln (DNA-Referenz Resgrid/OpenFleet/Emergency-Management: /* -- Dashboard-Kacheln (DNA-Referenz Resgrid/OpenFleet/Emergency-Management:
KPI-Statistik-Karten sind dort durchgängig das zentrale Muster) --------- */ KPI-Statistik-Karten sind dort durchgängig das zentrale Muster) --------- */