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,5 @@
|
||||
node_modules/
|
||||
dist/
|
||||
dev-dist/
|
||||
.env
|
||||
.env.local
|
||||
@@ -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.
|
||||
@@ -0,0 +1 @@
|
||||
VITE_API_BASE_URL=http://192.168.1.238/api/v1
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_BASE_URL: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user