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