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
+101 -30
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,32 +103,77 @@ 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">
<li key={objekt.id}> <h2 className="objekte-gruppe-titel">
<Link to={`/objekte/${objekt.id}/kontrolle`} className="card card-link"> {standort} <span className="text-muted">({gruppenObjekte.length})</span>
<div className="card-title-row"> </h2>
<strong>{objekt.name}</strong>
<span className="badge badge-neutral">{objekt.status}</span> <div className="objekte-tabellen-ansicht">
</div> <table className="table">
<div className="card-subtitle">{objekt.code}</div> <thead>
<div className="text-muted" style={{ fontSize: "0.85rem" }}> <tr>
{objekt.letzte_kontrolle_am <th>Code</th>
? `Letzte Kontrolle: ${new Date(objekt.letzte_kontrolle_am).toLocaleDateString("de-DE")}` <th>Name</th>
: "Noch nie kontrolliert"} <th>Status</th>
</div> <th>Letzte Kontrolle</th>
</Link> <th />
<Link </tr>
to={`/akte/objekt/${objekt.id}`} </thead>
className="text-muted" <tbody>
style={{ fontSize: "0.85rem" }} {gruppenObjekte.map((objekt) => (
onClick={(e) => e.stopPropagation()} <tr key={objekt.id}>
> <td>{objekt.code}</td>
Akte ansehen <td>
</Link> <Link to={`/objekte/${objekt.id}/kontrolle`}>{objekt.name}</Link>
</li> </td>
))} <td>
</ul> <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}>
<Link to={`/objekte/${objekt.id}/kontrolle`} className="card card-link">
<div className="card-title-row">
<strong>{objekt.name}</strong>
<span className="badge badge-neutral">{objekt.status}</span>
</div>
<div className="card-subtitle">{objekt.code}</div>
<div className="text-muted" style={{ fontSize: "0.85rem" }}>
{objekt.letzte_kontrolle_am
? `Letzte Kontrolle: ${new Date(objekt.letzte_kontrolle_am).toLocaleDateString("de-DE")}`
: "Noch nie kontrolliert"}
</div>
</Link>
<Link
to={`/akte/objekt/${objekt.id}`}
className="text-muted"
style={{ fontSize: "0.85rem" }}
onClick={(e) => e.stopPropagation()}
>
Akte ansehen
</Link>
</li>
))}
</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) --------- */