feat(mobile): MOBILE-003 Draft-first-Muster auf Mangelmeldung ausgeweitet
CI / backend-tests (push) Successful in 3m18s
CI / frontend-build (push) Successful in 21s

Gleiches Offline-Queue-Muster wie bei der Kontroll-Erfassung
(offline/queue.ts) jetzt auch für Mangelmeldung: sofort lokal
speichern statt auf Server-Antwort zu warten, Foto wird mit
gequeued und nach erfolgreicher Mangel-Erstellung separat
hochgeladen. useOnlineStatus/SyncStatus decken jetzt beide Queues ab.

Bewusst begrenzter Scope (Nutzer-Entscheidung): kein genereller
Offline-Layer für alle Schreib-Endpunkte, nur Kontrolle + Mangel.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVgbozhYmuEhiEJHffRXCV
This commit is contained in:
patrickandClaude Sonnet 5 committed 2026-09-10 20:14:39 +02:00
1 parent aceef65418
commit 78b07d671f
6 files changed
+245 -16

No files matched your search

+1 -1
View File
@@ -234,7 +234,7 @@ vergeben statt geraten.
| DOC-005 | Documents | Zugriffsrechte je Dokument | P2 | S | MEDIUM | DOC-001, FOUND-004 | ✅ (nach Dokumenttyp: Rechnungen nur für Materialverantwortliche+, per `EINGESCHRAENKTE_DOKUMENTTYPEN`) |
| MOBILE-001 | Mobile | PWA-Grundgerüst | P1 | M | MEDIUM | FOUND-005 | ✅ (Vite-PWA-Build erzeugt `manifest.webmanifest`/`sw.js`, `vite-plugin-pwa` in package.json) |
| MOBILE-002 | Mobile | QR-Scan-Workflow (Kernablauf) | P1 | M | MEDIUM | MOBILE-001, IDENT-008 | ✅ (`BarcodeScanner.tsx` verdrahtet in `ObjektListPage.tsx`) |
| MOBILE-003 | Mobile | Offline-Grundgerüst | P2 | L | HIGH | MOBILE-001 | 🔶 (`offline/queue.ts`, `useOnlineStatus.ts`, `SyncStatus.tsx` vorhanden — Warteschlange für Kontrollen, kein vollständiges Offline-App-Grundgerüst über alle Module) |
| MOBILE-003 | Mobile | Offline-Grundgerüst | P2 | L | HIGH | MOBILE-001 | ✅ erledigt 2026-09-10 (Draft-first-Muster auf Mangelmeldung ausgeweitet; bewusst kein genereller Offline-Layer für alle Module, siehe Kachel-DoD) |
| MOBILE-004 | Mobile | Mobile Mangelmeldung + Foto | P1 | S | LOW | MOBILE-002, DEFECT-001 | ✅ (Mangel-Erfassung inkl. Foto/Dokument-Anhang, in Kontroll-Flow verdrahtet) |
| MOBILE-005 | Mobile | Mobile Beladungskontrolle | P1 | S | MEDIUM | MOBILE-002, LOAD-004 | ✅ (`KontrollPage.tsx` mit Fach-Gruppierung, mobil-optimiert laut Memory-Feedback) |
| OPS-001 | Operations | Einsatzverwaltung (Bedarf konkretisiert 2026-09-10) | P3 | L | HIGH | FILE-001 | ✅ (`einsatz`-Modell, Nachdokumentation) |
+33 -3
View File
@@ -71,9 +71,34 @@ Details zu MOBILE-001 … MOBILE-005 (vollständig).
- **Akzeptanzkriterien:** Aktion offline ausführen, nach Wiederverbindung korrekt
übernommen.
- **Tests:** Offline-Simulationstest.
- **DoD:** **HIGH RISK, P2 — komplexestes Mobile-Thema.** MABEA hat KEINE
Offline-Fähigkeit (nur PWA-Shell-Caching via Vite-PWA-Plugin, keine Offline-Queue für
Schreibvorgänge) — bei Netzausfall schlägt jede Aktion einfach fehl.
- **DoD:** ✅ erledigt (2026-09-10) im Rahmen des vom Nutzer bestätigten
begrenzten Scopes: **Draft-first-Muster auf Mangelmeldung (MOBILE-004)
ausgeweitet**, NICHT als genereller Offline-Layer für jeden Schreib-
Endpunkt (explizit abgelehnte Alternative — zu großer Umbau ohne klaren
Bedarf über Kontrolle/Mangel hinaus).
- `frontend/src/offline/mangelQueue.ts` (neu): gleiches IndexedDB-Draft-
Muster wie `offline/queue.ts` (Kontrolle) — `enqueue()`/
`synchronisiereMangelQueue()`/`entferneFehlerEintrag()`. Foto wird als
`File`-Objekt mit in der Queue abgelegt (strukturiertes Klonen), nach
erfolgreicher `POST /maengel` separat über `ladeDokumentHoch()`
hochgeladen (braucht die serverseitig vergebene `mangel_id`) — schlägt
der Foto-Upload fehl, bleibt der Mangel trotzdem angelegt (bewusst: der
kleinere Schaden ist ein fehlendes Foto, nicht ein bei jedem Sync-Versuch
doppelt angelegter Mangel).
- `offline/useOnlineStatus.ts` triggert jetzt BEIDE Queues bei Reconnect
(`synchronisiereAlle()`), `SyncStatus.tsx` (globale Statusanzeige in
`AppShell`) zählt wartende Einträge aus beiden zusammen.
- `MangelListePage.tsx`: „Melden" schreibt sofort lokal (kein Warten auf
Server-Antwort), eigener „Lokal (noch nicht synchronisiert)"-Bereich
zeigt Status je Eintrag (wartet/wird übertragen/fehlgeschlagen), Foto-
Aufnahme direkt im Formular (`capture="environment"`).
- **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.
- **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
@@ -106,6 +131,11 @@ Details zu MOBILE-001 … MOBILE-005 (vollständig).
- **DoD:** deckt sich mit MABEA (`MangelListePage` + `DokumentePanel` bereits responsive
nutzbar) — direkter QR-Start der Meldung fehlt noch (Akte existiert jetzt, aber ohne
eingebetteten Melde-Kurzweg).
**Update 2026-09-10 (MOBILE-003 Draft-first-Ausweitung):** Foto-Aufnahme
jetzt direkt im Melde-Formular möglich (`capture="environment"`,
Kamera-Sensor statt nur Datei-Auswahl) statt erst nachträglich über
`DokumentePanel` — Foto wird zusammen mit der Meldung in die Offline-Queue
gelegt und nach erfolgreicher Mangel-Erstellung automatisch hochgeladen.
## MOBILE-005 — Mobile Beladungskontrolle
@@ -1,12 +1,14 @@
import { useEffect, useState } from "react";
import { alleEintraege } from "../../offline/queue";
import { alleEintraege as alleMangelEintraege } from "../../offline/mangelQueue";
import { useOnlineStatus } from "../../offline/useOnlineStatus";
/**
* 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.
* echte Lücke laut Redesign-Vorgabe, kein reiner Umbau. MOBILE-003: zählt
* jetzt beide Offline-Queues (Kontrolle + Mangelmeldung) zusammen.
*/
export function SyncStatus() {
const online = useOnlineStatus();
@@ -15,8 +17,9 @@ export function SyncStatus() {
useEffect(() => {
let aktiv = true;
async function pruefen() {
const eintraege = await alleEintraege();
if (aktiv) setWartend(eintraege.filter((e) => e.status !== "fehler").length);
const [kontrolle, mangel] = await Promise.all([alleEintraege(), alleMangelEintraege()]);
const gesamt = [...kontrolle, ...mangel].filter((e) => e.status !== "fehler").length;
if (aktiv) setWartend(gesamt);
}
void pruefen();
const interval = window.setInterval(pruefen, 5000);
+121
View File
@@ -0,0 +1,121 @@
import { createStore, del, entries, set } from "idb-keyval";
import { apiRequest, ApiError, ladeDokumentHoch } from "../api/client";
import type { Mangel, MangelPrioritaet } from "../api/types";
// MOBILE-003 (Draft-first, gleiches Muster wie offline/queue.ts für
// Kontrollen): Mangelmeldung ist neben der Kontroll-Erfassung die häufigste
// Feld-Aktion, die bei fehlender Verbindung nicht einfach fehlschlagen soll.
// Das optionale Foto wird als File-Objekt mit in IndexedDB abgelegt (moderne
// Browser unterstützen strukturiertes Klonen von File/Blob) und erst NACH
// erfolgreicher Mangel-Erstellung separat hochgeladen (Dokument braucht die
// bereits vergebene mangel_id als entitaet_id).
const store = createStore("mabea-offline-queue", "maengel");
export type MangelQueueStatus = "wartet" | "wird_uebertragen" | "fehler";
export interface MangelQueueItem {
key: string;
objektId: number;
entitaetTyp: "objektposition" | "geraet_instanz" | null;
entitaetId: string | null;
beschreibung: string;
prioritaet: MangelPrioritaet;
foto: File | null;
status: MangelQueueStatus;
fehlerText: string | null;
}
export async function enqueue(eingabe: {
objektId: number;
entitaetTyp?: "objektposition" | "geraet_instanz" | null;
entitaetId?: string | null;
beschreibung: string;
prioritaet: MangelPrioritaet;
foto?: File | null;
}): Promise<void> {
const key = crypto.randomUUID();
const item: MangelQueueItem = {
key,
objektId: eingabe.objektId,
entitaetTyp: eingabe.entitaetTyp ?? null,
entitaetId: eingabe.entitaetId ?? null,
beschreibung: eingabe.beschreibung,
prioritaet: eingabe.prioritaet,
foto: eingabe.foto ?? null,
status: "wartet",
fehlerText: null,
};
await set(key, item, store);
}
export async function alleEintraege(): Promise<MangelQueueItem[]> {
const all = await entries<string, MangelQueueItem>(store);
return all.map(([, value]) => value);
}
export async function entferneFehlerEintrag(key: string): Promise<void> {
await del(key, store);
}
async function sendeEintrag(item: MangelQueueItem): Promise<void> {
await set(item.key, { ...item, status: "wird_uebertragen" as MangelQueueStatus }, store);
try {
const mangel = await apiRequest<Mangel>("/maengel", {
method: "POST",
body: {
objekt_id: item.objektId,
entitaet_typ: item.entitaetTyp,
entitaet_id: item.entitaetId,
beschreibung: item.beschreibung,
prioritaet: item.prioritaet,
},
});
if (item.foto) {
try {
await ladeDokumentHoch({
entitaet_typ: "mangel",
entitaet_id: mangel.id,
dokumenttyp: "sonstiges",
istOriginal: true,
datei: item.foto,
});
} catch {
// Mangel selbst ist bereits erfolgreich angelegt (Kernnutzen erfüllt) -
// ein fehlgeschlagener Foto-Upload darf den Eintrag nicht in der Queue
// hängen lassen, sonst wird derselbe Mangel bei jedem Sync-Versuch neu
// angelegt. Foto-Verlust ist bewusst der kleinere Schaden hier.
}
}
await del(item.key, store);
} catch (error) {
if (error instanceof ApiError && error.status >= 400 && error.status < 500) {
await set(
item.key,
{
...item,
status: "fehler" as MangelQueueStatus,
fehlerText: `Server hat abgelehnt (${error.status}) - bitte erneut versuchen.`,
},
store
);
} else {
await set(item.key, { ...item, status: "wartet" as MangelQueueStatus }, store);
}
throw error;
}
}
export async function synchronisiereMangelQueue(): Promise<void> {
const eintraege = await alleEintraege();
for (const item of eintraege) {
if (item.status === "fehler") continue;
try {
await sendeEintrag(item);
} catch (error) {
if (!(error instanceof ApiError)) {
return; // vermutlich offline - restliche Queue in Ruhe lassen
}
}
}
}
+11 -2
View File
@@ -1,6 +1,15 @@
import { useEffect, useState } from "react";
import { synchronisiereQueue } from "./queue";
import { synchronisiereMangelQueue } from "./mangelQueue";
// 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.
async function synchronisiereAlle(): Promise<void> {
await Promise.all([synchronisiereQueue(), synchronisiereMangelQueue()]);
}
/** Prompt 17: bei wiederhergestellter Verbindung automatische Übertragung der
* zwischengespeicherten Eingaben, ohne dass der Mitarbeiter erneut eintippt. */
@@ -10,7 +19,7 @@ export function useOnlineStatus(): boolean {
useEffect(() => {
const handleOnline = () => {
setOnline(true);
void synchronisiereQueue();
void synchronisiereAlle();
};
const handleOffline = () => setOnline(false);
@@ -22,7 +31,7 @@ export function useOnlineStatus(): boolean {
// pragmatischer Kompromiss zwischen Akkuverbrauch und Reaktionszeit.
const interval = window.setInterval(() => {
if (navigator.onLine) {
void synchronisiereQueue();
void synchronisiereAlle();
}
}, 15000);
+73 -7
View File
@@ -4,6 +4,9 @@ import { useSearchParams } from "react-router-dom";
import { apiRequest } from "../api/client";
import { useAuth } from "../auth/AuthContext";
import { DokumentePanel } from "../components/DokumentePanel";
import * as mangelQueue from "../offline/mangelQueue";
import type { MangelQueueItem } from "../offline/mangelQueue";
import { useOnlineStatus } from "../offline/useOnlineStatus";
import type { Mangel, MangelPrioritaet, MangelStatus, Objekt } from "../api/types";
const STATUS_LABEL: Record<MangelStatus, string> = {
@@ -37,12 +40,15 @@ const STATUS_OPTIONEN: MangelStatus[] = [
export function MangelListePage() {
const { istMaterialverantwortlich, istLeitungsverantwortlich, istAdmin } = useAuth();
const darfStatusAendern = istMaterialverantwortlich || istLeitungsverantwortlich || istAdmin;
const online = useOnlineStatus();
const [maengel, setMaengel] = useState<Mangel[]>([]);
const [objekte, setObjekte] = useState<Objekt[]>([]);
const [laedt, setLaedt] = useState(true);
const [fehler, setFehler] = useState<string | null>(null);
const [nurOffene, setNurOffene] = useState(true);
const [lokaleEintraege, setLokaleEintraege] = useState<MangelQueueItem[]>([]);
const [foto, setFoto] = useState<File | null>(null);
const [suchparameter] = useSearchParams();
// Quick Action "Mangel melden" aus der Objektakte springt
@@ -72,8 +78,17 @@ export function MangelListePage() {
}
}
async function ladeLokaleEintraege() {
setLokaleEintraege(await mangelQueue.alleEintraege());
}
useEffect(() => {
laden();
void ladeLokaleEintraege();
// Nach Reconnect verschwinden erfolgreich übertragene Einträge aus der
// Queue - Liste neu abgleichen, statt auf die nächste manuelle Aktion zu warten.
const interval = window.setInterval(ladeLokaleEintraege, 5000);
return () => window.clearInterval(interval);
}, []);
function objektName(id: number): string {
@@ -88,20 +103,34 @@ export function MangelListePage() {
}
setWirdGemeldet(true);
try {
await apiRequest("/maengel", {
method: "POST",
body: { objekt_id: objektId, beschreibung, prioritaet },
});
// MOBILE-003 (Draft-first): sofort lokal ablegen statt direkt zu senden -
// funktioniert identisch online wie offline, Sync passiert im Hintergrund
// (useOnlineStatus). Der Mitarbeiter sieht das Ergebnis sofort in der
// "Lokal"-Liste unten, nicht erst nach einer Server-Antwort.
await mangelQueue.enqueue({ objektId, beschreibung, prioritaet, foto });
setBeschreibung("");
setPrioritaet("normal");
await laden();
setFoto(null);
await ladeLokaleEintraege();
void mangelQueue.synchronisiereMangelQueue().then(() => {
void ladeLokaleEintraege();
void laden();
});
} catch {
setFehler("Mangel konnte nicht gemeldet werden.");
setFehler("Mangel konnte nicht lokal gespeichert werden.");
} finally {
setWirdGemeldet(false);
}
}
async function erneutVersuchen(key: string) {
await mangelQueue.entferneFehlerEintrag(key);
// entferneFehlerEintrag löscht statt zurückzusetzen (einfacher, konsistent
// mit dem Kontroll-Queue-Muster) - Mitarbeiter meldet bei Bedarf einfach
// erneut, Formularinhalt ist ohnehin schon geleert.
await ladeLokaleEintraege();
}
async function statusAendern(mangel: Mangel, status: MangelStatus) {
try {
await apiRequest(`/maengel/${mangel.id}`, { method: "PATCH", body: { status } });
@@ -140,12 +169,49 @@ export function MangelListePage() {
<option value="normal">Normal</option>
<option value="hoch">Hoch (blockiert Einsatzbereitschaft)</option>
</select>
<input
type="file"
accept="image/*"
capture="environment"
onChange={(e) => setFoto(e.target.files?.[0] ?? null)}
/>
<button className="btn btn-primary" onClick={melden} disabled={wirdGemeldet}>
Melden
Melden {!online && "(wird bei Verbindung übertragen)"}
</button>
</div>
</div>
{lokaleEintraege.length > 0 && (
<div className="card" style={{ marginBottom: "1.25rem" }}>
<h3 style={{ marginTop: 0 }}>Lokal (noch nicht synchronisiert)</h3>
<ul className="card-list">
{lokaleEintraege.map((item) => (
<li key={item.key} className="row-between">
<span>
<strong>{objektName(item.objektId)}</strong> — {item.beschreibung}
{item.foto && <span className="text-muted"> 📷</span>}
</span>
<span className="row" style={{ gap: "0.4rem" }}>
<span
className={`badge ${item.status === "fehler" ? "badge-danger" : "badge-warning"}`}
title={item.fehlerText ?? undefined}
>
{item.status === "wartet" && "Wartet auf Verbindung"}
{item.status === "wird_uebertragen" && "Wird übertragen…"}
{item.status === "fehler" && "Fehlgeschlagen"}
</span>
{item.status === "fehler" && (
<button className="btn btn-secondary" onClick={() => erneutVersuchen(item.key)}>
Verwerfen
</button>
)}
</span>
</li>
))}
</ul>
</div>
)}
<label className="row" style={{ alignItems: "center", gap: "0.4rem", marginBottom: "1rem" }}>
<input type="checkbox" checked={nurOffene} onChange={(e) => setNurOffene(e.target.checked)} />
Nur offene Mängel anzeigen