From e3ed5e0f288c81b814fb999c15b7d9bf1c301fa7 Mon Sep 17 00:00:00 2001 From: patrick Date: Fri, 4 Sep 2026 00:38:02 +0200 Subject: [PATCH] =?UTF-8?q?Sprint=208:=20modernes=20Design-System=20f?= =?UTF-8?q?=C3=BCr=20die=20React-PWA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gemeinsames Stylesheet (Tokens, Karten, Buttons, Badges, Formulare) plus AppShell (Kopfzeile mit Navigation/Logout) statt Inline-Styles je Screen. Kein UI-Framework als neue Abhängigkeit - schlankes eigenes CSS, damit Build/Deploy-Weg unverändert bleibt. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01L85hmKbvX7Cqkq47KnQhFt --- frontend/src/App.tsx | 5 +- frontend/src/components/AppShell.tsx | 39 ++++ frontend/src/main.tsx | 1 + frontend/src/pages/AdminPage.tsx | 172 +++++++++----- frontend/src/pages/KontrollPage.tsx | 107 +++++---- frontend/src/pages/LoginPage.tsx | 66 +++--- frontend/src/pages/ObjektListPage.tsx | 47 ++-- frontend/src/styles/global.css | 323 ++++++++++++++++++++++++++ 8 files changed, 598 insertions(+), 162 deletions(-) create mode 100644 frontend/src/components/AppShell.tsx create mode 100644 frontend/src/styles/global.css diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3be3029..3ec3082 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2,6 +2,7 @@ import type { ReactNode } from "react"; import { Navigate, Route, Routes } from "react-router-dom"; import { AuthProvider, useAuth } from "./auth/AuthContext"; +import { AppShell } from "./components/AppShell"; import { AdminPage } from "./pages/AdminPage"; import { KontrollPage } from "./pages/KontrollPage"; import { LoginPage } from "./pages/LoginPage"; @@ -12,7 +13,7 @@ function GeschuetzteRoute({ children }: { children: ReactNode }) { if (!eingeloggt) { return ; } - return <>{children}; + return {children}; } function AdminRoute({ children }: { children: ReactNode }) { @@ -23,7 +24,7 @@ function AdminRoute({ children }: { children: ReactNode }) { if (!istAdmin) { return ; } - return <>{children}; + return {children}; } export default function App() { diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx new file mode 100644 index 0000000..75ce031 --- /dev/null +++ b/frontend/src/components/AppShell.tsx @@ -0,0 +1,39 @@ +import type { ReactNode } from "react"; +import { NavLink, useNavigate } from "react-router-dom"; + +import { useAuth } from "../auth/AuthContext"; + +/** Gemeinsamer Rahmen (Kopfzeile + Navigation) für alle eingeloggten Screens. */ +export function AppShell({ children }: { children: ReactNode }) { + const { istAdmin, logout } = useAuth(); + const navigate = useNavigate(); + + function handleLogout() { + logout(); + navigate("/login"); + } + + return ( +
+
+ + MABEA + + +
+ {children} +
+ ); +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 26886f0..dd41241 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -3,6 +3,7 @@ import { createRoot } from "react-dom/client"; import { BrowserRouter } from "react-router-dom"; import App from "./App"; +import "./styles/global.css"; createRoot(document.getElementById("root")!).render( diff --git a/frontend/src/pages/AdminPage.tsx b/frontend/src/pages/AdminPage.tsx index a6f0922..3c56b9f 100644 --- a/frontend/src/pages/AdminPage.tsx +++ b/frontend/src/pages/AdminPage.tsx @@ -15,12 +15,16 @@ export function AdminPage() { const [vorlagen, setVorlagen] = useState([]); const [objekte, setObjekte] = useState([]); const [fehler, setFehler] = useState(null); + const [laedt, setLaedt] = useState(true); const [standortName, setStandortName] = useState(""); + const [standortWirdAngelegt, setStandortWirdAngelegt] = useState(false); + const [objektCode, setObjektCode] = useState(""); const [objektName, setObjektName] = useState(""); const [vorlageId, setVorlageId] = useState(""); const [standortId, setStandortId] = useState(""); + const [objektWirdAngelegt, setObjektWirdAngelegt] = useState(false); async function ladeAlles() { const [s, v, o] = await Promise.all([ @@ -34,17 +38,22 @@ export function AdminPage() { } useEffect(() => { - ladeAlles().catch(() => setFehler("Stammdaten konnten nicht geladen werden.")); + ladeAlles() + .catch(() => setFehler("Stammdaten konnten nicht geladen werden.")) + .finally(() => setLaedt(false)); }, []); async function standortAnlegen() { setFehler(null); + setStandortWirdAngelegt(true); try { await apiRequest("/standorte", { method: "POST", body: { name: standortName } }); setStandortName(""); await ladeAlles(); } catch { setFehler("Standort konnte nicht angelegt werden."); + } finally { + setStandortWirdAngelegt(false); } } @@ -54,6 +63,7 @@ export function AdminPage() { return; } setFehler(null); + setObjektWirdAngelegt(true); const vorlage = vorlagen.find((v) => v.id === vorlageId); try { await apiRequest("/objekte", { @@ -71,65 +81,123 @@ export function AdminPage() { await ladeAlles(); } catch { setFehler("Objekt konnte nicht angelegt werden (Code evtl. schon vergeben)."); + } finally { + setObjektWirdAngelegt(false); } } + if (laedt) { + return ( +
+

Lade…

+
+ ); + } + return ( -
+

Administration

- {fehler &&

{fehler}

} + {fehler &&
{fehler}
} -
+

Standort anlegen

- setStandortName(e.target.value)} - placeholder="Name" - /> - -
    - {standorte.map((s) => ( -
  • {s.name}
  • - ))} -
-
- -
-

Objekt aus Vorlage anlegen

-
- - - setObjektCode(e.target.value)} placeholder="Code (eindeutig)" /> - setObjektName(e.target.value)} placeholder="Name" /> - +
+
+ setStandortName(e.target.value)} + placeholder="Name des Standorts" + /> + +
+ {standorte.length > 0 && ( +
    + {standorte.map((s) => ( +
  • + {s.name} +
  • + ))} +
+ )}
- -
    - {objekte.map((o) => ( -
  • - {o.name} ({o.code}) -
  • - ))} -
-
+ +
+

Objekt aus Vorlage anlegen

+
+ {vorlagen.length === 0 ? ( +

+ Keine aktive Vorlage vorhanden – erst Excel-Import oder Vorlage per API anlegen. +

+ ) : ( +
+
+ + +
+
+ + +
+
+ + setObjektCode(e.target.value)} /> +
+
+ + setObjektName(e.target.value)} /> +
+ +
+ )} + + {objekte.length > 0 && ( +
    + {objekte.map((o) => ( +
  • + {o.name} + {o.code} +
  • + ))} +
+ )} +
+
+ ); } diff --git a/frontend/src/pages/KontrollPage.tsx b/frontend/src/pages/KontrollPage.tsx index 178df48..ebca54a 100644 --- a/frontend/src/pages/KontrollPage.tsx +++ b/frontend/src/pages/KontrollPage.tsx @@ -170,71 +170,74 @@ export function KontrollPage() { if (sperre) { return ( -
+

Objekt gesperrt

-

- Wird gerade kontrolliert von {sperre.benutzer ?? "unbekannt"}, seit {sperre.seit}. -

- +
+

+ Wird gerade kontrolliert von {sperre.benutzer ?? "unbekannt"}, seit {sperre.seit}. +

+ +
); } if (startFehler) { return ( -
-

{startFehler}

- +
+
{startFehler}
+
); } return ( -
+

{objekt?.name ?? "Kontrolle"}

{!online && ( -

- Kein Netz - Eingaben werden lokal gespeichert und automatisch übertragen, sobald wieder Verbindung besteht. -

+
+ Kein Netz – Eingaben werden lokal gespeichert und automatisch übertragen, sobald wieder Verbindung besteht. +
)} -
    +
      {positionen.map((zustand) => ( -
    • -
      +
    • +
      {zustand.material?.name ?? `Material ${zustand.position.material_id}`}
      -
      +
      Soll: {zustand.position.sollmenge_effektiv} {zustand.material?.einheit ?? ""}
      -
      +
      eingabeAendern(zustand.position.material_id, e.target.value)} - style={{ width: "5rem", fontSize: "1.1rem", padding: "0.5rem" }} + style={{ width: "6rem" }} />
      {zustand.status === "fehler" && ( -
      -

      {zustand.fehlerText}

      -
      @@ -243,29 +246,31 @@ export function KontrollPage() { ))}
    - {abschlussFehler &&

    {abschlussFehler}

    } - - + {abschlussFehler &&
    {abschlussFehler}
    } +
    + + +
); } function StatusBadge({ status }: { status: PositionStatus }) { - const konfiguration: Record = { - unbestaetigt: { text: "offen", farbe: "#999" }, - wartet: { text: "nicht gespeichert", farbe: "#e0a800" }, - wird_uebertragen: { text: "wird übertragen…", farbe: "#e0a800" }, - gespeichert: { text: "gespeichert", farbe: "#2e7d32" }, - fehler: { text: "Fehler", farbe: "#c62828" }, + const konfiguration: Record = { + unbestaetigt: { text: "offen", klasse: "badge-neutral" }, + wartet: { text: "nicht gespeichert", klasse: "badge-warning" }, + wird_uebertragen: { text: "wird übertragen…", klasse: "badge-warning" }, + gespeichert: { text: "gespeichert", klasse: "badge-success" }, + fehler: { text: "Fehler", klasse: "badge-danger" }, }; - const { text, farbe } = konfiguration[status]; - return {text}; + const { text, klasse } = konfiguration[status]; + return {text}; } diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx index 8f7637d..fe1f42e 100644 --- a/frontend/src/pages/LoginPage.tsx +++ b/frontend/src/pages/LoginPage.tsx @@ -26,37 +26,41 @@ export function LoginPage() { } return ( -
-

MABEA

-
- - - {fehler &&

{fehler}

} - -
+
+
+

MABEA

+

+ Digitales Materialmanagement +

+
+
+ + setUsername(e.target.value)} + autoComplete="username" + autoFocus + /> +
+
+ + setPassword(e.target.value)} + autoComplete="current-password" + /> +
+ {fehler &&
{fehler}
} + +
+
); } diff --git a/frontend/src/pages/ObjektListPage.tsx b/frontend/src/pages/ObjektListPage.tsx index eb24125..04f29be 100644 --- a/frontend/src/pages/ObjektListPage.tsx +++ b/frontend/src/pages/ObjektListPage.tsx @@ -3,7 +3,6 @@ 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, @@ -12,12 +11,13 @@ export function ObjektListPage() { const [objekte, setObjekte] = useState([]); const [suche, setSuche] = useState(""); const [ladefehler, setLadefehler] = useState(null); - const { istAdmin } = useAuth(); + const [laedt, setLaedt] = useState(true); useEffect(() => { apiRequest("/objekte") .then(setObjekte) - .catch(() => setLadefehler("Objektliste konnte nicht geladen werden.")); + .catch(() => setLadefehler("Objektliste konnte nicht geladen werden.")) + .finally(() => setLaedt(false)); }, []); const gefiltert = objekte.filter( @@ -27,36 +27,31 @@ export function ObjektListPage() { ); return ( -
+

Objekte

- {istAdmin && ( - - Administration → - - )} setSuche(e.target.value)} - style={{ width: "100%", fontSize: "1.2rem", padding: "0.75rem", marginBottom: "1rem" }} + style={{ marginBottom: "1.25rem" }} /> - {ladefehler &&

{ladefehler}

} -
    + {ladefehler &&
    {ladefehler}
    } + {laedt &&

    Lade…

    } + {!laedt && !ladefehler && gefiltert.length === 0 && ( +

    + {objekte.length === 0 ? "Noch keine Objekte angelegt." : "Keine Treffer."} +

    + )} +
      {gefiltert.map((objekt) => ( -
    • - - {objekt.name} -
      {objekt.code}
      +
    • + +
      + {objekt.name} + {objekt.status} +
      +
      {objekt.code}
    • ))} diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css new file mode 100644 index 0000000..200abee --- /dev/null +++ b/frontend/src/styles/global.css @@ -0,0 +1,323 @@ +:root { + --color-bg: #f4f6f8; + --color-surface: #ffffff; + --color-border: #e2e5e9; + --color-text: #1a1f24; + --color-text-muted: #66707a; + --color-primary: #0b5394; + --color-primary-hover: #084174; + --color-danger: #c62828; + --color-danger-bg: #fdecea; + --color-warning: #b8860b; + --color-warning-bg: #fff8e1; + --color-success: #2e7d32; + --color-success-bg: #e8f5e9; + --color-muted-bg: #eef0f2; + --radius: 10px; + --shadow: 0 1px 3px rgba(0, 0, 0, 0.08), 0 1px 2px rgba(0, 0, 0, 0.06); + --font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; +} + +* { + box-sizing: border-box; +} + +html, +body, +#root { + height: 100%; +} + +body { + margin: 0; + font-family: var(--font-sans); + background: var(--color-bg); + color: var(--color-text); + -webkit-font-smoothing: antialiased; +} + +h1, +h2, +h3 { + margin: 0 0 0.75rem; + line-height: 1.2; +} + +p { + margin: 0 0 0.75rem; +} + +/* -- Layout ------------------------------------------------------------- */ + +.app-shell { + min-height: 100%; + display: flex; + flex-direction: column; +} + +.topbar { + background: var(--color-surface); + border-bottom: 1px solid var(--color-border); + padding: 0.75rem 1.25rem; + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + position: sticky; + top: 0; + z-index: 10; +} + +.topbar-brand { + font-weight: 700; + font-size: 1.1rem; + color: var(--color-primary); + text-decoration: none; +} + +.topbar-nav { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; +} + +.topbar-nav a { + color: var(--color-text-muted); + text-decoration: none; + padding: 0.4rem 0.7rem; + border-radius: var(--radius); + font-size: 0.95rem; +} + +.topbar-nav a:hover, +.topbar-nav a.active { + background: var(--color-muted-bg); + color: var(--color-text); +} + +.page { + flex: 1; + width: 100%; + max-width: 640px; + margin: 0 auto; + padding: 1.25rem; +} + +.page-narrow { + max-width: 380px; +} + +/* -- Cards ---------------------------------------------------------------- */ + +.card { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius); + box-shadow: var(--shadow); + padding: 1rem; + margin-bottom: 0.75rem; +} + +.card-list { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 0.6rem; +} + +.card-link { + display: block; + text-decoration: none; + color: inherit; +} + +.card-link:hover { + border-color: var(--color-primary); +} + +.card-title-row { + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.5rem; +} + +.card-subtitle { + font-size: 0.85rem; + color: var(--color-text-muted); + margin-top: 0.15rem; +} + +/* -- Forms ------------------------------------------------------------ */ + +.field { + display: flex; + flex-direction: column; + gap: 0.3rem; + margin-bottom: 1rem; +} + +.field label { + font-size: 0.9rem; + color: var(--color-text-muted); + font-weight: 600; +} + +.input, +select.input, +textarea.input { + width: 100%; + font-size: 1rem; + padding: 0.65rem 0.75rem; + border: 1px solid var(--color-border); + border-radius: var(--radius); + background: var(--color-surface); + color: var(--color-text); + font-family: inherit; +} + +.input:focus, +select.input:focus { + outline: none; + border-color: var(--color-primary); + box-shadow: 0 0 0 3px rgba(11, 83, 148, 0.15); +} + +.input-search { + font-size: 1.05rem; + padding: 0.85rem 1rem; +} + +/* -- Buttons ------------------------------------------------------------ */ + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.4rem; + font-size: 1rem; + font-weight: 600; + padding: 0.65rem 1.1rem; + border-radius: var(--radius); + border: 1px solid transparent; + cursor: pointer; + transition: background-color 0.15s ease, opacity 0.15s ease; + font-family: inherit; +} + +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.btn-primary { + background: var(--color-primary); + color: #fff; +} + +.btn-primary:hover:not(:disabled) { + background: var(--color-primary-hover); +} + +.btn-secondary { + background: var(--color-surface); + color: var(--color-text); + border-color: var(--color-border); +} + +.btn-secondary:hover:not(:disabled) { + background: var(--color-muted-bg); +} + +.btn-danger { + background: var(--color-surface); + color: var(--color-danger); + border-color: var(--color-danger); +} + +.btn-danger:hover:not(:disabled) { + background: var(--color-danger-bg); +} + +.btn-block { + width: 100%; +} + +/* -- Badges / Alerts ------------------------------------------------------ */ + +.badge { + display: inline-flex; + align-items: center; + font-size: 0.8rem; + font-weight: 700; + padding: 0.2rem 0.6rem; + border-radius: 999px; + white-space: nowrap; +} + +.badge-neutral { + background: var(--color-muted-bg); + color: var(--color-text-muted); +} + +.badge-warning { + background: var(--color-warning-bg); + color: var(--color-warning); +} + +.badge-success { + background: var(--color-success-bg); + color: var(--color-success); +} + +.badge-danger { + background: var(--color-danger-bg); + color: var(--color-danger); +} + +.alert { + border-radius: var(--radius); + padding: 0.75rem 1rem; + margin-bottom: 1rem; + font-size: 0.95rem; +} + +.alert-warning { + background: var(--color-warning-bg); + color: #6b5200; +} + +.alert-danger { + background: var(--color-danger-bg); + color: var(--color-danger); +} + +.text-muted { + color: var(--color-text-muted); +} + +.stack { + display: flex; + flex-direction: column; + gap: 0.6rem; +} + +.row { + display: flex; + gap: 0.6rem; + align-items: center; +} + +.row-between { + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.6rem; +} + +.section { + margin-bottom: 2rem; +}