Sprint 7: React-PWA-Frontend (Offline-Härtung) + Flutter-Android-Grundgerüst
CI / backend-tests (push) Successful in 54s
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:
@@ -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>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user