feat(mobile): MOBILE-003 Draft-first-Muster auf Mangelmeldung ausgeweitet
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:
1 parent
aceef65418
commit
78b07d671f
6 files changed
+245
-16
No files matched your search
@@ -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) |
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user