From 564e98e5c618c0b94d58a5047e5a07dadfc5ab9c Mon Sep 17 00:00:00 2001 From: patrick Date: Fri, 4 Sep 2026 00:10:26 +0200 Subject: [PATCH] =?UTF-8?q?Sprint=207:=20React-PWA-Frontend=20(Offline-H?= =?UTF-8?q?=C3=A4rtung)=20+=20Flutter-Android-Grundger=C3=BCst?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01L85hmKbvX7Cqkq47KnQhFt --- DEVLOG.md | 56 ++++ flutter_app/.gitignore | 8 + flutter_app/README.md | 40 +++ flutter_app/analysis_options.yaml | 1 + flutter_app/lib/api/api_client.dart | 101 +++++++ flutter_app/lib/main.dart | 45 +++ flutter_app/lib/screens/kontroll_screen.dart | 276 ++++++++++++++++++ flutter_app/lib/screens/login_screen.dart | 75 +++++ .../lib/screens/objekt_list_screen.dart | 82 ++++++ flutter_app/pubspec.yaml | 21 ++ frontend/.gitignore | 5 + frontend/README.md | 44 +++ frontend/example.env | 1 + frontend/index.html | 13 + frontend/package.json | 25 ++ frontend/src/App.tsx | 42 +++ frontend/src/api/client.ts | 79 +++++ frontend/src/api/types.ts | 49 ++++ frontend/src/auth/AuthContext.tsx | 41 +++ frontend/src/main.tsx | 13 + frontend/src/offline/queue.ts | 114 ++++++++ frontend/src/offline/useOnlineStatus.ts | 37 +++ frontend/src/pages/KontrollPage.tsx | 271 +++++++++++++++++ frontend/src/pages/LoginPage.tsx | 62 ++++ frontend/src/pages/ObjektListPage.tsx | 59 ++++ frontend/src/vite-env.d.ts | 9 + frontend/tsconfig.json | 19 ++ frontend/vite.config.ts | 36 +++ 28 files changed, 1624 insertions(+) create mode 100644 flutter_app/.gitignore create mode 100644 flutter_app/README.md create mode 100644 flutter_app/analysis_options.yaml create mode 100644 flutter_app/lib/api/api_client.dart create mode 100644 flutter_app/lib/main.dart create mode 100644 flutter_app/lib/screens/kontroll_screen.dart create mode 100644 flutter_app/lib/screens/login_screen.dart create mode 100644 flutter_app/lib/screens/objekt_list_screen.dart create mode 100644 flutter_app/pubspec.yaml create mode 100644 frontend/.gitignore create mode 100644 frontend/README.md create mode 100644 frontend/example.env create mode 100644 frontend/index.html create mode 100644 frontend/package.json create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/api/client.ts create mode 100644 frontend/src/api/types.ts create mode 100644 frontend/src/auth/AuthContext.tsx create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/offline/queue.ts create mode 100644 frontend/src/offline/useOnlineStatus.ts create mode 100644 frontend/src/pages/KontrollPage.tsx create mode 100644 frontend/src/pages/LoginPage.tsx create mode 100644 frontend/src/pages/ObjektListPage.tsx create mode 100644 frontend/src/vite-env.d.ts create mode 100644 frontend/tsconfig.json create mode 100644 frontend/vite.config.ts diff --git a/DEVLOG.md b/DEVLOG.md index 6f0e03d..b48f068 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -1350,3 +1350,59 @@ Keine Commits in dieser Session. - backend/tests/test_mindermenge.py | 187 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ --- +## 2026-09-03 23:53 – 23:59 (6m) +**Beschreibung:** Claude Code Session +**Projekt:** asb-material + +### Commits +- aba991b Sprint 6: Dashboard-Kennzahlen, Ablaufdaten-Warnungen, E-Mail-Benachrichtigung + +### Geänderte Dateien +- DEVLOG.md | 46 +++++++++++++++++++++ +- backend/alembic/versions/0003_add_benutzer_email.py | 26 ++++++++++++ +- backend/app/api/v1/api.py | 2 + +- backend/app/api/v1/endpoints/benutzer.py | 3 ++ +- backend/app/api/v1/endpoints/dashboard.py | 27 ++++++++++++ +- backend/app/api/v1/endpoints/fehlbestaende.py | 20 ++++++++- +- backend/app/core/app_settings.py | 10 +++++ +- backend/app/core/email.py | 35 ++++++++++++++++ +- backend/app/models/auth.py | 1 + +- backend/app/schemas/benutzer.py | 4 ++ +- backend/app/schemas/dashboard.py | 20 +++++++++ +- backend/app/services/benachrichtigung.py | 59 +++++++++++++++++++++++++++ +- backend/app/services/dashboard.py | 91 +++++++++++++++++++++++++++++++++++++++++ +- backend/app/services/kontrolle.py | 2 + +- backend/pyproject.toml | 1 + +- backend/tests/conftest.py | 1 + +- backend/tests/test_dashboard.py | 147 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ +- backend/tests/test_email.py | 68 +++++++++++++++++++++++++++++++ + +--- +## 2026-09-03 23:59 – 23:59 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** asb-material + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- DEVLOG.md | 46 +++++++++++++++++++++ +- backend/alembic/versions/0003_add_benutzer_email.py | 26 ++++++++++++ +- backend/app/api/v1/api.py | 2 + +- backend/app/api/v1/endpoints/benutzer.py | 3 ++ +- backend/app/api/v1/endpoints/dashboard.py | 27 ++++++++++++ +- backend/app/api/v1/endpoints/fehlbestaende.py | 20 ++++++++- +- backend/app/core/app_settings.py | 10 +++++ +- backend/app/core/email.py | 35 ++++++++++++++++ +- backend/app/models/auth.py | 1 + +- backend/app/schemas/benutzer.py | 4 ++ +- backend/app/schemas/dashboard.py | 20 +++++++++ +- backend/app/services/benachrichtigung.py | 59 +++++++++++++++++++++++++++ +- backend/app/services/dashboard.py | 91 +++++++++++++++++++++++++++++++++++++++++ +- backend/app/services/kontrolle.py | 2 + +- backend/pyproject.toml | 1 + +- backend/tests/conftest.py | 1 + +- backend/tests/test_dashboard.py | 147 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ +- backend/tests/test_email.py | 68 +++++++++++++++++++++++++++++++ + +--- diff --git a/flutter_app/.gitignore b/flutter_app/.gitignore new file mode 100644 index 0000000..a2565f8 --- /dev/null +++ b/flutter_app/.gitignore @@ -0,0 +1,8 @@ +.dart_tool/ +.packages +build/ +.flutter-plugins +.flutter-plugins-dependencies +android/ +ios/ +*.iml diff --git a/flutter_app/README.md b/flutter_app/README.md new file mode 100644 index 0000000..7226259 --- /dev/null +++ b/flutter_app/README.md @@ -0,0 +1,40 @@ +# MABEA Android-App (Flutter) – erstes Grundgerüst + +Zweiter Client derselben API (Prompt 19 API-first-Prinzip) neben der React-PWA +(`frontend/`). Deckt Login, Objektliste, Kontroll-Screen (Start/Sperre-Übernahme/ +Positionen/Abschluss/Abbruch) ab. + +## Wichtig: Plattform-Ordner fehlen noch + +Dieses Verzeichnis enthält nur `lib/`, `pubspec.yaml`, `analysis_options.yaml` - +die von `flutter create` generierten Plattform-Ordner (`android/`, `ios/`, Gradle- +Konfiguration etc.) wurden bewusst NICHT von Hand nachgebaut, da das ohne +Testlauf zu fehleranfällig wäre. Erstinbetriebnahme auf dem Zielsystem (mit +Flutter-SDK installiert): + +```bash +cd flutter_app +flutter create . --platforms android --org de.perlbach24 --project-name mabea_app +# überschreibt pubspec.yaml NICHT automatisch mit *nix cp -n, ggf. lib/-Inhalt +# und pubspec.yaml-Dependencies danach gegen diese Version abgleichen +flutter pub get +flutter build apk +``` + +## API-Adresse + +`lib/main.dart`: `kApiBaseUrl` ist aktuell fest auf den Deployment-Server +(`192.168.1.238`) gesetzt. Für Produktivbetrieb auf `--dart-define` umstellen +oder zumindest vor jedem Build prüfen/anpassen. + +## Bewusst noch nicht umgesetzt (Unterschied zur React-PWA) + +- **Keine Offline-Queue**: anders als die PWA (`frontend/src/offline/queue.ts`, + IndexedDB) sendet diese erste Fassung Positionen direkt; bei Netzwerkfehler + erscheint nur eine "erneut versuchen"-Meldung, keine automatische + Zwischenspeicherung/Sync bei Reconnect. Nachziehen, sobald die App über das + Grundgerüst hinauswächst - gleiches Muster wie in der PWA übertragbar + (z. B. `sqflite` oder `hive` statt IndexedDB). +- Kein QR-/Barcode-Kamera-Scan (Karte 10, Roadmap). +- Keine Tests (Flutter-Widget-/Integrationstests) - Sprintplan sieht Testphase 4 + primär für die PWA vor (Playwright); Android-Testabdeckung ist noch offen. diff --git a/flutter_app/analysis_options.yaml b/flutter_app/analysis_options.yaml new file mode 100644 index 0000000..f9b3034 --- /dev/null +++ b/flutter_app/analysis_options.yaml @@ -0,0 +1 @@ +include: package:flutter_lints/flutter.yaml diff --git a/flutter_app/lib/api/api_client.dart b/flutter_app/lib/api/api_client.dart new file mode 100644 index 0000000..07b6316 --- /dev/null +++ b/flutter_app/lib/api/api_client.dart @@ -0,0 +1,101 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; +import 'package:shared_preferences/shared_preferences.dart'; + +/// Gleiche Basis-Idee wie im React-Client (frontend/src/api/client.ts): +/// gemeinsamer JSON-Wrapper, Token aus persistentem Speicher, ApiException +/// mit HTTP-Status, damit aufrufender Code gezielt reagieren kann (z.B. 409 +/// bei Objekt-Sperre). +class ApiException implements Exception { + final int status; + final dynamic detail; + ApiException(this.status, this.detail); + + @override + String toString() => 'ApiException($status, $detail)'; +} + +class ApiClient { + ApiClient({required this.baseUrl}); + + final String baseUrl; + String? _token; + + Future ladeToken() async { + final prefs = await SharedPreferences.getInstance(); + _token = prefs.getString('mabea_token'); + } + + Future setzeToken(String? token) async { + _token = token; + final prefs = await SharedPreferences.getInstance(); + if (token == null) { + await prefs.remove('mabea_token'); + } else { + await prefs.setString('mabea_token', token); + } + } + + bool get istEingeloggt => _token != null; + + Future login(String username, String password) async { + final response = await http.post( + Uri.parse('$baseUrl/auth/login'), + headers: {'Content-Type': 'application/x-www-form-urlencoded'}, + body: {'username': username, 'password': password}, + ); + if (response.statusCode != 200) { + throw ApiException(response.statusCode, _tryDecode(response.body)); + } + final data = jsonDecode(response.body) as Map; + final token = data['access_token'] as String; + await setzeToken(token); + return token; + } + + Future request( + String path, { + String method = 'GET', + Object? body, + }) async { + final uri = Uri.parse('$baseUrl$path'); + final headers = { + 'Content-Type': 'application/json', + if (_token != null) 'Authorization': 'Bearer $_token', + }; + final encodedBody = body != null ? jsonEncode(body) : null; + + late http.Response response; + switch (method) { + case 'POST': + response = await http.post(uri, headers: headers, body: encodedBody); + break; + case 'PUT': + response = await http.put(uri, headers: headers, body: encodedBody); + break; + case 'PATCH': + response = await http.patch(uri, headers: headers, body: encodedBody); + break; + case 'DELETE': + response = await http.delete(uri, headers: headers); + break; + default: + response = await http.get(uri, headers: headers); + } + + if (response.statusCode >= 400) { + throw ApiException(response.statusCode, _tryDecode(response.body)); + } + if (response.body.isEmpty) return null; + return jsonDecode(response.body); + } + + dynamic _tryDecode(String body) { + try { + return jsonDecode(body); + } catch (_) { + return null; + } + } +} diff --git a/flutter_app/lib/main.dart b/flutter_app/lib/main.dart new file mode 100644 index 0000000..4701304 --- /dev/null +++ b/flutter_app/lib/main.dart @@ -0,0 +1,45 @@ +import 'package:flutter/material.dart'; + +import 'api/api_client.dart'; +import 'screens/login_screen.dart'; +import 'screens/objekt_list_screen.dart'; + +// API-Basis-URL fest im Code statt .env: Flutter-Umgebungsvariablen brauchen +// --dart-define beim Build, unpraktisch für den ersten Wurf. Vor Produktivbetrieb +// hier auf die echte Backend-Adresse setzen bzw. auf --dart-define umstellen. +const String kApiBaseUrl = 'http://192.168.1.238/api/v1'; + +void main() { + runApp(const MabeaApp()); +} + +class MabeaApp extends StatefulWidget { + const MabeaApp({super.key}); + + @override + State createState() => _MabeaAppState(); +} + +class _MabeaAppState extends State { + final _apiClient = ApiClient(baseUrl: kApiBaseUrl); + bool _bereit = false; + + @override + void initState() { + super.initState(); + _apiClient.ladeToken().then((_) => setState(() => _bereit = true)); + } + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'MABEA', + theme: ThemeData(colorSchemeSeed: const Color(0xFF0B5394), useMaterial3: true), + home: !_bereit + ? const Scaffold(body: Center(child: CircularProgressIndicator())) + : _apiClient.istEingeloggt + ? ObjektListScreen(apiClient: _apiClient) + : LoginScreen(apiClient: _apiClient), + ); + } +} diff --git a/flutter_app/lib/screens/kontroll_screen.dart b/flutter_app/lib/screens/kontroll_screen.dart new file mode 100644 index 0000000..89976ab --- /dev/null +++ b/flutter_app/lib/screens/kontroll_screen.dart @@ -0,0 +1,276 @@ +import 'package:flutter/material.dart'; + +import '../api/api_client.dart'; + +/// Prompt 11 Screens 2-6 (kompakt). Erste Android-Fassung ohne Offline-Queue +/// (siehe flutter_app/README.md) - Positionen werden direkt gesendet, bei +/// Netzwerkfehler erscheint eine Fehlermeldung mit Wiederholen-Button statt +/// automatischer Zwischenspeicherung wie in der React-PWA (Prompt 17). +class KontrollScreen extends StatefulWidget { + const KontrollScreen({super.key, required this.apiClient, required this.objektId}); + final ApiClient apiClient; + final int objektId; + + @override + State createState() => _KontrollScreenState(); +} + +class _PositionZustand { + _PositionZustand({required this.position, required this.material}); + final Map position; + final Map? material; + String status = 'unbestaetigt'; // unbestaetigt | wird_gesendet | gespeichert | fehler + String? fehlerText; + late final TextEditingController eingabeController = + TextEditingController(text: position['sollmenge_effektiv'] as String); +} + +class _KontrollScreenState extends State { + Map? _objekt; + Map? _kontrolle; + Map? _sperre; + List<_PositionZustand> _positionen = []; + String? _startFehler; + String? _abschlussFehler; + bool _wirdAbgeschlossen = false; + + @override + void initState() { + super.initState(); + _ladeObjekt(); + _starteKontrolle(false); + } + + Future _ladeObjekt() async { + final objekt = await widget.apiClient.request('/objekte/${widget.objektId}'); + if (mounted) setState(() => _objekt = objekt as Map); + } + + Future _starteKontrolle(bool uebernehmen) async { + setState(() { + _startFehler = null; + _sperre = null; + }); + try { + final kontrolle = await widget.apiClient.request( + '/objekte/${widget.objektId}/kontrollen', + method: 'POST', + body: {'uebernehmen': uebernehmen}, + ); + setState(() => _kontrolle = kontrolle as Map); + await _ladePositionen(); + } on ApiException catch (error) { + if (error.status == 409) { + setState(() => _sperre = error.detail as Map?); + } else { + setState(() => _startFehler = 'Kontrolle konnte nicht gestartet werden.'); + } + } catch (_) { + setState(() => _startFehler = 'Keine Verbindung.'); + } + } + + Future _ladePositionen() async { + final kontrolle = _kontrolle; + if (kontrolle == null) return; + final positionen = await widget.apiClient + .request('/objekte/${kontrolle['objekt_id']}/positionen') as List; + final materialien = + await widget.apiClient.request('/materialien') as List; + final materialById = {for (final m in materialien) m['id'] as int: m as Map}; + + setState(() { + _positionen = positionen + .cast>() + .where((p) => p['ist_status'] == 'aktiv') + .map((p) => _PositionZustand(position: p, material: materialById[p['material_id']])) + .toList(); + }); + } + + Future _positionSenden(_PositionZustand zustand) async { + final kontrolle = _kontrolle; + if (kontrolle == null) return; + setState(() { + zustand.status = 'wird_gesendet'; + zustand.fehlerText = null; + }); + try { + await widget.apiClient.request( + '/kontrollen/${kontrolle['id']}/positionen/${zustand.position['material_id']}', + method: 'PUT', + body: {'istmenge': zustand.eingabeController.text}, + ); + setState(() => zustand.status = 'gespeichert'); + } catch (_) { + setState(() { + zustand.status = 'fehler'; + zustand.fehlerText = 'Senden fehlgeschlagen - erneut versuchen, sobald wieder Verbindung besteht.'; + }); + } + } + + bool get _alleUebertragen => + _positionen.isNotEmpty && _positionen.every((p) => p.status == 'gespeichert'); + + Future _abschliessen() async { + final kontrolle = _kontrolle; + if (kontrolle == null) return; + setState(() { + _wirdAbgeschlossen = true; + _abschlussFehler = null; + }); + try { + await widget.apiClient.request('/kontrollen/${kontrolle['id']}/abschliessen', method: 'POST'); + if (mounted) Navigator.of(context).pop(); + } on ApiException catch (error) { + setState(() { + _abschlussFehler = error.status == 409 + ? 'Nicht alle Positionen wurden erfasst.' + : 'Abschluss fehlgeschlagen.'; + }); + } finally { + if (mounted) setState(() => _wirdAbgeschlossen = false); + } + } + + Future _abbrechen() async { + final kontrolle = _kontrolle; + if (kontrolle == null) return; + await widget.apiClient + .request('/kontrollen/${kontrolle['id']}/abbrechen', method: 'POST', body: {}); + if (mounted) Navigator.of(context).pop(); + } + + @override + Widget build(BuildContext context) { + if (_sperre != null) { + return Scaffold( + appBar: AppBar(title: const Text('Objekt gesperrt')), + body: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Wird gerade kontrolliert von ${_sperre!['benutzer'] ?? 'unbekannt'}, ' + 'seit ${_sperre!['seit'] ?? ''}.', + ), + const SizedBox(height: 16), + FilledButton( + onPressed: () => _starteKontrolle(true), + child: const Text('Trotzdem übernehmen'), + ), + ], + ), + ), + ); + } + + if (_startFehler != null) { + return Scaffold( + appBar: AppBar(title: const Text('Fehler')), + body: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(_startFehler!, style: const TextStyle(color: Colors.red)), + TextButton(onPressed: () => _starteKontrolle(false), child: const Text('Erneut versuchen')), + ], + ), + ), + ); + } + + return Scaffold( + appBar: AppBar(title: Text(_objekt?['name'] as String? ?? 'Kontrolle')), + body: ListView.builder( + itemCount: _positionen.length, + itemBuilder: (context, index) { + final zustand = _positionen[index]; + return Card( + margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + zustand.material?['name'] as String? ?? + 'Material ${zustand.position['material_id']}', + style: const TextStyle(fontWeight: FontWeight.bold), + ), + _StatusChip(status: zustand.status), + ], + ), + Text('Soll: ${zustand.position['sollmenge_effektiv']} ${zustand.material?['einheit'] ?? ''}'), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: TextField( + controller: zustand.eingabeController, + keyboardType: TextInputType.number, + ), + ), + const SizedBox(width: 8), + FilledButton( + onPressed: () => _positionSenden(zustand), + child: const Text('Bestätigen'), + ), + ], + ), + if (zustand.status == 'fehler' && zustand.fehlerText != null) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text(zustand.fehlerText!, style: const TextStyle(color: Colors.red)), + ), + ], + ), + ), + ); + }, + ), + bottomNavigationBar: SafeArea( + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (_abschlussFehler != null) + Text(_abschlussFehler!, style: const TextStyle(color: Colors.red)), + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: _alleUebertragen && !_wirdAbgeschlossen ? _abschliessen : null, + child: Text(_alleUebertragen ? 'Kontrolle abschließen' : 'Noch nicht alle Positionen gespeichert…'), + ), + ), + TextButton(onPressed: _abbrechen, child: const Text('Kontrolle abbrechen')), + ], + ), + ), + ), + ); + } +} + +class _StatusChip extends StatelessWidget { + const _StatusChip({required this.status}); + final String status; + + @override + Widget build(BuildContext context) { + final konfiguration = { + 'unbestaetigt': ('offen', Colors.grey), + 'wird_gesendet': ('wird gesendet…', Colors.orange), + 'gespeichert': ('gespeichert', Colors.green), + 'fehler': ('Fehler', Colors.red), + }; + final (text, farbe) = konfiguration[status] ?? ('?', Colors.grey); + return Text(text, style: TextStyle(color: farbe, fontWeight: FontWeight.bold)); + } +} diff --git a/flutter_app/lib/screens/login_screen.dart b/flutter_app/lib/screens/login_screen.dart new file mode 100644 index 0000000..d81c8e0 --- /dev/null +++ b/flutter_app/lib/screens/login_screen.dart @@ -0,0 +1,75 @@ +import 'package:flutter/material.dart'; + +import '../api/api_client.dart'; +import 'objekt_list_screen.dart'; + +class LoginScreen extends StatefulWidget { + const LoginScreen({super.key, required this.apiClient}); + final ApiClient apiClient; + + @override + State createState() => _LoginScreenState(); +} + +class _LoginScreenState extends State { + final _loginController = TextEditingController(); + final _passwortController = TextEditingController(); + String? _fehler; + bool _ladeVorgang = false; + + Future _anmelden() async { + setState(() { + _fehler = null; + _ladeVorgang = true; + }); + try { + await widget.apiClient.login(_loginController.text, _passwortController.text); + if (!mounted) return; + Navigator.of(context).pushReplacement( + MaterialPageRoute(builder: (_) => ObjektListScreen(apiClient: widget.apiClient)), + ); + } catch (_) { + setState(() => _fehler = 'Login oder Passwort falsch'); + } finally { + if (mounted) setState(() => _ladeVorgang = false); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Text('MABEA', style: TextStyle(fontSize: 32, fontWeight: FontWeight.bold)), + const SizedBox(height: 24), + TextField( + controller: _loginController, + decoration: const InputDecoration(labelText: 'Login'), + ), + const SizedBox(height: 12), + TextField( + controller: _passwortController, + decoration: const InputDecoration(labelText: 'Passwort'), + obscureText: true, + ), + if (_fehler != null) ...[ + const SizedBox(height: 12), + Text(_fehler!, style: const TextStyle(color: Colors.red)), + ], + const SizedBox(height: 24), + FilledButton( + onPressed: _ladeVorgang ? null : _anmelden, + child: Text(_ladeVorgang ? 'Wird geprüft…' : 'Anmelden'), + ), + ], + ), + ), + ), + ); + } +} diff --git a/flutter_app/lib/screens/objekt_list_screen.dart b/flutter_app/lib/screens/objekt_list_screen.dart new file mode 100644 index 0000000..c34fd73 --- /dev/null +++ b/flutter_app/lib/screens/objekt_list_screen.dart @@ -0,0 +1,82 @@ +import 'package:flutter/material.dart'; + +import '../api/api_client.dart'; +import 'kontroll_screen.dart'; + +/// Prompt 11 Screen 1: Standort/Objekt wählen. Kamera-Scan (Karte 10) ist Roadmap - +/// das Suchfeld nimmt schon jetzt einen gescannten Code entgegen, sobald ein +/// Scanner-Plugin ergänzt wird. +class ObjektListScreen extends StatefulWidget { + const ObjektListScreen({super.key, required this.apiClient}); + final ApiClient apiClient; + + @override + State createState() => _ObjektListScreenState(); +} + +class _ObjektListScreenState extends State { + List _objekte = []; + String _suche = ''; + String? _fehler; + + @override + void initState() { + super.initState(); + _laden(); + } + + Future _laden() async { + try { + final ergebnis = await widget.apiClient.request('/objekte'); + setState(() => _objekte = ergebnis as List); + } catch (_) { + setState(() => _fehler = 'Objektliste konnte nicht geladen werden.'); + } + } + + @override + Widget build(BuildContext context) { + final gefiltert = _objekte.where((o) { + final name = (o['name'] as String).toLowerCase(); + final code = (o['code'] as String).toLowerCase(); + final suche = _suche.toLowerCase(); + return name.contains(suche) || code.contains(suche); + }).toList(); + + return Scaffold( + appBar: AppBar(title: const Text('Objekte')), + body: Column( + children: [ + Padding( + padding: const EdgeInsets.all(12), + child: TextField( + decoration: const InputDecoration(labelText: 'Suche oder Code scannen…'), + onChanged: (wert) => setState(() => _suche = wert), + ), + ), + if (_fehler != null) Text(_fehler!, style: const TextStyle(color: Colors.red)), + Expanded( + child: ListView.builder( + itemCount: gefiltert.length, + itemBuilder: (context, index) { + final objekt = gefiltert[index]; + return ListTile( + title: Text(objekt['name'] as String), + subtitle: Text(objekt['code'] as String), + onTap: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => KontrollScreen( + apiClient: widget.apiClient, + objektId: objekt['id'] as int, + ), + ), + ), + ); + }, + ), + ), + ], + ), + ); + } +} diff --git a/flutter_app/pubspec.yaml b/flutter_app/pubspec.yaml new file mode 100644 index 0000000..b309375 --- /dev/null +++ b/flutter_app/pubspec.yaml @@ -0,0 +1,21 @@ +name: mabea_app +description: MABEA - Digitales Materialmanagement Rettungsdienst/KatS (Android-Client) +publish_to: "none" +version: 0.1.0+1 + +environment: + sdk: ">=3.3.0 <4.0.0" + +dependencies: + flutter: + sdk: flutter + http: ^1.2.2 + shared_preferences: ^2.3.2 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^4.0.0 + +flutter: + uses-material-design: true diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..9f7f96d --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +dev-dist/ +.env +.env.local diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..e32a4f5 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,44 @@ +# MABEA Frontend – Sprint 7 + +React + Vite PWA. Deckt Prompt 11 Screens 1-6 (kompakt zusammengefasst: Objektliste, +Kontrolle mit Positionserfassung, Abschluss/Abbruch) und Prompt 17 (Offline-Härtung: +IndexedDB-Queue, Statusanzeige je Position, Abschluss blockiert bis alles gespeichert) ab. + +## Setup (auf dem Zielsystem, nicht lokal) + +```bash +npm install +cp example.env .env +# .env: VITE_API_BASE_URL auf die Backend-URL setzen +npm run build +``` + +`npm run build` erzeugt `dist/` inkl. Service Worker (vite-plugin-pwa) - dieser Ordner +wird vom Reverse-Proxy (nginx) ausgeliefert, siehe `deploy/`. + +## Fehlende Assets vor Produktivbetrieb + +- `public/icon-192.png`, `public/icon-512.png` (PWA-Manifest-Icons) müssen noch ergänzt + werden - aktuell nur in `vite.config.ts` referenziert, keine echten Bilddateien vorhanden. + +## Architektur-Entscheidungen (Sprint 7) + +- **Offline-Queue** (`src/offline/queue.ts`): IndexedDB statt reinem Service-Worker-Cache, + weil tatsächliche Formulardaten (Ist-Mengen) über Reload/Absturz hinweg erhalten bleiben + müssen. Der Service Worker selbst cached nur den App-Shell (Assets), keine API-Requests. +- **Idempotenz**: `PUT /kontrollen/{id}/positionen/{material_id}` ist backend-seitig ein + Upsert (siehe `backend/app/services/kontrolle.py`) - die Queue kann denselben Eintrag + beliebig oft erneut senden, ohne doppelte Kontrollpositionen/Fehlbestände zu erzeugen. +- **Statusanzeige**: jede Position zeigt "nicht gespeichert" / "wird übertragen…" / + "gespeichert" / "Fehler" - kein stiller Datenverlust bei Verbindungsabbruch (Prompt 17.5). +- **Abschluss-Sperre**: der Abschließen-Button ist deaktiviert, solange auch nur eine + Position nicht bestätigt gespeichert ist. +- Client-Fehler (4xx, z. B. Objekt-Sperre durch Übernahme) werden NICHT automatisch + wiederholt - der Mitarbeiter sieht den Fehler und muss bewusst reagieren. + +## Noch nicht umgesetzt (bewusst außerhalb Sprint 7) + +- QR-/Barcode-Kamera-Scan (Karte 10, Roadmap) - Objektsuche ist vorbereitet (Textfeld + akzeptiert auch einen gescannten Code), aber kein Kamera-Zugriff verdrahtet. +- Playwright-E2E-Tests und Praktiker-Explorations-Session (Testphase 4) - dafür wird ein + laufendes Test-Deployment gebraucht, kein reiner Code-Schritt. diff --git a/frontend/example.env b/frontend/example.env new file mode 100644 index 0000000..cb35b5d --- /dev/null +++ b/frontend/example.env @@ -0,0 +1 @@ +VITE_API_BASE_URL=http://192.168.1.238/api/v1 diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..02fd5b1 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + MABEA + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..121e3c9 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,25 @@ +{ + "name": "mabea-frontend", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc --noEmit && vite build", + "preview": "vite preview" + }, + "dependencies": { + "idb-keyval": "^6.2.1", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.26.2" + }, + "devDependencies": { + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.1", + "typescript": "^5.5.4", + "vite": "^5.4.6", + "vite-plugin-pwa": "^0.20.5" + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..758d98b --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,42 @@ +import type { ReactNode } from "react"; +import { Navigate, Route, Routes } from "react-router-dom"; + +import { AuthProvider, useAuth } from "./auth/AuthContext"; +import { KontrollPage } from "./pages/KontrollPage"; +import { LoginPage } from "./pages/LoginPage"; +import { ObjektListPage } from "./pages/ObjektListPage"; + +function GeschuetzteRoute({ children }: { children: ReactNode }) { + const { eingeloggt } = useAuth(); + if (!eingeloggt) { + return ; + } + return <>{children}; +} + +export default function App() { + return ( + + + } /> + + + + } + /> + + + + } + /> + } /> + + + ); +} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 0000000..dba5b97 --- /dev/null +++ b/frontend/src/api/client.ts @@ -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(path: string, options: RequestOptions = {}): Promise { + const headers: Record = { "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 { + 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; +} diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts new file mode 100644 index 0000000..1608880 --- /dev/null +++ b/frontend/src/api/types.ts @@ -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; +} diff --git a/frontend/src/auth/AuthContext.tsx b/frontend/src/auth/AuthContext.tsx new file mode 100644 index 0000000..2d0239f --- /dev/null +++ b/frontend/src/auth/AuthContext.tsx @@ -0,0 +1,41 @@ +import { createContext, useContext, useMemo, useState, type ReactNode } from "react"; + +import { getAuthToken, login as apiLogin, setAuthToken } from "../api/client"; + +interface AuthContextValue { + eingeloggt: boolean; + login: (username: string, password: string) => Promise; + logout: () => void; +} + +const AuthContext = createContext(null); + +export function AuthProvider({ children }: { children: ReactNode }) { + const [eingeloggt, setEingeloggt] = useState(getAuthToken() !== null); + + const value = useMemo( + () => ({ + eingeloggt, + login: async (username: string, password: string) => { + const token = await apiLogin(username, password); + setAuthToken(token); + setEingeloggt(true); + }, + logout: () => { + setAuthToken(null); + setEingeloggt(false); + }, + }), + [eingeloggt] + ); + + return {children}; +} + +export function useAuth(): AuthContextValue { + const ctx = useContext(AuthContext); + if (!ctx) { + throw new Error("useAuth muss innerhalb von AuthProvider verwendet werden"); + } + return ctx; +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..26886f0 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,13 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { BrowserRouter } from "react-router-dom"; + +import App from "./App"; + +createRoot(document.getElementById("root")!).render( + + + + + +); diff --git a/frontend/src/offline/queue.ts b/frontend/src/offline/queue.ts new file mode 100644 index 0000000..7a2e9aa --- /dev/null +++ b/frontend/src/offline/queue.ts @@ -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 { + 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 { + const all = await entries(store); + return all.map(([, value]) => value); +} + +export async function eintraegeFuerKontrolle(kontrolleId: string): Promise { + 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 { + const eintraege = await eintraegeFuerKontrolle(kontrolleId); + return eintraege.length > 0; +} + +async function sendeEintrag(item: QueueItem): Promise { + await set(item.key, { ...item, status: "wird_uebertragen" as QueueStatus }, store); + try { + const ergebnis = await apiRequest( + `/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 { + 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 { + await del(keyFor(kontrolleId, materialId), store); +} diff --git a/frontend/src/offline/useOnlineStatus.ts b/frontend/src/offline/useOnlineStatus.ts new file mode 100644 index 0000000..cd05ce1 --- /dev/null +++ b/frontend/src/offline/useOnlineStatus.ts @@ -0,0 +1,37 @@ +import { useEffect, useState } from "react"; + +import { synchronisiereQueue } from "./queue"; + +/** Prompt 17: bei wiederhergestellter Verbindung automatische Übertragung der + * zwischengespeicherten Eingaben, ohne dass der Mitarbeiter erneut eintippt. */ +export function useOnlineStatus(): boolean { + const [online, setOnline] = useState(navigator.onLine); + + useEffect(() => { + const handleOnline = () => { + setOnline(true); + void synchronisiereQueue(); + }; + const handleOffline = () => setOnline(false); + + window.addEventListener("online", handleOnline); + window.addEventListener("offline", handleOffline); + + // Periodischer Versuch: "online" laut Browser heißt nicht zwingend, dass der + // Backend-Server erreichbar ist (z. B. WLAN ohne Internet). 15s ist ein + // pragmatischer Kompromiss zwischen Akkuverbrauch und Reaktionszeit. + const interval = window.setInterval(() => { + if (navigator.onLine) { + void synchronisiereQueue(); + } + }, 15000); + + return () => { + window.removeEventListener("online", handleOnline); + window.removeEventListener("offline", handleOffline); + window.clearInterval(interval); + }; + }, []); + + return online; +} diff --git a/frontend/src/pages/KontrollPage.tsx b/frontend/src/pages/KontrollPage.tsx new file mode 100644 index 0000000..178df48 --- /dev/null +++ b/frontend/src/pages/KontrollPage.tsx @@ -0,0 +1,271 @@ +import { useEffect, useMemo, useState } from "react"; +import { useNavigate, useParams } from "react-router-dom"; + +import { apiRequest, ApiError } from "../api/client"; +import type { Kontrolle, Material, Objekt, Objektposition } from "../api/types"; +import { useOnlineStatus } from "../offline/useOnlineStatus"; +import { enqueue, eintraegeFuerKontrolle, entferneFehlerEintrag, synchronisiereQueue } from "../offline/queue"; +import type { QueueStatus } from "../offline/queue"; + +type PositionStatus = "unbestaetigt" | QueueStatus | "gespeichert"; + +interface PositionZustand { + position: Objektposition; + material: Material | undefined; + status: PositionStatus; + eingabe: string; + fehlerText: string | null; +} + +interface Sperrmeldung { + benutzer: string | null; + seit: string; +} + +// Prompt 11 Screens 2-6 (kompakt zusammengefasst) + Prompt 17 Offline-Härtung: +// Start -> Positionen erfassen (über die lokale Queue, nie direkt blockierend) -> +// Abschluss erst möglich, wenn nichts mehr in der Queue unbestätigt liegt. +export function KontrollPage() { + const { objektId } = useParams<{ objektId: string }>(); + const navigate = useNavigate(); + const online = useOnlineStatus(); + + const [objekt, setObjekt] = useState(null); + const [kontrolle, setKontrolle] = useState(null); + const [sperre, setSperre] = useState(null); + const [positionen, setPositionen] = useState([]); + const [startFehler, setStartFehler] = useState(null); + const [wirdAbgeschlossen, setWirdAbgeschlossen] = useState(false); + const [abschlussFehler, setAbschlussFehler] = useState(null); + + useEffect(() => { + if (!objektId) return; + apiRequest(`/objekte/${objektId}`).then(setObjekt).catch(() => undefined); + }, [objektId]); + + async function starteKontrolle(uebernehmen: boolean) { + if (!objektId) return; + setStartFehler(null); + try { + const neueKontrolle = await apiRequest(`/objekte/${objektId}/kontrollen`, { + method: "POST", + body: { uebernehmen }, + }); + setKontrolle(neueKontrolle); + setSperre(null); + } catch (error) { + if (error instanceof ApiError && error.status === 409) { + const detail = error.detail as { benutzer?: string; seit?: string } | null; + setSperre({ benutzer: detail?.benutzer ?? null, seit: detail?.seit ?? "" }); + } else { + setStartFehler("Kontrolle konnte nicht gestartet werden (keine Verbindung?)."); + } + } + } + + useEffect(() => { + void starteKontrolle(false); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [objektId]); + + useEffect(() => { + if (!kontrolle) return; + Promise.all([ + apiRequest(`/objekte/${kontrolle.objekt_id}/positionen`), + apiRequest("/materialien"), + ]).then(([positionsListe, materialListe]) => { + const materialById = new Map(materialListe.map((m) => [m.id, m])); + setPositionen( + positionsListe + .filter((p) => p.ist_status === "aktiv") + .map((p) => ({ + position: p, + material: materialById.get(p.material_id), + status: "unbestaetigt", + eingabe: p.sollmenge_effektiv, + fehlerText: null, + })) + ); + }); + }, [kontrolle]); + + // Reconciliation-Loop: gleicht lokalen Anzeigezustand mit der Offline-Queue ab. + // Ein Eintrag, der aus der Queue verschwunden ist, wurde erfolgreich übertragen + // (queue.ts entfernt Einträge ausschließlich bei Erfolg, siehe dort). + useEffect(() => { + if (!kontrolle) return; + const interval = window.setInterval(async () => { + const eintraege = await eintraegeFuerKontrolle(kontrolle.id); + const byMaterial = new Map(eintraege.map((e) => [e.materialId, e])); + setPositionen((bisherige) => + bisherige.map((zustand) => { + const inQueue = byMaterial.get(zustand.position.material_id); + if (inQueue) { + return { ...zustand, status: inQueue.status, fehlerText: inQueue.fehlerText }; + } + if (zustand.status === "wartet" || zustand.status === "wird_uebertragen") { + return { ...zustand, status: "gespeichert", fehlerText: null }; + } + return zustand; + }) + ); + }, 2000); + return () => window.clearInterval(interval); + }, [kontrolle]); + + useEffect(() => { + if (online) void synchronisiereQueue(); + }, [online]); + + function eingabeAendern(materialId: number, wert: string) { + setPositionen((bisherige) => + bisherige.map((z) => (z.position.material_id === materialId ? { ...z, eingabe: wert } : z)) + ); + } + + async function positionSenden(materialId: number, istmenge: string) { + if (!kontrolle) return; + await enqueue(kontrolle.id, materialId, istmenge); + setPositionen((bisherige) => + bisherige.map((z) => (z.position.material_id === materialId ? { ...z, status: "wartet" } : z)) + ); + void synchronisiereQueue(); + } + + async function fehlerErneutVersuchen(materialId: number, istmenge: string) { + if (!kontrolle) return; + await entferneFehlerEintrag(kontrolle.id, materialId); + await positionSenden(materialId, istmenge); + } + + const alleUebertragen = useMemo( + () => positionen.length > 0 && positionen.every((p) => p.status === "gespeichert"), + [positionen] + ); + + async function abschliessen() { + if (!kontrolle) return; + setWirdAbgeschlossen(true); + setAbschlussFehler(null); + try { + await apiRequest(`/kontrollen/${kontrolle.id}/abschliessen`, { method: "POST" }); + navigate("/objekte"); + } catch (error) { + if (error instanceof ApiError && error.status === 409) { + setAbschlussFehler("Nicht alle Positionen wurden erfasst."); + } else { + setAbschlussFehler("Abschluss fehlgeschlagen (keine Verbindung?)."); + } + } finally { + setWirdAbgeschlossen(false); + } + } + + async function abbrechen() { + if (!kontrolle) return; + const grund = window.prompt("Grund für den Abbruch (optional):") ?? undefined; + await apiRequest(`/kontrollen/${kontrolle.id}/abbrechen`, { method: "POST", body: { grund } }); + navigate("/objekte"); + } + + if (sperre) { + return ( +
+

