Sprint 8: minimale Admin-Seite (Standort/Objekt-Anlage) im Frontend
CI / backend-tests (push) Successful in 56s
CI / frontend-build (push) Successful in 1m43s

Frontend deckte bisher nur den Mitarbeiter-Kontrollflow ab, Stammdaten
mussten per API/Skript angelegt werden (Test-Deployment-Lücke). /admin
zeigt Standort- und Objekt-Anlage (aus aktiver Vorlage), nur für Rolle
administration sichtbar (/auth/me liefert Rollen an AuthContext).
Bewusst kein Bereich/Kategorie/Material-CRUD - kommt aus Excel-Import.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L85hmKbvX7Cqkq47KnQhFt
This commit is contained in:
2026-09-04 00:33:28 +02:00
co-authored by Claude Sonnet 5
parent 8943e362e1
commit a97886defd
5 changed files with 200 additions and 3 deletions
+20
View File
@@ -2,6 +2,7 @@ import type { ReactNode } from "react";
import { Navigate, Route, Routes } from "react-router-dom";
import { AuthProvider, useAuth } from "./auth/AuthContext";
import { AdminPage } from "./pages/AdminPage";
import { KontrollPage } from "./pages/KontrollPage";
import { LoginPage } from "./pages/LoginPage";
import { ObjektListPage } from "./pages/ObjektListPage";
@@ -14,6 +15,17 @@ function GeschuetzteRoute({ children }: { children: ReactNode }) {
return <>{children}</>;
}
function AdminRoute({ children }: { children: ReactNode }) {
const { eingeloggt, istAdmin } = useAuth();
if (!eingeloggt) {
return <Navigate to="/login" replace />;
}
if (!istAdmin) {
return <Navigate to="/objekte" replace />;
}
return <>{children}</>;
}
export default function App() {
return (
<AuthProvider>
@@ -35,6 +47,14 @@ export default function App() {
</GeschuetzteRoute>
}
/>
<Route
path="/admin"
element={
<AdminRoute>
<AdminPage />
</AdminRoute>
}
/>
<Route path="*" element={<Navigate to="/objekte" replace />} />
</Routes>
</AuthProvider>
+21
View File
@@ -47,3 +47,24 @@ export interface Material {
einheit: string;
materialtyp: string;
}
export interface Standort {
id: number;
name: string;
adresse: string | null;
}
export interface Beladungsvorlage {
id: number;
objekttyp_id: number;
name: string;
version: number;
status: string;
}
export interface Me {
id: number;
name: string;
login: string;
rollen: string[];
}
+17 -3
View File
@@ -1,9 +1,11 @@
import { createContext, useContext, useMemo, useState, type ReactNode } from "react";
import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
import { getAuthToken, login as apiLogin, setAuthToken } from "../api/client";
import { apiRequest, getAuthToken, login as apiLogin, setAuthToken } from "../api/client";
import type { Me } from "../api/types";
interface AuthContextValue {
eingeloggt: boolean;
istAdmin: boolean;
login: (username: string, password: string) => Promise<void>;
logout: () => void;
}
@@ -12,10 +14,22 @@ const AuthContext = createContext<AuthContextValue | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [eingeloggt, setEingeloggt] = useState<boolean>(getAuthToken() !== null);
const [rollen, setRollen] = useState<string[]>([]);
useEffect(() => {
if (!eingeloggt) {
setRollen([]);
return;
}
apiRequest<Me>("/auth/me")
.then((me) => setRollen(me.rollen))
.catch(() => setRollen([]));
}, [eingeloggt]);
const value = useMemo<AuthContextValue>(
() => ({
eingeloggt,
istAdmin: rollen.includes("administration"),
login: async (username: string, password: string) => {
const token = await apiLogin(username, password);
setAuthToken(token);
@@ -26,7 +40,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setEingeloggt(false);
},
}),
[eingeloggt]
[eingeloggt, rollen]
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
+135
View File
@@ -0,0 +1,135 @@
import { useEffect, useState } from "react";
import { apiRequest } from "../api/client";
import type { Beladungsvorlage, Objekt, Standort } from "../api/types";
/**
* Minimale Admin-Seite (Standort/Objekt-Anlage), nachgezogen weil das
* Frontend bisher nur den Mitarbeiter-Kontrollflow (Sprint 7) abdeckte und
* Stammdaten/Objekte nur per API/Skript angelegt werden konnten. Bewusst
* schlank: keine Bereich-/Kategorie-/Material-/Vorlagen-CRUD, nur was für
* die Pilotphase sofort nötig ist (Vorlagen kommen aus dem Excel-Import).
*/
export function AdminPage() {
const [standorte, setStandorte] = useState<Standort[]>([]);
const [vorlagen, setVorlagen] = useState<Beladungsvorlage[]>([]);
const [objekte, setObjekte] = useState<Objekt[]>([]);
const [fehler, setFehler] = useState<string | null>(null);
const [standortName, setStandortName] = useState("");
const [objektCode, setObjektCode] = useState("");
const [objektName, setObjektName] = useState("");
const [vorlageId, setVorlageId] = useState<number | "">("");
const [standortId, setStandortId] = useState<number | "">("");
async function ladeAlles() {
const [s, v, o] = await Promise.all([
apiRequest<Standort[]>("/standorte"),
apiRequest<Beladungsvorlage[]>("/vorlagen"),
apiRequest<Objekt[]>("/objekte"),
]);
setStandorte(s);
setVorlagen(v.filter((vorlage) => vorlage.status === "aktiv"));
setObjekte(o);
}
useEffect(() => {
ladeAlles().catch(() => setFehler("Stammdaten konnten nicht geladen werden."));
}, []);
async function standortAnlegen() {
setFehler(null);
try {
await apiRequest("/standorte", { method: "POST", body: { name: standortName } });
setStandortName("");
await ladeAlles();
} catch {
setFehler("Standort konnte nicht angelegt werden.");
}
}
async function objektAnlegen() {
if (vorlageId === "" || standortId === "") {
setFehler("Vorlage und Standort auswählen.");
return;
}
setFehler(null);
const vorlage = vorlagen.find((v) => v.id === vorlageId);
try {
await apiRequest("/objekte", {
method: "POST",
body: {
code: objektCode,
name: objektName,
objekttyp_id: vorlage?.objekttyp_id,
vorlage_id: vorlageId,
standort_id: standortId,
},
});
setObjektCode("");
setObjektName("");
await ladeAlles();
} catch {
setFehler("Objekt konnte nicht angelegt werden (Code evtl. schon vergeben).");
}
}
return (
<div style={{ padding: 16, maxWidth: 640, margin: "0 auto" }}>
<h1>Administration</h1>
{fehler && <p style={{ color: "red" }}>{fehler}</p>}
<section style={{ marginBottom: 32 }}>
<h2>Standort anlegen</h2>
<input
value={standortName}
onChange={(e) => setStandortName(e.target.value)}
placeholder="Name"
/>
<button onClick={standortAnlegen} disabled={!standortName}>
Anlegen
</button>
<ul>
{standorte.map((s) => (
<li key={s.id}>{s.name}</li>
))}
</ul>
</section>
<section>
<h2>Objekt aus Vorlage anlegen</h2>
<div style={{ display: "flex", flexDirection: "column", gap: 8, maxWidth: 320 }}>
<select value={vorlageId} onChange={(e) => setVorlageId(Number(e.target.value) || "")}>
<option value="">Vorlage wählen</option>
{vorlagen.map((v) => (
<option key={v.id} value={v.id}>
{v.name} (v{v.version})
</option>
))}
</select>
<select value={standortId} onChange={(e) => setStandortId(Number(e.target.value) || "")}>
<option value="">Standort wählen</option>
{standorte.map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</select>
<input value={objektCode} onChange={(e) => setObjektCode(e.target.value)} placeholder="Code (eindeutig)" />
<input value={objektName} onChange={(e) => setObjektName(e.target.value)} placeholder="Name" />
<button onClick={objektAnlegen} disabled={!objektCode || !objektName}>
Anlegen
</button>
</div>
<ul>
{objekte.map((o) => (
<li key={o.id}>
{o.name} ({o.code})
</li>
))}
</ul>
</section>
</div>
);
}
+7
View File
@@ -3,6 +3,7 @@ import { Link } from "react-router-dom";
import { apiRequest } from "../api/client";
import type { Objekt } from "../api/types";
import { useAuth } from "../auth/AuthContext";
// Prompt 11 Screen 1: Standort/Objekt wählen. QR-/Barcode-Scan (Karte 10) ist
// Roadmap - dieser Screen bietet bewusst schon die Code-Suche als Textfeld an,
@@ -11,6 +12,7 @@ export function ObjektListPage() {
const [objekte, setObjekte] = useState<Objekt[]>([]);
const [suche, setSuche] = useState("");
const [ladefehler, setLadefehler] = useState<string | null>(null);
const { istAdmin } = useAuth();
useEffect(() => {
apiRequest<Objekt[]>("/objekte")
@@ -27,6 +29,11 @@ export function ObjektListPage() {
return (
<main style={{ maxWidth: 480, margin: "0 auto", padding: "1rem" }}>
<h1>Objekte</h1>
{istAdmin && (
<Link to="/admin" style={{ display: "inline-block", marginBottom: "1rem" }}>
Administration
</Link>
)}
<input
placeholder="Suche oder Code scannen…"
value={suche}