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
+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>
);
}