Objekt gesperrt

+

+ Wird gerade kontrolliert von {sperre.benutzer ?? "unbekannt"}, seit {sperre.seit}. +

+ +
+ ); + } + + if (startFehler) { + return ( +
+

{startFehler}

+ +
+ ); + } + + return ( +
+

{objekt?.name ?? "Kontrolle"}

+ {!online && ( +

+ Kein Netz - Eingaben werden lokal gespeichert und automatisch übertragen, sobald wieder Verbindung besteht. +

+ )} +
    + {positionen.map((zustand) => ( +
  • +
    + {zustand.material?.name ?? `Material ${zustand.position.material_id}`} + +
    +
    + Soll: {zustand.position.sollmenge_effektiv} {zustand.material?.einheit ?? ""} +
    +
    + eingabeAendern(zustand.position.material_id, e.target.value)} + style={{ width: "5rem", fontSize: "1.1rem", padding: "0.5rem" }} + /> + +
    + {zustand.status === "fehler" && ( +
    +

    {zustand.fehlerText}

    + +
    + )} +
  • + ))} +
+ + {abschlussFehler &&

{abschlussFehler}

} + + +
+ ); +} + +function StatusBadge({ status }: { status: PositionStatus }) { + const konfiguration: Record = { + unbestaetigt: { text: "offen", farbe: "#999" }, + wartet: { text: "nicht gespeichert", farbe: "#e0a800" }, + wird_uebertragen: { text: "wird übertragen…", farbe: "#e0a800" }, + gespeichert: { text: "gespeichert", farbe: "#2e7d32" }, + fehler: { text: "Fehler", farbe: "#c62828" }, + }; + const { text, farbe } = konfiguration[status]; + return {text}; +} diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx new file mode 100644 index 0000000..8f7637d --- /dev/null +++ b/frontend/src/pages/LoginPage.tsx @@ -0,0 +1,62 @@ +import { useState, type FormEvent } from "react"; +import { useNavigate } from "react-router-dom"; + +import { useAuth } from "../auth/AuthContext"; + +export function LoginPage() { + const { login } = useAuth(); + const navigate = useNavigate(); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [fehler, setFehler] = useState(null); + const [wirdGeladen, setWirdGeladen] = useState(false); + + async function handleSubmit(event: FormEvent) { + event.preventDefault(); + setFehler(null); + setWirdGeladen(true); + try { + await login(username, password); + navigate("/objekte"); + } catch { + setFehler("Login oder Passwort falsch"); + } finally { + setWirdGeladen(false); + } + } + + return ( +
+

MABEA

+
+ + + {fehler &&

{fehler}

} + +
+
+ ); +} diff --git a/frontend/src/pages/ObjektListPage.tsx b/frontend/src/pages/ObjektListPage.tsx new file mode 100644 index 0000000..d110483 --- /dev/null +++ b/frontend/src/pages/ObjektListPage.tsx @@ -0,0 +1,59 @@ +import { useEffect, useState } from "react"; +import { Link } from "react-router-dom"; + +import { apiRequest } from "../api/client"; +import type { Objekt } from "../api/types"; + +// Prompt 11 Screen 1: Standort/Objekt wählen. QR-/Barcode-Scan (Karte 10) ist +// Roadmap - dieser Screen bietet bewusst schon die Code-Suche als Textfeld an, +// ein späterer Kamera-Scan kann denselben Code einfach in dasselbe Feld schreiben. +export function ObjektListPage() { + const [objekte, setObjekte] = useState([]); + const [suche, setSuche] = useState(""); + const [ladefehler, setLadefehler] = useState(null); + + useEffect(() => { + apiRequest("/objekte") + .then(setObjekte) + .catch(() => setLadefehler("Objektliste konnte nicht geladen werden.")); + }, []); + + const gefiltert = objekte.filter( + (o) => + o.name.toLowerCase().includes(suche.toLowerCase()) || + o.code.toLowerCase().includes(suche.toLowerCase()) + ); + + return ( +
+

