Bestückungs-Pflege: Fach-Sidebar + Verwaltung linksbündig statt zentriert
CI / backend-tests (push) Failing after 1m20s
CI / frontend-build (push) Successful in 24s

ObjektPositionenPanel: Fach-Sidebar (Vorlagen-Feature "feste Fächer-Liste")
links, damit die Bestückung übersichtlich bleibt und auch Dritte (Vertretung
ohne die Kontrollperson) sehen, wo welches Material im Rucksack hingehört.
Gruppierungslogik aus KontrollPage in gemeinsames Util (utils/fachGruppierung)
ausgelagert, da jetzt zweimal gebraucht.

.page-wide: margin:0 statt zentriert (Nutzer-Vorgabe: bei 1200px sonst viel
verschenkter Platz links/rechts auf breiten Monitoren).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVgbozhYmuEhiEJHffRXCV
This commit is contained in:
2026-09-04 22:51:43 +02:00
co-authored by Claude Sonnet 5
parent a7b670f6f8
commit d8e3f3aa21
7 changed files with 187 additions and 37 deletions
+36
View File
@@ -3506,3 +3506,39 @@ Keine Commits in dieser Session.
- frontend/src/styles/global.css | 7 +++++++
---
## 2026-09-04 22:35 22:35 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** frontend
### Commits
- a7b670f Bestückung eines Objekts vollständig pflegbar (Sollmenge/Status/Neuanlage)
### Geänderte Dateien
- DEVLOG.md | 13 ++++
- frontend/src/pages/admin/ObjektPositionenPanel.tsx | 221 ++++++++++++++++++++++++++++++++++++++++++++++++-------------------
---
## 2026-09-04 22:45 22:45 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** asb-material
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- DEVLOG.md | 13 ++++
- frontend/src/pages/admin/ObjektPositionenPanel.tsx | 221 ++++++++++++++++++++++++++++++++++++++++++++++++-------------------
---
## 2026-09-04 22:45 22:45 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** asb-material
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- DEVLOG.md | 13 ++++
- frontend/src/pages/admin/ObjektPositionenPanel.tsx | 221 ++++++++++++++++++++++++++++++++++++++++++++++++-------------------
---
+43
View File
@@ -93,3 +93,46 @@ Keine Commits in dieser Session.
- frontend/src/styles/global.css | 19 ++++++
---
## 2026-09-04 22:30 22:30 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** asb-material
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- DEVLOG.md | 24 ++++++++++++++++++++
- backend/app/api/v1/endpoints/benutzer.py | 6 ++++-
- backend/tests/test_benutzer_admin.py | 37 ++++++++++++++++++++++++++++++
- frontend/src/App.tsx | 7 ++++--
- frontend/src/auth/AuthContext.tsx | 15 ++++++++++++
- frontend/src/components/AppShell.tsx | 6 ++---
- frontend/src/pages/AdminPage.tsx | 94 ++++++++++++++++++++++++++++++++++++++++++++++++----------------------------
- frontend/src/pages/admin/ObjektSection.tsx | 42 ++++++++++++++++++++++++++--------
---
## 2026-09-04 22:33 22:34 (1m)
**Beschreibung:** Claude Code Session
**Projekt:** asb-material
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- DEVLOG.md | 36 ++++++++++++++++++++++++++++++++++++
- frontend/src/pages/AdminPage.tsx | 4 ++--
- frontend/src/styles/global.css | 7 +++++++
---
## 2026-09-04 22:48 22:50 (1m)
**Beschreibung:** Claude Code Session
**Projekt:** asb-material
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- DEVLOG.md | 13 ++++
- frontend/src/pages/admin/ObjektPositionenPanel.tsx | 221 ++++++++++++++++++++++++++++++++++++++++++++++++-------------------
---
+2 -29
View File
@@ -2,35 +2,8 @@ import { useNavigate, useParams } from "react-router-dom";
import { NachfuellDialog } from "./kontrolle/NachfuellDialog";
import { PositionCard } from "./kontrolle/PositionCard";
import type { PositionZustand } from "./kontrolle/types";
import { useKontrolle } from "./kontrolle/useKontrolle";
const OHNE_FACH = "Ohne Fach";
// Gruppierung nach Fach (Vorlagen-Feature "feste Fächer-Liste je Objekttyp"):
// Reihenfolge folgt dem ersten Auftreten in der Positionsliste (die wiederum
// die Vorlagen-Reihenfolge widerspiegelt), "Ohne Fach" immer zuletzt - so
// bleibt die Erfassung nah am Regal/Fahrzeug-Layout statt alphabetisch zu
// springen.
function gruppiereNachFach(positionen: PositionZustand[]): [string, PositionZustand[]][] {
const gruppen = new Map<string, PositionZustand[]>();
for (const zustand of positionen) {
const fach = zustand.fach ?? OHNE_FACH;
const liste = gruppen.get(fach);
if (liste) {
liste.push(zustand);
} else {
gruppen.set(fach, [zustand]);
}
}
const eintraege = [...gruppen.entries()];
eintraege.sort(([a], [b]) => {
if (a === OHNE_FACH) return 1;
if (b === OHNE_FACH) return -1;
return 0;
});
return eintraege;
}
import { gruppiereNachFach, OHNE_FACH } from "../utils/fachGruppierung";
export function KontrollPage() {
const { objektId } = useParams<{ objektId: string }>();
@@ -55,7 +28,7 @@ export function KontrollPage() {
nachfuellDialogSchliessen,
} = useKontrolle(objektId);
const fachGruppen = gruppiereNachFach(positionen);
const fachGruppen = gruppiereNachFach(positionen, (zustand) => zustand.fach);
const nurOhneFach = fachGruppen.length === 1 && fachGruppen[0][0] === OHNE_FACH;
async function handleAbschliessen() {
@@ -1,11 +1,13 @@
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { apiRequest } from "../../api/client";
import type { Material, Objektposition } from "../../api/types";
import type { Beladungsvorlage, Material, Objektposition } from "../../api/types";
import { gruppiereNachFach, OHNE_FACH } from "../../utils/fachGruppierung";
import { GeraeteInstanzenListe } from "./GeraeteInstanzenListe";
interface Props {
objektId: number;
vorlageId: number | null;
materialien: Material[];
onFehler: (text: string) => void;
}
@@ -34,19 +36,30 @@ function leeresFormular(p: Objektposition): ZeilenFormular {
* Endpunkte existierten schon (PATCH/POST .../positionen), waren im Frontend
* bisher nur für ablauf_charge/geraet_sn und nicht für Sollmenge/Status/Neuanlage
* angebunden.
*
* Nutzer-Vorgabe: Fach-Sidebar links, damit die Bestückung übersichtlich bleibt
* UND damit auch Dritte (Vertretung, wenn die Kontrollperson nicht da ist)
* auf einen Blick sehen, wo welches Material im Rucksack hingehört - gleiche
* Fach-Zuordnung wie in der Kontroll-Erfassung (Vorlagenposition.fach).
*/
export function ObjektPositionenPanel({ objektId, materialien, onFehler }: Props) {
export function ObjektPositionenPanel({ objektId, vorlageId, materialien, onFehler }: Props) {
const [positionen, setPositionen] = useState<Objektposition[]>([]);
const [vorlage, setVorlage] = useState<Beladungsvorlage | null>(null);
const [formulare, setFormulare] = useState<Record<string, ZeilenFormular>>({});
const [speichertId, setSpeichertId] = useState<string | null>(null);
const [neuesMaterialId, setNeuesMaterialId] = useState<number | "">("");
const [neueSollmenge, setNeueSollmenge] = useState("");
const [wirdHinzugefuegt, setWirdHinzugefuegt] = useState(false);
const [aktivesFach, setAktivesFach] = useState<string | null>(null);
async function laden() {
try {
const liste = await apiRequest<Objektposition[]>(`/objekte/${objektId}/positionen`);
const [liste, vorlageDaten] = await Promise.all([
apiRequest<Objektposition[]>(`/objekte/${objektId}/positionen`),
vorlageId ? apiRequest<Beladungsvorlage>(`/vorlagen/${vorlageId}`) : Promise.resolve(null),
]);
setPositionen(liste);
setVorlage(vorlageDaten);
setFormulare(Object.fromEntries(liste.map((p) => [p.id, leeresFormular(p)])));
} catch {
onFehler("Positionen konnten nicht geladen werden.");
@@ -55,8 +68,25 @@ export function ObjektPositionenPanel({ objektId, materialien, onFehler }: Props
useEffect(() => {
laden();
setAktivesFach(null);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [objektId]);
}, [objektId, vorlageId]);
const fachByMaterialId = useMemo(() => {
const map = new Map<number, string | null>();
for (const vp of vorlage?.positionen ?? []) {
map.set(vp.material_id, vp.fach);
}
return map;
}, [vorlage]);
const fachGruppen = useMemo(
() => gruppiereNachFach(positionen, (p) => fachByMaterialId.get(p.material_id) ?? null),
[positionen, fachByMaterialId]
);
const sichtbarePositionen = aktivesFach
? fachGruppen.find(([fach]) => fach === aktivesFach)?.[1] ?? []
: positionen;
function materialName(materialId: number) {
return materialien.find((m) => m.id === materialId)?.name ?? `Material ${materialId}`;
@@ -156,8 +186,34 @@ export function ObjektPositionenPanel({ objektId, materialien, onFehler }: Props
{positionen.length === 0 && <p className="text-muted">Keine Positionen in diesem Objekt.</p>}
{positionen.length > 0 && (
<div className="row" style={{ alignItems: "flex-start", gap: "1.25rem" }}>
<nav
className="card"
style={{ minWidth: "11rem", flexShrink: 0, padding: "0.5rem", position: "sticky", top: "0.5rem" }}
>
<button
className={aktivesFach === null ? "btn btn-primary btn-block" : "btn btn-secondary btn-block"}
style={{ marginBottom: "0.3rem" }}
onClick={() => setAktivesFach(null)}
>
Alle ({positionen.length})
</button>
{fachGruppen.map(([fach, gruppenPositionen]) => (
<button
key={fach}
className={aktivesFach === fach ? "btn btn-primary btn-block" : "btn btn-secondary btn-block"}
style={{ marginBottom: "0.3rem" }}
onClick={() => setAktivesFach(fach)}
>
{fach} ({gruppenPositionen.length})
</button>
))}
</nav>
<div style={{ flex: 1, minWidth: 0 }}>
<ul className="card-list">
{positionen.map((p) => {
{sichtbarePositionen.map((p) => {
const typ = materialtyp(p.material_id);
const formular = formulare[p.id] ?? leeresFormular(p);
const entfernt = p.ist_status === "entfernt";
@@ -166,6 +222,7 @@ export function ObjektPositionenPanel({ objektId, materialien, onFehler }: Props
<div className="card-title-row">
<strong>{materialName(p.material_id)}</strong>
<span className="row" style={{ gap: "0.4rem" }}>
<span className="badge badge-neutral">{fachByMaterialId.get(p.material_id) ?? OHNE_FACH}</span>
<span className="badge badge-neutral">{typ}</span>
{entfernt && <span className="badge badge-danger">entfernt</span>}
</span>
@@ -239,5 +296,8 @@ export function ObjektPositionenPanel({ objektId, materialien, onFehler }: Props
})}
</ul>
</div>
</div>
)}
</div>
);
}
+6 -1
View File
@@ -288,7 +288,12 @@ export function ObjektSection({
)}
{ausgeklapptId === o.id && (
<div style={{ marginTop: "0.5rem" }}>
<ObjektPositionenPanel objektId={o.id} materialien={materialien} onFehler={onFehler} />
<ObjektPositionenPanel
objektId={o.id}
vorlageId={o.vorlage_id}
materialien={materialien}
onFehler={onFehler}
/>
</div>
)}
{duplizierId === o.id && (
+4 -1
View File
@@ -110,9 +110,12 @@ p {
/* Verwaltung (Admin/Material-/Leitungsverantwortliche) läuft am Desktop -
Tabellen (Änderungslog) und mehrspaltige Formulare brauchen mehr Platz als
die mobile 640px-Kontroll-Ansicht der Mitarbeiter. */
die mobile 640px-Kontroll-Ansicht der Mitarbeiter. Nutzer-Vorgabe: links
ausgerichtet statt zentriert - bei 1200px sonst viel verschenkter Platz
rechts/links auf breiten Monitoren. */
.page-wide {
max-width: 1200px;
margin: 0;
}
/* -- Cards ---------------------------------------------------------------- */
+30
View File
@@ -0,0 +1,30 @@
export const OHNE_FACH = "Ohne Fach";
/**
* Gruppierung nach Fach (Vorlagen-Feature "feste Fächer-Liste je Objekttyp"):
* Reihenfolge folgt dem ersten Auftreten in der Eingabeliste (die wiederum
* die Vorlagen-Reihenfolge widerspiegelt), "Ohne Fach" immer zuletzt - bleibt
* damit nah am Regal-/Fahrzeug-Layout statt alphabetisch zu springen.
* Geteilt zwischen Kontroll-Erfassung (KontrollPage) und Bestückungs-Pflege
* (ObjektPositionenPanel) - gleiche Gruppierungslogik, unterschiedliche
* Eintragstypen.
*/
export function gruppiereNachFach<T>(eintraege: T[], fachVon: (eintrag: T) => string | null): [string, T[]][] {
const gruppen = new Map<string, T[]>();
for (const eintrag of eintraege) {
const fach = fachVon(eintrag) ?? OHNE_FACH;
const liste = gruppen.get(fach);
if (liste) {
liste.push(eintrag);
} else {
gruppen.set(fach, [eintrag]);
}
}
const gruppenListe = [...gruppen.entries()];
gruppenListe.sort(([a], [b]) => {
if (a === OHNE_FACH) return 1;
if (b === OHNE_FACH) return -1;
return 0;
});
return gruppenListe;
}