Bestückungs-Pflege: Fach-Sidebar + Verwaltung linksbündig statt zentriert
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:
@@ -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 ++++++++++++++++++++++++++++++++++++++++++++++++-------------------
|
||||
|
||||
---
|
||||
|
||||
@@ -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,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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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 ---------------------------------------------------------------- */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user