Sprint 7: React-PWA-Frontend (Offline-Härtung) + Flutter-Android-Grundgerüst
CI / backend-tests (push) Successful in 54s

React/Vite-PWA (frontend/):
- Login, Objektliste (Prompt 11 Screen 1), Kontroll-Screen (Screens 2-6 kompakt:
  Start/Objekt-Sperre-Übernahme, Positionserfassung, Abschluss/Abbruch)
- Offline-Härtung (Prompt 17): IndexedDB-Queue (src/offline/queue.ts) puffert
  Positions-PUTs lokal, automatische Übertragung bei Reconnect + periodischem
  Sync-Versuch, Statusanzeige je Position (nicht gespeichert/wird übertragen/
  gespeichert/Fehler), Abschluss-Button bleibt gesperrt bis alles gespeichert
- Nutzt aus, dass PUT /kontrollen/{id}/positionen/{material_id} backend-seitig
  idempotent ist (Upsert) - Queue kann beliebig oft retryen ohne Duplikate
- Service Worker via vite-plugin-pwa für App-Shell-Caching (Start im Feld ohne
  Cold-Load); PWA-Icons als TODO vermerkt (noch keine echten Bilddateien)
- Client-Fehler (4xx) werden nicht automatisch wiederholt, nur Netzwerkfehler

Flutter-Android-Grundgerüst (flutter_app/), auf Nutzerwunsch parallel begonnen:
- Gleiche API als zweiter Client (Prompt 19 API-first), Login/Objektliste/
  Kontroll-Screen als Dart-Äquivalent zur PWA
- Bewusst OHNE Offline-Queue in dieser ersten Fassung (siehe flutter_app/README.md)
- Plattform-Ordner (android/, ios/) nicht von Hand erzeugt - müssen auf dem
  Zielsystem per `flutter create .` nachgezogen werden, sonst zu fehleranfällig
  ohne Testlauf

