diff --git a/arbeitskacheln/15_mobile.md b/arbeitskacheln/15_mobile.md index 7e1ecba..e2992fb 100644 --- a/arbeitskacheln/15_mobile.md +++ b/arbeitskacheln/15_mobile.md @@ -95,10 +95,29 @@ Details zu MOBILE-001 … MOBILE-005 (vollständig). - **Kein neuer Unit-Test** — folgt demselben (bestehenden) Präzedenzfall: `offline/queue.ts` (Kontrolle) hat ebenfalls keinen automatisierten Test, IndexedDB-Interaktion ist hier bewusst nicht isoliert getestet. - - **Bewusst NICHT abgedeckt:** alle anderen Schreib-Aktionen (Geräte- - Status ändern, Wartungsauftrag anlegen, Lagerbuchungen, …) bleiben ohne - Offline-Queue — schlagen bei Verbindungsverlust weiterhin sofort fehl, - wie vorher. Kein generischer Offline-Layer, siehe Scope-Entscheidung oben. + - **Update 2026-09-13 (Nutzer-Entscheidung: bisherige Scope-Begrenzung + bewusst revidiert):** Gerätestatus-Änderung, Wartungsauftrag-Anlage und + Lagerbuchungen (Ein-/Auslagern/Umlagern) laufen jetzt ebenfalls über + Offline-Queues — weiterhin KEIN genereller "beliebiger Endpunkt"-Layer + (das bleibt abgelehnt), sondern fünf eigenständige, feature-spezifische + Queues, die sich eine gemeinsame Sync-Mechanik teilen + (`frontend/src/offline/draftQueue.ts`, neu: `erzeugeDraftQueue()` + kapselt enqueue/sendeEintrag/synchronisiere, jede Queue bringt nur + eigenen Item-Typ + `sende()`-Funktion mit). Neue Dateien: + `geraetStatusQueue.ts`, `wartungQueue.ts`, `lagerbewegungQueue.ts`. + **Bewusster Unterschied bei Lagerbuchungen:** anders als die anderen vier + Queues (immer sofort in die Queue, dann Sync-Versuch) prüft + `LagerSection.tsx` zuerst online, ob die Buchung an echten + Geschäftsregeln scheitert (z.B. "nicht genug Bestand", ein `ApiError`, + 4xx) — nur ein echter Netzwerkfehler (kein `ApiError`) landet in der + Queue. Ein Bestandskonflikt soll sofort sichtbar sein, nicht erst beim + späteren Sync-Versuch unbemerkt fehlschlagen. `useOnlineStatus.ts` + synchronisiert jetzt alle fünf Queues bei Reconnect, `SyncStatus.tsx` + zählt wartende Einträge aus allen zusammen (zwei Feldnamen-Generationen: + ältere Queues `status`, neue `queueStatus`, bewusst nicht vereinheitlicht + nur für diese eine Zählung). **Kein neuer Unit-Test** — folgt demselben + Präzedenzfall wie die ursprüngliche Kontroll-/Mangel-Queue (IndexedDB- + Interaktion bleibt bewusst nicht isoliert getestet, siehe oben). - **Referenz (Open-Source-Vergleich 2026-09-09):** KP Front (`feuerwehr-oberwil/kp-front`, identischer Stack FastAPI/PostgreSQL/React-Vite-PWA, AGPL-3.0-or-later — nur Muster, keine Codeübernahme) löst das nicht als globale Offline-Queue, sondern als diff --git a/frontend/src/components/WartungSection.tsx b/frontend/src/components/WartungSection.tsx index 5b1c1d6..03d0293 100644 --- a/frontend/src/components/WartungSection.tsx +++ b/frontend/src/components/WartungSection.tsx @@ -3,6 +3,7 @@ import { useEffect, useState } from "react"; import { apiRequest } from "../api/client"; import { useAuth } from "../auth/AuthContext"; import type { ExterneStelle, FaelligeWartung, Wartungsauftrag, WartungsauftragTeil } from "../api/types"; +import * as wartungQueue from "../offline/wartungQueue"; import { DokumentePanel } from "./DokumentePanel"; interface Props { @@ -75,21 +76,20 @@ export function WartungSection({ objektId, onFehler }: Props) { onFehler("Wartungsart angeben."); return; } + // MOBILE-003-Erweiterung: über die Offline-Queue statt blockierend. try { - await apiRequest("/wartungsauftraege", { - method: "POST", - body: { - objekt_id: objektId, - wartungsart: neuArt, - werkstatt_intern: !neuExtern, - werkstatt_name: neuExtern && neuDienstleisterId === "" ? neuWerkstattName || null : null, - dienstleister_id: neuExtern && neuDienstleisterId !== "" ? neuDienstleisterId : null, - }, + await wartungQueue.enqueue({ + objektId, + wartungsart: neuArt, + werkstattIntern: !neuExtern, + werkstattName: neuExtern && neuDienstleisterId === "" ? neuWerkstattName || null : null, + dienstleisterId: neuExtern && neuDienstleisterId !== "" ? neuDienstleisterId : null, }); setNeuArt(""); setNeuExtern(false); setNeuWerkstattName(""); setNeuDienstleisterId(""); + await wartungQueue.synchronisiereWartungQueue(); await laden(); } catch { onFehler("Wartungsauftrag konnte nicht angelegt werden."); diff --git a/frontend/src/components/status/SyncStatus.tsx b/frontend/src/components/status/SyncStatus.tsx index f6d01be..a8707ee 100644 --- a/frontend/src/components/status/SyncStatus.tsx +++ b/frontend/src/components/status/SyncStatus.tsx @@ -1,14 +1,22 @@ import { useEffect, useState } from "react"; -import { alleEintraege } from "../../offline/queue"; +import { alleEintraege as alleGeraetStatusEintraege } from "../../offline/geraetStatusQueue"; +import { alleEintraege as alleLagerbewegungEintraege } from "../../offline/lagerbewegungQueue"; import { alleEintraege as alleMangelEintraege } from "../../offline/mangelQueue"; +import { alleEintraege } from "../../offline/queue"; import { useOnlineStatus } from "../../offline/useOnlineStatus"; +import { alleEintraege as alleWartungEintraege } from "../../offline/wartungQueue"; /** * Online-/Offline-/Sync-Status war bisher nirgends sichtbar * (useOnlineStatus/Queue existierten nur intern für den Kontroll-Workflow) - * echte Lücke laut Redesign-Vorgabe, kein reiner Umbau. MOBILE-003: zählt - * jetzt beide Offline-Queues (Kontrolle + Mangelmeldung) zusammen. + * jetzt alle fünf Offline-Queues zusammen (Kontrolle, Mangelmeldung, + * Gerätestatus, Wartungsauftrag, Lagerbewegung - Scope 2026-09-13 + * erweitert). Die neueren vier Queues nutzen `queueStatus` (draftQueue.ts), + * die ursprüngliche Kontroll-Queue noch `status` (queue.ts) - beide + * Feldnamen hier separat behandelt statt eine gemeinsame Normalisierung + * einzuführen, die nur für diese eine Zählung gebraucht würde. */ export function SyncStatus() { const online = useOnlineStatus(); @@ -17,8 +25,16 @@ export function SyncStatus() { useEffect(() => { let aktiv = true; async function pruefen() { - const [kontrolle, mangel] = await Promise.all([alleEintraege(), alleMangelEintraege()]); - const gesamt = [...kontrolle, ...mangel].filter((e) => e.status !== "fehler").length; + const [kontrolle, mangel, geraetStatus, wartung, lagerbewegung] = await Promise.all([ + alleEintraege(), + alleMangelEintraege(), + alleGeraetStatusEintraege(), + alleWartungEintraege(), + alleLagerbewegungEintraege(), + ]); + const gesamt = + [...kontrolle, ...mangel].filter((e) => e.status !== "fehler").length + + [...geraetStatus, ...wartung, ...lagerbewegung].filter((e) => e.queueStatus !== "fehler").length; if (aktiv) setWartend(gesamt); } void pruefen(); diff --git a/frontend/src/offline/draftQueue.ts b/frontend/src/offline/draftQueue.ts new file mode 100644 index 0000000..209076e --- /dev/null +++ b/frontend/src/offline/draftQueue.ts @@ -0,0 +1,75 @@ +import { createStore, del, entries, set } from "idb-keyval"; + +import { ApiError } from "../api/client"; + +// Geteilte Sync-Mechanik für die per-Feature-Queues (Kontrolle/Mangel/ +// Gerätestatus/Wartungsauftrag/Lagerbuchung) - bewusst KEIN genereller +// "beliebiger Endpunkt"-Layer (das war die 2026-09-10 abgelehnte +// Alternative), sondern nur die Wiederholung aus enqueue/sendeEintrag/ +// synchronisiere zwischen den bereits bestehenden Queues (queue.ts, +// mangelQueue.ts) an einer Stelle statt fünffach kopiert. Jede Queue bleibt +// eine eigene Datei mit eigenem Item-Typ und eigener sende()-Funktion. +export type QueueStatus = "wartet" | "wird_uebertragen" | "fehler"; + +export interface DraftItem { + key: string; + queueStatus: QueueStatus; + fehlerText: string | null; +} + +export function erzeugeDraftQueue(storeName: string, sende: (item: T) => Promise) { + const store = createStore("mabea-offline-queue", storeName); + let synchronisierungLaeuft = false; + + async function alleEintraege(): Promise { + const all = await entries(store); + return all.map(([, wert]) => wert); + } + + async function speichere(item: T): Promise { + await set(item.key, item, store); + } + + async function entferneFehlerEintrag(key: string): Promise { + await del(key, store); + } + + async function sendeEintrag(item: T): Promise { + await speichere({ ...item, queueStatus: "wird_uebertragen" }); + try { + await sende(item); + await del(item.key, store); + } catch (error) { + if (error instanceof ApiError && error.status >= 400 && error.status < 500) { + await speichere({ + ...item, + queueStatus: "fehler", + fehlerText: `Server hat abgelehnt (${error.status}) - bitte erneut versuchen.`, + }); + } else { + await speichere({ ...item, queueStatus: "wartet", fehlerText: null }); + } + throw error; + } + } + + async function synchronisiere(): Promise { + if (synchronisierungLaeuft) return; + synchronisierungLaeuft = true; + try { + const eintraege = await alleEintraege(); + for (const item of eintraege) { + if (item.queueStatus === "fehler") continue; // braucht bewusste Nutzer-Aktion + try { + await sendeEintrag(item); + } catch (error) { + if (!(error instanceof ApiError)) return; // vermutlich offline - restliche Queue in Ruhe lassen + } + } + } finally { + synchronisierungLaeuft = false; + } + } + + return { store, alleEintraege, speichere, entferneFehlerEintrag, synchronisiere }; +} diff --git a/frontend/src/offline/geraetStatusQueue.ts b/frontend/src/offline/geraetStatusQueue.ts new file mode 100644 index 0000000..89d4f0a --- /dev/null +++ b/frontend/src/offline/geraetStatusQueue.ts @@ -0,0 +1,52 @@ +import { apiRequest } from "../api/client"; +import type { GeraetInstanz } from "../api/types"; +import { erzeugeDraftQueue, type DraftItem } from "./draftQueue"; + +// MOBILE-003-Erweiterung (2026-09-13, Nutzer-Entscheidung: bisherige +// Scope-Begrenzung auf Kontrolle/Mangel revidiert): Gerätestatus-Änderung +// (z.B. Gerät als defekt melden) lief bisher blockierend gegen die API - +// dieselbe Draft-first-Idee wie Kontrolle/Mangel, hier für +// PATCH /geraete/{id}. +export interface GeraetStatusQueueItem extends DraftItem { + geraetId: string; + neuerStatus: GeraetInstanz["status"]; + pruefdatum: string | null; + bemerkung: string | null; + erzeugeFehlbestand: boolean; +} + +const queue = erzeugeDraftQueue("geraetStatus", async (item) => { + await apiRequest(`/geraete/${item.geraetId}`, { + method: "PATCH", + body: { + status: item.neuerStatus, + pruefdatum: item.pruefdatum, + bemerkung: item.bemerkung, + erzeuge_fehlbestand: item.erzeugeFehlbestand, + }, + }); +}); + +export async function enqueue(eingabe: { + geraetId: string; + neuerStatus: GeraetInstanz["status"]; + pruefdatum: string | null; + bemerkung: string | null; + erzeugeFehlbestand: boolean; +}): Promise { + const key = crypto.randomUUID(); + await queue.speichere({ + key, + geraetId: eingabe.geraetId, + neuerStatus: eingabe.neuerStatus, + pruefdatum: eingabe.pruefdatum, + bemerkung: eingabe.bemerkung, + erzeugeFehlbestand: eingabe.erzeugeFehlbestand, + queueStatus: "wartet", + fehlerText: null, + }); +} + +export const alleEintraege = queue.alleEintraege; +export const entferneFehlerEintrag = queue.entferneFehlerEintrag; +export const synchronisiereGeraetStatusQueue = queue.synchronisiere; diff --git a/frontend/src/offline/lagerbewegungQueue.ts b/frontend/src/offline/lagerbewegungQueue.ts new file mode 100644 index 0000000..dd16c79 --- /dev/null +++ b/frontend/src/offline/lagerbewegungQueue.ts @@ -0,0 +1,62 @@ +import { apiRequest } from "../api/client"; +import { erzeugeDraftQueue, type DraftItem } from "./draftQueue"; + +// MOBILE-003-Erweiterung (2026-09-13): Ein-/Auslagerung/Umlagerung liefen +// bisher blockierend gegen die jeweiligen POST-Endpunkte. 409 (z.B. "nicht +// genug Bestand") landet über den 4xx-Zweig der Draft-Queue korrekt im +// "fehler"-Status statt endlos automatisch erneut versucht zu werden. +type LagerbewegungAktion = "einlagern" | "auslagern" | "umlagern"; + +export interface LagerbewegungQueueItem extends DraftItem { + aktion: LagerbewegungAktion; + lagerplatzId: number; + materialId: number; + menge: string; + grund: string | null; + nachLagerplatzId: number | null; // nur bei "umlagern" gesetzt +} + +const queue = erzeugeDraftQueue("lagerbewegung", async (item) => { + if (item.aktion === "umlagern") { + await apiRequest(`/lagerplaetze/${item.lagerplatzId}/umlagern`, { + method: "POST", + body: { + nach_lagerplatz_id: item.nachLagerplatzId, + material_id: item.materialId, + menge: item.menge, + grund: item.grund, + }, + }); + return; + } + await apiRequest(`/lagerplaetze/${item.lagerplatzId}/${item.aktion}`, { + method: "POST", + body: { material_id: item.materialId, menge: item.menge, grund: item.grund }, + }); +}); + +export async function enqueue(eingabe: { + aktion: LagerbewegungAktion; + lagerplatzId: number; + materialId: number; + menge: string; + grund?: string | null; + nachLagerplatzId?: number | null; +}): Promise { + const key = crypto.randomUUID(); + await queue.speichere({ + key, + aktion: eingabe.aktion, + lagerplatzId: eingabe.lagerplatzId, + materialId: eingabe.materialId, + menge: eingabe.menge, + grund: eingabe.grund ?? null, + nachLagerplatzId: eingabe.nachLagerplatzId ?? null, + queueStatus: "wartet", + fehlerText: null, + }); +} + +export const alleEintraege = queue.alleEintraege; +export const entferneFehlerEintrag = queue.entferneFehlerEintrag; +export const synchronisiereLagerbewegungQueue = queue.synchronisiere; diff --git a/frontend/src/offline/useOnlineStatus.ts b/frontend/src/offline/useOnlineStatus.ts index fd36d2a..78586c3 100644 --- a/frontend/src/offline/useOnlineStatus.ts +++ b/frontend/src/offline/useOnlineStatus.ts @@ -1,14 +1,25 @@ import { useEffect, useState } from "react"; -import { synchronisiereQueue } from "./queue"; +import { synchronisiereGeraetStatusQueue } from "./geraetStatusQueue"; +import { synchronisiereLagerbewegungQueue } from "./lagerbewegungQueue"; import { synchronisiereMangelQueue } from "./mangelQueue"; +import { synchronisiereQueue } from "./queue"; +import { synchronisiereWartungQueue } from "./wartungQueue"; // MOBILE-003: zentraler Reconnect-Trigger für ALLE Offline-Queues der App -// (Kontroll-Erfassung + Mangelmeldung) - läuft global über SyncStatus in -// AppShell, nicht nur auf der jeweiligen Seite, damit auch ein Seitenwechsel -// während des Wartens auf Verbindung die Übertragung nicht verhindert. +// (Kontroll-Erfassung, Mangelmeldung, Gerätestatus, Wartungsauftrag, +// Lagerbewegung - Scope 2026-09-13 erweitert) - läuft global über SyncStatus +// in AppShell, nicht nur auf der jeweiligen Seite, damit auch ein +// Seitenwechsel während des Wartens auf Verbindung die Übertragung nicht +// verhindert. async function synchronisiereAlle(): Promise { - await Promise.all([synchronisiereQueue(), synchronisiereMangelQueue()]); + await Promise.all([ + synchronisiereQueue(), + synchronisiereMangelQueue(), + synchronisiereGeraetStatusQueue(), + synchronisiereWartungQueue(), + synchronisiereLagerbewegungQueue(), + ]); } /** Prompt 17: bei wiederhergestellter Verbindung automatische Übertragung der diff --git a/frontend/src/offline/wartungQueue.ts b/frontend/src/offline/wartungQueue.ts new file mode 100644 index 0000000..489d28a --- /dev/null +++ b/frontend/src/offline/wartungQueue.ts @@ -0,0 +1,49 @@ +import { apiRequest } from "../api/client"; +import { erzeugeDraftQueue, type DraftItem } from "./draftQueue"; + +// MOBILE-003-Erweiterung (2026-09-13): Wartungsauftrag anlegen lief bisher +// blockierend gegen POST /wartungsauftraege. +export interface WartungQueueItem extends DraftItem { + objektId: number; + wartungsart: string; + werkstattIntern: boolean; + werkstattName: string | null; + dienstleisterId: number | null; +} + +const queue = erzeugeDraftQueue("wartung", async (item) => { + await apiRequest("/wartungsauftraege", { + method: "POST", + body: { + objekt_id: item.objektId, + wartungsart: item.wartungsart, + werkstatt_intern: item.werkstattIntern, + werkstatt_name: item.werkstattName, + dienstleister_id: item.dienstleisterId, + }, + }); +}); + +export async function enqueue(eingabe: { + objektId: number; + wartungsart: string; + werkstattIntern: boolean; + werkstattName: string | null; + dienstleisterId: number | null; +}): Promise { + const key = crypto.randomUUID(); + await queue.speichere({ + key, + objektId: eingabe.objektId, + wartungsart: eingabe.wartungsart, + werkstattIntern: eingabe.werkstattIntern, + werkstattName: eingabe.werkstattName, + dienstleisterId: eingabe.dienstleisterId, + queueStatus: "wartet", + fehlerText: null, + }); +} + +export const alleEintraege = queue.alleEintraege; +export const entferneFehlerEintrag = queue.entferneFehlerEintrag; +export const synchronisiereWartungQueue = queue.synchronisiere; diff --git a/frontend/src/pages/admin/GeraeteInstanzenListe.tsx b/frontend/src/pages/admin/GeraeteInstanzenListe.tsx index 436bbcc..dcaf1f5 100644 --- a/frontend/src/pages/admin/GeraeteInstanzenListe.tsx +++ b/frontend/src/pages/admin/GeraeteInstanzenListe.tsx @@ -4,6 +4,7 @@ import { apiRequest } from "../../api/client"; import type { GeraetInstanz, Pruefung, PruefungsAmpel } from "../../api/types"; import { CustodyDialog } from "../../components/CustodyDialog"; import { FristBadge } from "../../components/status/FristBadge"; +import * as geraetStatusQueue from "../../offline/geraetStatusQueue"; const ERGEBNIS_LABEL: Record = { bestanden: "Bestanden", @@ -119,17 +120,20 @@ export function GeraeteInstanzenListe({ objektpositionId, pruefintervallMonate, } async function speichernStatus(instanz: GeraetInstanz) { + // MOBILE-003-Erweiterung: läuft über die Offline-Queue statt blockierend + // gegen die API - bei bestehender Verbindung praktisch sofort (Sync- + // Versuch läuft direkt im Anschluss), bei Verbindungsverlust bleibt die + // Änderung erhalten statt einfach zu scheitern. try { - await apiRequest(`/geraete/${instanz.id}`, { - method: "PATCH", - body: { - status: statusAuswahl, - pruefdatum: pruefdatum || null, - bemerkung: bemerkung || null, - erzeuge_fehlbestand: statusAuswahl !== "einsatzbereit" && erzeugeFehlbestand, - }, + await geraetStatusQueue.enqueue({ + geraetId: instanz.id, + neuerStatus: statusAuswahl, + pruefdatum: pruefdatum || null, + bemerkung: bemerkung || null, + erzeugeFehlbestand: statusAuswahl !== "einsatzbereit" && erzeugeFehlbestand, }); setBearbeiteId(null); + await geraetStatusQueue.synchronisiereGeraetStatusQueue(); await laden(); } catch { onFehler("Status konnte nicht geändert werden."); diff --git a/frontend/src/pages/admin/LagerSection.tsx b/frontend/src/pages/admin/LagerSection.tsx index 9c4e64c..b0488c5 100644 --- a/frontend/src/pages/admin/LagerSection.tsx +++ b/frontend/src/pages/admin/LagerSection.tsx @@ -1,7 +1,8 @@ import { useEffect, useState } from "react"; -import { apiRequest } from "../../api/client"; +import { apiRequest, ApiError } from "../../api/client"; import type { Bestand, Lagerort, LagerortTyp, Lagerplatz, Material, Materialbewegung } from "../../api/types"; +import * as lagerbewegungQueue from "../../offline/lagerbewegungQueue"; interface Props { materialien: Material[]; @@ -126,6 +127,12 @@ export function LagerSection({ materialien, onFehler }: Props) { } } + // MOBILE-003-Erweiterung: anders als Gerätestatus/Wartungsauftrag NICHT + // "immer zuerst in die Queue" - eine Buchung hat echte Geschäftskonflikte + // (Bestand reicht nicht), die der Nutzer SOFORT sehen soll, wenn eine + // Verbindung besteht, statt sie erst beim späteren Sync-Versuch zu + // erfahren. Nur ein echter Netzwerkfehler (kein ApiError) landet in der + // Offline-Queue, ein 4xx-Konflikt wird weiterhin direkt angezeigt. async function buchen(richtung: "einlagern" | "auslagern") { if (ausgewaehlterPlatz === "" || buchungMaterial === "" || !buchungMenge) { onFehler("Material und Menge angeben."); @@ -139,8 +146,21 @@ export function LagerSection({ materialien, onFehler }: Props) { setBuchungMenge(""); setBuchungGrund(""); await ladeBestandUndBewegungen(ausgewaehlterPlatz); - } catch { - onFehler(richtung === "auslagern" ? "Nicht genug Bestand am Lagerplatz." : "Buchung fehlgeschlagen."); + } catch (fehler) { + if (fehler instanceof ApiError) { + onFehler(richtung === "auslagern" ? "Nicht genug Bestand am Lagerplatz." : "Buchung fehlgeschlagen."); + return; + } + await lagerbewegungQueue.enqueue({ + aktion: richtung, + lagerplatzId: ausgewaehlterPlatz, + materialId: buchungMaterial, + menge: buchungMenge, + grund: buchungGrund || null, + }); + setBuchungMenge(""); + setBuchungGrund(""); + onFehler("Keine Verbindung - Buchung wird automatisch übertragen, sobald wieder online."); } } @@ -163,8 +183,23 @@ export function LagerSection({ materialien, onFehler }: Props) { setBuchungGrund(""); setUmlagernZiel(""); await ladeBestandUndBewegungen(ausgewaehlterPlatz); - } catch { - onFehler("Umlagerung fehlgeschlagen (nicht genug Bestand oder Ziel = Quelle)."); + } catch (fehler) { + if (fehler instanceof ApiError) { + onFehler("Umlagerung fehlgeschlagen (nicht genug Bestand oder Ziel = Quelle)."); + return; + } + await lagerbewegungQueue.enqueue({ + aktion: "umlagern", + lagerplatzId: ausgewaehlterPlatz, + nachLagerplatzId: umlagernZiel, + materialId: buchungMaterial, + menge: buchungMenge, + grund: buchungGrund || null, + }); + setBuchungMenge(""); + setBuchungGrund(""); + setUmlagernZiel(""); + onFehler("Keine Verbindung - Umlagerung wird automatisch übertragen, sobald wieder online."); } }