feat(objekte): Umschalter Kacheln/Gruppierung, useIsDesktop statt CSS-Doppelrender
CI / backend-tests (push) Failing after 2m10s
CI / frontend-build (push) Successful in 20s

Ansicht wird jetzt per Button in der Leiste umgeschaltet (persistiert in
localStorage) statt fest auf Gruppierung. Zusätzlich: Tabelle/Karten-
Umschaltung lief bisher über zwei parallel im DOM liegende, per CSS ein-/
ausgeblendete Blöcke - durch echten useIsDesktop-Hook (matchMedia) ersetzt,
reagiert live auf Resize statt nur beim Neuladen.

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:12:19 +02:00
co-authored by Claude Sonnet 5
parent fbb9903db4
commit 174cefa595
3 changed files with 112 additions and 46 deletions
+20
View File
@@ -0,0 +1,20 @@
import { useEffect, useState } from "react";
const BREAKPOINT = "(min-width: 900px)";
/** Echte Breiten-Erkennung statt zweier per CSS ein-/ausgeblendeter DOM-Bäume -
* reagiert live auf Resize/Fenster-Verschiebung zwischen Monitoren. */
export function useIsDesktop(): boolean {
const [istDesktop, setIstDesktop] = useState(() => window.matchMedia(BREAKPOINT).matches);
useEffect(() => {
const query = window.matchMedia(BREAKPOINT);
function aendern(e: MediaQueryListEvent) {
setIstDesktop(e.matches);
}
query.addEventListener("change", aendern);
return () => query.removeEventListener("change", aendern);
}, []);
return istDesktop;
}
+90 -30
View File
@@ -4,13 +4,32 @@ import { Link, useNavigate, useSearchParams } from "react-router-dom";
import { apiRequest, ApiError } from "../api/client"; import { apiRequest, ApiError } from "../api/client";
import type { Objekt, Standort } from "../api/types"; import type { Objekt, Standort } from "../api/types";
import { BarcodeScanner } from "../components/BarcodeScanner"; import { BarcodeScanner } from "../components/BarcodeScanner";
import { useIsDesktop } from "../hooks/useIsDesktop";
const OHNE_STANDORT = "Ohne Standort"; const OHNE_STANDORT = "Ohne Standort";
const SPEICHER_KEY_ANSICHT = "mabea-objekte-ansicht";
function ansichtLaden(): boolean {
try {
return localStorage.getItem(SPEICHER_KEY_ANSICHT) !== "kacheln";
} catch {
return true;
}
}
function ansichtSpeichern(gruppiert: boolean): void {
try {
localStorage.setItem(SPEICHER_KEY_ANSICHT, gruppiert ? "gruppiert" : "kacheln");
} catch {
// Speichern optional
}
}
// 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 istDesktop = useIsDesktop();
const [objekte, setObjekte] = useState<Objekt[]>([]); const [objekte, setObjekte] = useState<Objekt[]>([]);
const [standorte, setStandorte] = useState<Standort[]>([]); const [standorte, setStandorte] = useState<Standort[]>([]);
const [suche, setSuche] = useState(""); const [suche, setSuche] = useState("");
@@ -18,6 +37,15 @@ export function ObjektListPage() {
const [laedt, setLaedt] = useState(true); const [laedt, setLaedt] = useState(true);
const [scannerOffen, setScannerOffen] = useState(false); const [scannerOffen, setScannerOffen] = useState(false);
const [suchparameter, setSuchparameter] = useSearchParams(); const [suchparameter, setSuchparameter] = useSearchParams();
const [gruppiert, setGruppiert] = useState(ansichtLaden);
function ansichtUmschalten() {
setGruppiert((bisherig) => {
const neu = !bisherig;
ansichtSpeichern(neu);
return neu;
});
}
// Quick Action "QR scannen" aus der Command Palette springt // Quick Action "QR scannen" aus der Command Palette springt
// hierher mit ?scan=1 und öffnet den Scanner direkt, statt nur die Liste zu zeigen. // hierher mit ?scan=1 und öffnet den Scanner direkt, statt nur die Liste zu zeigen.
@@ -92,6 +120,9 @@ export function ObjektListPage() {
<button className="btn btn-secondary" onClick={() => setScannerOffen(true)}> <button className="btn btn-secondary" onClick={() => setScannerOffen(true)}>
Scannen Scannen
</button> </button>
<button className="btn btn-secondary" onClick={ansichtUmschalten}>
{gruppiert ? "Kacheln" : "Nach Standort gruppieren"}
</button>
</div> </div>
{scannerOffen && ( {scannerOffen && (
<BarcodeScanner onErkannt={scanVerarbeiten} onSchliessen={() => setScannerOffen(false)} /> <BarcodeScanner onErkannt={scanVerarbeiten} onSchliessen={() => setScannerOffen(false)} />
@@ -103,13 +134,42 @@ export function ObjektListPage() {
{objekte.length === 0 ? "Noch keine Objekte angelegt." : "Keine Treffer."} {objekte.length === 0 ? "Noch keine Objekte angelegt." : "Keine Treffer."}
</p> </p>
)} )}
{gruppen.map(([standort, gruppenObjekte]) => ( {!gruppiert && gefiltert.length > 0 && (
<ul className="card-list">
{gefiltert.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>
)}
{gruppiert &&
gruppen.map(([standort, gruppenObjekte]) => (
<section key={standort} className="objekte-gruppe"> <section key={standort} className="objekte-gruppe">
<h2 className="objekte-gruppe-titel"> <h2 className="objekte-gruppe-titel">
{standort} <span className="text-muted">({gruppenObjekte.length})</span> {standort} <span className="text-muted">({gruppenObjekte.length})</span>
</h2> </h2>
<div className="objekte-tabellen-ansicht"> {istDesktop ? (
<table className="table"> <table className="table">
<thead> <thead>
<tr> <tr>
@@ -144,34 +204,34 @@ export function ObjektListPage() {
))} ))}
</tbody> </tbody>
</table> </table>
</div> ) : (
<ul className="card-list">
<ul className="card-list objekte-karten-ansicht"> {gruppenObjekte.map((objekt) => (
{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"> <strong>{objekt.name}</strong>
<strong>{objekt.name}</strong> <span className="badge badge-neutral">{objekt.status}</span>
<span className="badge badge-neutral">{objekt.status}</span> </div>
</div> <div className="card-subtitle">{objekt.code}</div>
<div className="card-subtitle">{objekt.code}</div> <div className="text-muted" style={{ fontSize: "0.85rem" }}>
<div className="text-muted" style={{ fontSize: "0.85rem" }}> {objekt.letzte_kontrolle_am
{objekt.letzte_kontrolle_am ? `Letzte Kontrolle: ${new Date(objekt.letzte_kontrolle_am).toLocaleDateString("de-DE")}`
? `Letzte Kontrolle: ${new Date(objekt.letzte_kontrolle_am).toLocaleDateString("de-DE")}` : "Noch nie kontrolliert"}
: "Noch nie kontrolliert"} </div>
</div> </Link>
</Link> <Link
<Link to={`/akte/objekt/${objekt.id}`}
to={`/akte/objekt/${objekt.id}`} className="text-muted"
className="text-muted" style={{ fontSize: "0.85rem" }}
style={{ fontSize: "0.85rem" }} onClick={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()} >
> Akte ansehen
Akte ansehen </Link>
</Link> </li>
</li> ))}
))} </ul>
</ul> )}
</section> </section>
))} ))}
</main> </main>
+2 -16
View File
@@ -697,8 +697,8 @@ select.input:focus {
} }
/* Objekte-Liste: nach Standort gruppiert, ab 900px Tabelle statt Karten /* Objekte-Liste: nach Standort gruppiert, ab 900px Tabelle statt Karten
(mehr Objekte auf einen Blick am Desktop) - beide Ansichten liegen im DOM, (mehr Objekte auf einen Blick am Desktop) - Umschaltung per useIsDesktop-
CSS schaltet um statt JS-Breite-Erkennung (kein Hydration-/Resize-Zittern). */ Hook (echte Breiten-Erkennung, reagiert live auf Resize), nicht per CSS. */
.objekte-gruppe { .objekte-gruppe {
margin-bottom: 1.75rem; margin-bottom: 1.75rem;
} }
@@ -708,20 +708,6 @@ select.input:focus {
margin-bottom: 0.6rem; 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) --------- */