Objekte

+ setSuche(e.target.value)} + style={{ width: "100%", fontSize: "1.2rem", padding: "0.75rem", marginBottom: "1rem" }} + /> + {ladefehler &&

{ladefehler}

} +
    + {gefiltert.map((objekt) => ( +
  • + + {objekt.name} +
    {objekt.code}
    + +
  • + ))} +
+
+ ); +} diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 0000000..8304c33 --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1,9 @@ +/// + +interface ImportMetaEnv { + readonly VITE_API_BASE_URL: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..8ca2af4 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..d3b2593 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,36 @@ +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; +import { VitePWA } from "vite-plugin-pwa"; + +// MABEA PWA (Sprint 7 / Prompt 17): Service Worker cached App-Shell für schnellen +// Start im Einsatzumfeld. Die eigentliche Offline-Pufferung von Kontroll-Eingaben +// läuft NICHT über den Service Worker, sondern über eine IndexedDB-Queue +// (src/offline/queue.ts) - Service Worker allein reicht für Formular-Requests +// nicht aus, ohne Background-Sync-Komplexität einzuführen. +export default defineConfig({ + plugins: [ + react(), + VitePWA({ + registerType: "autoUpdate", + workbox: { + globPatterns: ["**/*.{js,css,html,svg,png,ico}"], + }, + manifest: { + name: "MABEA", + short_name: "MABEA", + description: "Digitales Materialmanagement Rettungsdienst/KatS", + theme_color: "#0b5394", + background_color: "#ffffff", + display: "standalone", + start_url: "/", + icons: [ + { src: "icon-192.png", sizes: "192x192", type: "image/png" }, + { src: "icon-512.png", sizes: "512x512", type: "image/png" }, + ], + }, + }), + ], + server: { + port: 5173, + }, +});