Sprint 8: minimale Admin-Seite (Standort/Objekt-Anlage) im Frontend
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:
@@ -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>
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user