Offen: Playwright-E2E (Testphase 4), Praktiker-Session, PWA-Icons, Flutter-
Offline-Queue - brauchen laufendes Deployment bzw. sind kein reiner Code-Schritt.

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:10:26 +02:00
co-authored by Claude Sonnet 5
parent aba991bbe2
commit 564e98e5c6
28 changed files with 1624 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
node_modules/
dist/
dev-dist/
.env
.env.local
+44
View File
@@ -0,0 +1,44 @@
# MABEA Frontend Sprint 7
React + Vite PWA. Deckt Prompt 11 Screens 1-6 (kompakt zusammengefasst: Objektliste,
Kontrolle mit Positionserfassung, Abschluss/Abbruch) und Prompt 17 (Offline-Härtung:
IndexedDB-Queue, Statusanzeige je Position, Abschluss blockiert bis alles gespeichert) ab.
## Setup (auf dem Zielsystem, nicht lokal)
```bash
npm install
cp example.env .env
# .env: VITE_API_BASE_URL auf die Backend-URL setzen
npm run build
```
`npm run build` erzeugt `dist/` inkl. Service Worker (vite-plugin-pwa) - dieser Ordner
wird vom Reverse-Proxy (nginx) ausgeliefert, siehe `deploy/`.
## Fehlende Assets vor Produktivbetrieb
- `public/icon-192.png`, `public/icon-512.png` (PWA-Manifest-Icons) müssen noch ergänzt
werden - aktuell nur in `vite.config.ts` referenziert, keine echten Bilddateien vorhanden.
## Architektur-Entscheidungen (Sprint 7)
- **Offline-Queue** (`src/offline/queue.ts`): IndexedDB statt reinem Service-Worker-Cache,
weil tatsächliche Formulardaten (Ist-Mengen) über Reload/Absturz hinweg erhalten bleiben
müssen. Der Service Worker selbst cached nur den App-Shell (Assets), keine API-Requests.
- **Idempotenz**: `PUT /kontrollen/{id}/positionen/{material_id}` ist backend-seitig ein
Upsert (siehe `backend/app/services/kontrolle.py`) - die Queue kann denselben Eintrag
beliebig oft erneut senden, ohne doppelte Kontrollpositionen/Fehlbestände zu erzeugen.
- **Statusanzeige**: jede Position zeigt "nicht gespeichert" / "wird übertragen…" /
"gespeichert" / "Fehler" - kein stiller Datenverlust bei Verbindungsabbruch (Prompt 17.5).
- **Abschluss-Sperre**: der Abschließen-Button ist deaktiviert, solange auch nur eine
Position nicht bestätigt gespeichert ist.
- Client-Fehler (4xx, z. B. Objekt-Sperre durch Übernahme) werden NICHT automatisch
wiederholt - der Mitarbeiter sieht den Fehler und muss bewusst reagieren.
## Noch nicht umgesetzt (bewusst außerhalb Sprint 7)
- QR-/Barcode-Kamera-Scan (Karte 10, Roadmap) - Objektsuche ist vorbereitet (Textfeld
akzeptiert auch einen gescannten Code), aber kein Kamera-Zugriff verdrahtet.
- Playwright-E2E-Tests und Praktiker-Explorations-Session (Testphase 4) - dafür wird ein
laufendes Test-Deployment gebraucht, kein reiner Code-Schritt.
+1
View File
@@ -0,0 +1 @@
VITE_API_BASE_URL=http://192.168.1.238/api/v1
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#0b5394" />
<title>MABEA</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+25
View File
@@ -0,0 +1,25 @@
{
"name": "mabea-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview"
},
"dependencies": {
"idb-keyval": "^6.2.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.2"
},
"devDependencies": {
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"typescript": "^5.5.4",
"vite": "^5.4.6",
"vite-plugin-pwa": "^0.20.5"
}
}
+42
View File
@@ -0,0 +1,42 @@
import type { ReactNode } from "react";
import { Navigate, Route, Routes } from "react-router-dom";
import { AuthProvider, useAuth } from "./auth/AuthContext";
import { KontrollPage } from "./pages/KontrollPage";
import { LoginPage } from "./pages/LoginPage";
import { ObjektListPage } from "./pages/ObjektListPage";
function GeschuetzteRoute({ children }: { children: ReactNode }) {
const { eingeloggt } = useAuth();
if (!eingeloggt) {
return <Navigate to="/login" replace />;
}
return <>{children}</>;
}
export default function App() {
return (
<AuthProvider>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route
path="/objekte"
element={
<GeschuetzteRoute>
<ObjektListPage />
</GeschuetzteRoute>
}
/>
<Route
path="/objekte/:objektId/kontrolle"
element={
<GeschuetzteRoute>
<KontrollPage />
</GeschuetzteRoute>
}
/>
<Route path="*" element={<Navigate to="/objekte" replace />} />
</Routes>
</AuthProvider>
);
}
+79
View File
@@ -0,0 +1,79 @@
const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "/api/v1";
let authToken: string | null = localStorage.getItem("mabea_token");
export function setAuthToken(token: string | null): void {
authToken = token;
if (token) {
localStorage.setItem("mabea_token", token);
} else {
localStorage.removeItem("mabea_token");
}
}
export function getAuthToken(): string | null {
return authToken;
}
export class ApiError extends Error {
status: number;
detail: unknown;
constructor(status: number, detail: unknown) {
super(`API-Fehler ${status}`);
this.status = status;
this.detail = detail;
}
}
interface RequestOptions {
method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
body?: unknown;
}
/**
* Generischer JSON-Client. Wirft ApiError bei Nicht-2xx-Antworten, damit
* aufrufender Code (insb. die Offline-Queue) gezielt auf HTTP-Status reagieren
* kann (z. B. 409 bei Objekt-Sperre nicht wie ein Netzwerkfehler behandeln).
*/
export async function apiRequest<T>(path: string, options: RequestOptions = {}): Promise<T> {
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (authToken) {
headers["Authorization"] = `Bearer ${authToken}`;
}
const response = await fetch(`${BASE_URL}${path}`, {
method: options.method ?? "GET",
headers,
body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
});
if (!response.ok) {
let detail: unknown = null;
try {
detail = await response.json();
} catch {
// Antwort ohne JSON-Body (z. B. 204) - detail bleibt null.
}
throw new ApiError(response.status, detail);
}
if (response.status === 204) {
return undefined as T;
}
return (await response.json()) as T;
}
export async function login(username: string, password: string): Promise<string> {
const body = new URLSearchParams({ username, password });
const response = await fetch(`${BASE_URL}/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body,
});
if (!response.ok) {
throw new ApiError(response.status, await response.json().catch(() => null));
}
const data = (await response.json()) as { access_token: string };
return data.access_token;
}
+49
View File
@@ -0,0 +1,49 @@
export interface Objekt {
id: number;
code: string;
name: string;
objekttyp_id: number;
vorlage_id: number | null;
standort_id: number;
status: string;
}
export interface Objektposition {
id: string;
objekt_id: number;
material_id: number;
sollmenge_override: string | null;
sollmenge_effektiv: string;
ist_status: string;
istmenge: string;
seriennummer: string | null;
ablaufdatum: string | null;
chargennummer: string | null;
}
export interface Kontrolle {
id: string;
objekt_id: number;
benutzer_id: number;
status: "nicht_gestartet" | "in_bearbeitung" | "abgeschlossen" | "abgebrochen";
gestartet_am: string;
beendet_am: string | null;
abbruch_grund: string | null;
}
export interface Kontrollposition {
id: string;
kontrolle_id: string;
material_id: number;
sollmenge_snapshot: string;
istmenge_erfasst: string;
abweichung: boolean;
fehlbestand_id: string | null;
}
export interface Material {
id: number;
name: string;
einheit: string;
materialtyp: string;
}
+41
View File
@@ -0,0 +1,41 @@
import { createContext, useContext, useMemo, useState, type ReactNode } from "react";
import { getAuthToken, login as apiLogin, setAuthToken } from "../api/client";
interface AuthContextValue {
eingeloggt: boolean;
login: (username: string, password: string) => Promise<void>;
logout: () => void;
}
const AuthContext = createContext<AuthContextValue | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [eingeloggt, setEingeloggt] = useState<boolean>(getAuthToken() !== null);
const value = useMemo<AuthContextValue>(
() => ({
eingeloggt,
login: async (username: string, password: string) => {
const token = await apiLogin(username, password);
setAuthToken(token);
setEingeloggt(true);
},
logout: () => {
setAuthToken(null);
setEingeloggt(false);
},
}),
[eingeloggt]
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext);
if (!ctx) {
throw new Error("useAuth muss innerhalb von AuthProvider verwendet werden");
}
return ctx;
}
+13
View File
@@ -0,0 +1,13 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import App from "./App";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>
);
+114
View File
@@ -0,0 +1,114 @@
import { createStore, del, entries, set } from "idb-keyval";
import { apiRequest, ApiError } from "../api/client";
import type { Kontrollposition } from "../api/types";
// Prompt 17: Eingaben werden lokal zwischengespeichert, sobald eingegeben - nicht
// erst bei Übertragung an den Server verworfen, wenn die Verbindung kurz fehlt.
// IndexedDB statt reinem Service-Worker-Cache, weil hier tatsächliche Nutzdaten
// (Ist-Mengen) über einen Neustart/Tab-Wechsel hinweg erhalten bleiben müssen.
const store = createStore("mabea-offline-queue", "kontrollpositionen");
export type QueueStatus = "wartet" | "wird_uebertragen" | "fehler";
export interface QueueItem {
key: string; // `${kontrolleId}:${materialId}`
kontrolleId: string;
materialId: number;
istmenge: string;
status: QueueStatus;
fehlerText: string | null;
fehlbestandId: string | null; // wird nach erfolgreicher Übertragung befüllt
}
function keyFor(kontrolleId: string, materialId: number): string {
return `${kontrolleId}:${materialId}`;
}
export async function enqueue(kontrolleId: string, materialId: number, istmenge: string): Promise<void> {
const key = keyFor(kontrolleId, materialId);
const item: QueueItem = {
key,
kontrolleId,
materialId,
istmenge,
status: "wartet",
fehlerText: null,
fehlbestandId: null,
};
await set(key, item, store);
}
export async function alleEintraege(): Promise<QueueItem[]> {
const all = await entries<string, QueueItem>(store);
return all.map(([, value]) => value);
}
export async function eintraegeFuerKontrolle(kontrolleId: string): Promise<QueueItem[]> {
const all = await alleEintraege();
return all.filter((item) => item.kontrolleId === kontrolleId);
}
/** Prompt 16: Abschluss darf erst möglich sein, wenn nichts mehr unbestätigt in
* der Warteschlange liegt (weder "wartet" noch "wird_uebertragen"). Ein
* "fehler"-Eintrag blockiert ebenfalls - der Mitarbeiter muss ihn sehen und
* bewusst erneut versuchen, kein stiller Datenverlust. */
export async function hatUnbestaetigtePositionen(kontrolleId: string): Promise<boolean> {
const eintraege = await eintraegeFuerKontrolle(kontrolleId);
return eintraege.length > 0;
}
async function sendeEintrag(item: QueueItem): Promise<void> {
await set(item.key, { ...item, status: "wird_uebertragen" as QueueStatus }, store);
try {
const ergebnis = await apiRequest<Kontrollposition>(
`/kontrollen/${item.kontrolleId}/positionen/${item.materialId}`,
{ method: "PUT", body: { istmenge: item.istmenge } }
);
// Erfolgreich übertragen (idempotent - ein erneutes Senden desselben Werts
// durch die Queue vor einem Absturz führt zu keinem doppelten Fehlbestand,
// siehe Backend app/services/kontrolle.py erfasse_position) -> aus Queue entfernen.
void ergebnis;
await del(item.key, store);
} catch (error) {
if (error instanceof ApiError && error.status >= 400 && error.status < 500) {
// Client-Fehler (z. B. 403 Objekt-Sperre an anderen Benutzer übergegangen,
// 404 Kontrolle nicht mehr aktiv) - erneutes Senden würde nichts ändern,
// aber der Eintrag bleibt sichtbar in der Queue, bis der Mitarbeiter reagiert.
await set(
item.key,
{
...item,
status: "fehler" as QueueStatus,
fehlerText: `Server hat abgelehnt (${error.status}) - bitte Kontrolle neu prüfen.`,
},
store
);
} else {
// Netzwerkfehler - bleibt "wartet", nächster Sync-Versuch übernimmt wieder.
await set(item.key, { ...item, status: "wartet" as QueueStatus }, store);
}
throw error;
}
}
/** Wird bei Reconnect (online-Event) und periodisch aufgerufen. Sendet alle
* wartenden Einträge, bricht bei Netzwerkfehlern early ab (weitere Versuche
* würden ohnehin scheitern, solange keine Verbindung besteht). */
export async function synchronisiereQueue(): Promise<void> {
const eintraege = await alleEintraege();
for (const item of eintraege) {
if (item.status === "fehler") continue; // braucht bewusste Nutzer-Aktion, kein Auto-Retry
try {
await sendeEintrag(item);
} catch (error) {
if (!(error instanceof ApiError)) {
return; // vermutlich offline - restliche Queue in Ruhe lassen, nicht durchhämmern
}
}
}
}
export async function entferneFehlerEintrag(kontrolleId: string, materialId: number): Promise<void> {
await del(keyFor(kontrolleId, materialId), store);
}
+37
View File
@@ -0,0 +1,37 @@
import { useEffect, useState } from "react";
import { synchronisiereQueue } from "./queue";
/** Prompt 17: bei wiederhergestellter Verbindung automatische Übertragung der
* zwischengespeicherten Eingaben, ohne dass der Mitarbeiter erneut eintippt. */
export function useOnlineStatus(): boolean {
const [online, setOnline] = useState(navigator.onLine);
useEffect(() => {
const handleOnline = () => {
setOnline(true);
void synchronisiereQueue();
};
const handleOffline = () => setOnline(false);
window.addEventListener("online", handleOnline);
window.addEventListener("offline", handleOffline);
// Periodischer Versuch: "online" laut Browser heißt nicht zwingend, dass der
// Backend-Server erreichbar ist (z. B. WLAN ohne Internet). 15s ist ein
// pragmatischer Kompromiss zwischen Akkuverbrauch und Reaktionszeit.
const interval = window.setInterval(() => {
if (navigator.onLine) {
void synchronisiereQueue();
}
}, 15000);
return () => {
window.removeEventListener("online", handleOnline);
window.removeEventListener("offline", handleOffline);
window.clearInterval(interval);
};
}, []);
return online;
}
+271
View File
@@ -0,0 +1,271 @@
import { useEffect, useMemo, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { apiRequest, ApiError } from "../api/client";
import type { Kontrolle, Material, Objekt, Objektposition } from "../api/types";
import { useOnlineStatus } from "../offline/useOnlineStatus";
import { enqueue, eintraegeFuerKontrolle, entferneFehlerEintrag, synchronisiereQueue } from "../offline/queue";
import type { QueueStatus } from "../offline/queue";
type PositionStatus = "unbestaetigt" | QueueStatus | "gespeichert";
interface PositionZustand {
position: Objektposition;
material: Material | undefined;
status: PositionStatus;
eingabe: string;
fehlerText: string | null;
}
interface Sperrmeldung {
benutzer: string | null;
seit: string;
}
// Prompt 11 Screens 2-6 (kompakt zusammengefasst) + Prompt 17 Offline-Härtung:
// Start -> Positionen erfassen (über die lokale Queue, nie direkt blockierend) ->
// Abschluss erst möglich, wenn nichts mehr in der Queue unbestätigt liegt.
export function KontrollPage() {
const { objektId } = useParams<{ objektId: string }>();
const navigate = useNavigate();
const online = useOnlineStatus();
const [objekt, setObjekt] = useState<Objekt | null>(null);
const [kontrolle, setKontrolle] = useState<Kontrolle | null>(null);
const [sperre, setSperre] = useState<Sperrmeldung | null>(null);
const [positionen, setPositionen] = useState<PositionZustand[]>([]);
const [startFehler, setStartFehler] = useState<string | null>(null);
const [wirdAbgeschlossen, setWirdAbgeschlossen] = useState(false);
const [abschlussFehler, setAbschlussFehler] = useState<string | null>(null);
useEffect(() => {
if (!objektId) return;
apiRequest<Objekt>(`/objekte/${objektId}`).then(setObjekt).catch(() => undefined);
}, [objektId]);
async function starteKontrolle(uebernehmen: boolean) {
if (!objektId) return;
setStartFehler(null);
try {
const neueKontrolle = await apiRequest<Kontrolle>(`/objekte/${objektId}/kontrollen`, {
method: "POST",
body: { uebernehmen },
});
setKontrolle(neueKontrolle);
setSperre(null);
} catch (error) {
if (error instanceof ApiError && error.status === 409) {
const detail = error.detail as { benutzer?: string; seit?: string } | null;
setSperre({ benutzer: detail?.benutzer ?? null, seit: detail?.seit ?? "" });
} else {
setStartFehler("Kontrolle konnte nicht gestartet werden (keine Verbindung?).");
}
}
}
useEffect(() => {
void starteKontrolle(false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [objektId]);
useEffect(() => {
if (!kontrolle) return;
Promise.all([
apiRequest<Objektposition[]>(`/objekte/${kontrolle.objekt_id}/positionen`),
apiRequest<Material[]>("/materialien"),
]).then(([positionsListe, materialListe]) => {
const materialById = new Map(materialListe.map((m) => [m.id, m]));
setPositionen(
positionsListe
.filter((p) => p.ist_status === "aktiv")
.map((p) => ({
position: p,
material: materialById.get(p.material_id),
status: "unbestaetigt",
eingabe: p.sollmenge_effektiv,
fehlerText: null,
}))
);
});
}, [kontrolle]);
// Reconciliation-Loop: gleicht lokalen Anzeigezustand mit der Offline-Queue ab.
// Ein Eintrag, der aus der Queue verschwunden ist, wurde erfolgreich übertragen
// (queue.ts entfernt Einträge ausschließlich bei Erfolg, siehe dort).
useEffect(() => {
if (!kontrolle) return;
const interval = window.setInterval(async () => {
const eintraege = await eintraegeFuerKontrolle(kontrolle.id);
const byMaterial = new Map(eintraege.map((e) => [e.materialId, e]));
setPositionen((bisherige) =>
bisherige.map((zustand) => {
const inQueue = byMaterial.get(zustand.position.material_id);
if (inQueue) {
return { ...zustand, status: inQueue.status, fehlerText: inQueue.fehlerText };
}
if (zustand.status === "wartet" || zustand.status === "wird_uebertragen") {
return { ...zustand, status: "gespeichert", fehlerText: null };
}
return zustand;
})
);
}, 2000);
return () => window.clearInterval(interval);
}, [kontrolle]);
useEffect(() => {
if (online) void synchronisiereQueue();
}, [online]);
function eingabeAendern(materialId: number, wert: string) {
setPositionen((bisherige) =>
bisherige.map((z) => (z.position.material_id === materialId ? { ...z, eingabe: wert } : z))
);
}
async function positionSenden(materialId: number, istmenge: string) {
if (!kontrolle) return;
await enqueue(kontrolle.id, materialId, istmenge);
setPositionen((bisherige) =>
bisherige.map((z) => (z.position.material_id === materialId ? { ...z, status: "wartet" } : z))
);
void synchronisiereQueue();
}
async function fehlerErneutVersuchen(materialId: number, istmenge: string) {
if (!kontrolle) return;
await entferneFehlerEintrag(kontrolle.id, materialId);
await positionSenden(materialId, istmenge);
}
const alleUebertragen = useMemo(
() => positionen.length > 0 && positionen.every((p) => p.status === "gespeichert"),
[positionen]
);
async function abschliessen() {
if (!kontrolle) return;
setWirdAbgeschlossen(true);
setAbschlussFehler(null);
try {
await apiRequest(`/kontrollen/${kontrolle.id}/abschliessen`, { method: "POST" });
navigate("/objekte");
} catch (error) {
if (error instanceof ApiError && error.status === 409) {
setAbschlussFehler("Nicht alle Positionen wurden erfasst.");
} else {
setAbschlussFehler("Abschluss fehlgeschlagen (keine Verbindung?).");
}
} finally {
setWirdAbgeschlossen(false);
}
}
async function abbrechen() {
if (!kontrolle) return;
const grund = window.prompt("Grund für den Abbruch (optional):") ?? undefined;
await apiRequest(`/kontrollen/${kontrolle.id}/abbrechen`, { method: "POST", body: { grund } });
navigate("/objekte");
}
if (sperre) {
return (
<main style={{ maxWidth: 480, margin: "0 auto", padding: "1rem" }}>
<h1>Objekt gesperrt</h1>
<p>
Wird gerade kontrolliert von <strong>{sperre.benutzer ?? "unbekannt"}</strong>, seit {sperre.seit}.
</p>
<button onClick={() => starteKontrolle(true)} style={{ padding: "0.75rem 1.5rem" }}>
Trotzdem übernehmen
</button>
</main>
);
}
if (startFehler) {
return (
<main style={{ maxWidth: 480, margin: "0 auto", padding: "1rem" }}>
<p style={{ color: "crimson" }}>{startFehler}</p>
<button onClick={() => starteKontrolle(false)}>Erneut versuchen</button>
</main>
);
}
return (
<main style={{ maxWidth: 480, margin: "0 auto", padding: "1rem" }}>
<h1>{objekt?.name ?? "Kontrolle"}</h1>
{!online && (
<p style={{ background: "#fff3cd", padding: "0.5rem", borderRadius: 4 }}>
Kein Netz - Eingaben werden lokal gespeichert und automatisch übertragen, sobald wieder Verbindung besteht.
</p>
)}
<ul style={{ listStyle: "none", padding: 0 }}>
{positionen.map((zustand) => (
<li
key={zustand.position.id}
style={{
border: "1px solid #ccc",
borderRadius: 8,
padding: "0.75rem",
marginBottom: "0.5rem",
}}
>
<div style={{ display: "flex", justifyContent: "space-between" }}>
<strong>{zustand.material?.name ?? `Material ${zustand.position.material_id}`}</strong>
<StatusBadge status={zustand.status} />
</div>
<div style={{ fontSize: "0.85rem", color: "#666" }}>
Soll: {zustand.position.sollmenge_effektiv} {zustand.material?.einheit ?? ""}
</div>
<div style={{ display: "flex", gap: "0.5rem", marginTop: "0.5rem" }}>
<input
type="number"
value={zustand.eingabe}
onChange={(e) => eingabeAendern(zustand.position.material_id, e.target.value)}
style={{ width: "5rem", fontSize: "1.1rem", padding: "0.5rem" }}
/>
<button
onClick={() => positionSenden(zustand.position.material_id, zustand.eingabe)}
style={{ flex: 1, fontSize: "1.1rem" }}
>
Bestätigen
</button>
</div>
{zustand.status === "fehler" && (
<div style={{ marginTop: "0.5rem" }}>
<p style={{ color: "crimson", margin: 0 }}>{zustand.fehlerText}</p>
<button onClick={() => fehlerErneutVersuchen(zustand.position.material_id, zustand.eingabe)}>
Erneut versuchen
</button>
</div>
)}
</li>
))}
</ul>
{abschlussFehler && <p style={{ color: "crimson" }}>{abschlussFehler}</p>}
<button
onClick={abschliessen}
disabled={!alleUebertragen || wirdAbgeschlossen}
style={{ width: "100%", padding: "1rem", fontSize: "1.2rem" }}
>
{alleUebertragen ? "Kontrolle abschließen" : "Noch nicht alle Positionen gespeichert…"}
</button>
<button onClick={abbrechen} style={{ width: "100%", padding: "0.75rem", marginTop: "0.5rem" }}>
Kontrolle abbrechen
</button>
</main>
);
}
function StatusBadge({ status }: { status: PositionStatus }) {
const konfiguration: Record<PositionStatus, { text: string; farbe: string }> = {
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 { text, farbe } = konfiguration[status];
return <span style={{ color: farbe, fontWeight: "bold" }}>{text}</span>;
}
+62
View File
@@ -0,0 +1,62 @@
import { useState, type FormEvent } from "react";
import { useNavigate } from "react-router-dom";
import { useAuth } from "../auth/AuthContext";
export function LoginPage() {
const { login } = useAuth();
const navigate = useNavigate();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [fehler, setFehler] = useState<string | null>(null);
const [wirdGeladen, setWirdGeladen] = useState(false);
async function handleSubmit(event: FormEvent) {
event.preventDefault();
setFehler(null);
setWirdGeladen(true);
try {
await login(username, password);
navigate("/objekte");
} catch {
setFehler("Login oder Passwort falsch");
} finally {
setWirdGeladen(false);
}
}
return (
<main style={{ maxWidth: 320, margin: "4rem auto", padding: "0 1rem" }}>
<h1>MABEA</h1>
<form onSubmit={handleSubmit}>
<label>
Login
<input
style={{ display: "block", width: "100%", fontSize: "1.2rem", padding: "0.5rem" }}
value={username}
onChange={(e) => setUsername(e.target.value)}
autoComplete="username"
/>
</label>
<label style={{ display: "block", marginTop: "1rem" }}>
Passwort
<input
style={{ display: "block", width: "100%", fontSize: "1.2rem", padding: "0.5rem" }}
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
/>
</label>
{fehler && <p style={{ color: "crimson" }}>{fehler}</p>}
<button
type="submit"
disabled={wirdGeladen}
style={{ marginTop: "1rem", width: "100%", fontSize: "1.2rem", padding: "0.75rem" }}
>
{wirdGeladen ? "Wird geprüft…" : "Anmelden"}
</button>
</form>
</main>
);
}
+59
View File
@@ -0,0 +1,59 @@
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { apiRequest } from "../api/client";
import type { Objekt } from "../api/types";
// 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,
// ein späterer Kamera-Scan kann denselben Code einfach in dasselbe Feld schreiben.
export function ObjektListPage() {
const [objekte, setObjekte] = useState<Objekt[]>([]);
const [suche, setSuche] = useState("");
const [ladefehler, setLadefehler] = useState<string | null>(null);
useEffect(() => {
apiRequest<Objekt[]>("/objekte")
.then(setObjekte)
.catch(() => setLadefehler("Objektliste konnte nicht geladen werden."));
}, []);
const gefiltert = objekte.filter(
(o) =>
o.name.toLowerCase().includes(suche.toLowerCase()) ||
o.code.toLowerCase().includes(suche.toLowerCase())
);
return (
<main style={{ maxWidth: 480, margin: "0 auto", padding: "1rem" }}>
<h1>Objekte</h1>
<input
placeholder="Suche oder Code scannen…"
value={suche}
onChange={(e) => setSuche(e.target.value)}
style={{ width: "100%", fontSize: "1.2rem", padding: "0.75rem", marginBottom: "1rem" }}
/>
{ladefehler && <p style={{ color: "crimson" }}>{ladefehler}</p>}
<ul style={{ listStyle: "none", padding: 0 }}>
{gefiltert.map((objekt) => (
<li key={objekt.id} style={{ marginBottom: "0.5rem" }}>
<Link
to={`/objekte/${objekt.id}/kontrolle`}
style={{
display: "block",
padding: "1rem",
border: "1px solid #ccc",
borderRadius: 8,
textDecoration: "none",
color: "inherit",
}}
>
<strong>{objekt.name}</strong>
<div style={{ fontSize: "0.9rem", color: "#666" }}>{objekt.code}</div>
</Link>
</li>
))}
</ul>
</main>
);
}
+9
View File
@@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_BASE_URL: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
+19
View File
@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
+36
View File
@@ -0,0 +1,36 @@
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
import { VitePWA } from "vite-plugin-pwa";
// MABEA PWA (Sprint 7 / Prompt 17): Service Worker cached App-Shell für schnellen
// Start im Einsatzumfeld. Die eigentliche Offline-Pufferung von Kontroll-Eingaben
// läuft NICHT über den Service Worker, sondern über eine IndexedDB-Queue
// (src/offline/queue.ts) - Service Worker allein reicht für Formular-Requests
// nicht aus, ohne Background-Sync-Komplexität einzuführen.
export default defineConfig({
plugins: [
react(),
VitePWA({
registerType: "autoUpdate",
workbox: {
globPatterns: ["**/*.{js,css,html,svg,png,ico}"],
},
manifest: {
name: "MABEA",
short_name: "MABEA",
description: "Digitales Materialmanagement Rettungsdienst/KatS",
theme_color: "#0b5394",
background_color: "#ffffff",
display: "standalone",
start_url: "/",
icons: [
{ src: "icon-192.png", sizes: "192x192", type: "image/png" },
{ src: "icon-512.png", sizes: "512x512", type: "image/png" },
],
},
}),
],
server: {
port: 5173,
},
});