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,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;
|
||||
}
|
||||
Reference in New Issue
Block a user