Sprint 7: React-PWA-Frontend (Offline-Härtung) + Flutter-Android-Grundgerüst
CI / backend-tests (push) Successful in 54s
CI / backend-tests (push) Successful in 54s
React/Vite-PWA (frontend/):
- Login, Objektliste (Prompt 11 Screen 1), Kontroll-Screen (Screens 2-6 kompakt:
Start/Objekt-Sperre-Übernahme, Positionserfassung, Abschluss/Abbruch)
- Offline-Härtung (Prompt 17): IndexedDB-Queue (src/offline/queue.ts) puffert
Positions-PUTs lokal, automatische Übertragung bei Reconnect + periodischem
Sync-Versuch, Statusanzeige je Position (nicht gespeichert/wird übertragen/
gespeichert/Fehler), Abschluss-Button bleibt gesperrt bis alles gespeichert
- Nutzt aus, dass PUT /kontrollen/{id}/positionen/{material_id} backend-seitig
idempotent ist (Upsert) - Queue kann beliebig oft retryen ohne Duplikate
- Service Worker via vite-plugin-pwa für App-Shell-Caching (Start im Feld ohne
Cold-Load); PWA-Icons als TODO vermerkt (noch keine echten Bilddateien)
- Client-Fehler (4xx) werden nicht automatisch wiederholt, nur Netzwerkfehler
Flutter-Android-Grundgerüst (flutter_app/), auf Nutzerwunsch parallel begonnen:
- Gleiche API als zweiter Client (Prompt 19 API-first), Login/Objektliste/
Kontroll-Screen als Dart-Äquivalent zur PWA
- Bewusst OHNE Offline-Queue in dieser ersten Fassung (siehe flutter_app/README.md)
- Plattform-Ordner (android/, ios/) nicht von Hand erzeugt - müssen auf dem
Zielsystem per `flutter create .` nachgezogen werden, sonst zu fehleranfällig
ohne Testlauf
Offen: Playwright-E2E (Testphase 4), Praktiker-Session, PWA-Icons, Flutter-
Offline-Queue - brauchen laufendes Deployment bzw. sind kein reiner Code-Schritt.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L85hmKbvX7Cqkq47KnQhFt
This commit is contained in:
@@ -1350,3 +1350,59 @@ Keine Commits in dieser Session.
|
|||||||
- backend/tests/test_mindermenge.py | 187 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
- 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 +++++++++++++++++++++++++++++++
|
||||||
|
|
||||||
|
---
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
.dart_tool/
|
||||||
|
.packages
|
||||||
|
build/
|
||||||
|
.flutter-plugins
|
||||||
|
.flutter-plugins-dependencies
|
||||||
|
android/
|
||||||
|
ios/
|
||||||
|
*.iml
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
include: package:flutter_lints/flutter.yaml
|
||||||
@@ -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<void> ladeToken() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
_token = prefs.getString('mabea_token');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> 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<String> 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<String, dynamic>;
|
||||||
|
final token = data['access_token'] as String;
|
||||||
|
await setzeToken(token);
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<dynamic> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<MabeaApp> createState() => _MabeaAppState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MabeaAppState extends State<MabeaApp> {
|
||||||
|
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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<KontrollScreen> createState() => _KontrollScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PositionZustand {
|
||||||
|
_PositionZustand({required this.position, required this.material});
|
||||||
|
final Map<String, dynamic> position;
|
||||||
|
final Map<String, dynamic>? 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<KontrollScreen> {
|
||||||
|
Map<String, dynamic>? _objekt;
|
||||||
|
Map<String, dynamic>? _kontrolle;
|
||||||
|
Map<String, dynamic>? _sperre;
|
||||||
|
List<_PositionZustand> _positionen = [];
|
||||||
|
String? _startFehler;
|
||||||
|
String? _abschlussFehler;
|
||||||
|
bool _wirdAbgeschlossen = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_ladeObjekt();
|
||||||
|
_starteKontrolle(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _ladeObjekt() async {
|
||||||
|
final objekt = await widget.apiClient.request('/objekte/${widget.objektId}');
|
||||||
|
if (mounted) setState(() => _objekt = objekt as Map<String, dynamic>);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _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<String, dynamic>);
|
||||||
|
await _ladePositionen();
|
||||||
|
} on ApiException catch (error) {
|
||||||
|
if (error.status == 409) {
|
||||||
|
setState(() => _sperre = error.detail as Map<String, dynamic>?);
|
||||||
|
} else {
|
||||||
|
setState(() => _startFehler = 'Kontrolle konnte nicht gestartet werden.');
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
setState(() => _startFehler = 'Keine Verbindung.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _ladePositionen() async {
|
||||||
|
final kontrolle = _kontrolle;
|
||||||
|
if (kontrolle == null) return;
|
||||||
|
final positionen = await widget.apiClient
|
||||||
|
.request('/objekte/${kontrolle['objekt_id']}/positionen') as List<dynamic>;
|
||||||
|
final materialien =
|
||||||
|
await widget.apiClient.request('/materialien') as List<dynamic>;
|
||||||
|
final materialById = {for (final m in materialien) m['id'] as int: m as Map<String, dynamic>};
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_positionen = positionen
|
||||||
|
.cast<Map<String, dynamic>>()
|
||||||
|
.where((p) => p['ist_status'] == 'aktiv')
|
||||||
|
.map((p) => _PositionZustand(position: p, material: materialById[p['material_id']]))
|
||||||
|
.toList();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _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<void> _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<void> _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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<LoginScreen> createState() => _LoginScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LoginScreenState extends State<LoginScreen> {
|
||||||
|
final _loginController = TextEditingController();
|
||||||
|
final _passwortController = TextEditingController();
|
||||||
|
String? _fehler;
|
||||||
|
bool _ladeVorgang = false;
|
||||||
|
|
||||||
|
Future<void> _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'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<ObjektListScreen> createState() => _ObjektListScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ObjektListScreenState extends State<ObjektListScreen> {
|
||||||
|
List<dynamic> _objekte = [];
|
||||||
|
String _suche = '';
|
||||||
|
String? _fehler;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_laden();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _laden() async {
|
||||||
|
try {
|
||||||
|
final ergebnis = await widget.apiClient.request('/objekte');
|
||||||
|
setState(() => _objekte = ergebnis as List<dynamic>);
|
||||||
|
} 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
dev-dist/
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
VITE_API_BASE_URL=http://192.168.1.238/api/v1
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
|
<meta name="theme-color" content="#0b5394" />
|
||||||
|
<title>MABEA</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 <Navigate to="/login" replace />;
|
||||||
|
}
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<AuthProvider>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
<Route
|
||||||
|
path="/objekte"
|
||||||
|
element={
|
||||||
|
<GeschuetzteRoute>
|
||||||
|
<ObjektListPage />
|
||||||
|
</GeschuetzteRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/objekte/:objektId/kontrolle"
|
||||||
|
element={
|
||||||
|
<GeschuetzteRoute>
|
||||||
|
<KontrollPage />
|
||||||
|
</GeschuetzteRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route path="*" element={<Navigate to="/objekte" replace />} />
|
||||||
|
</Routes>
|
||||||
|
</AuthProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||||
|
const headers: Record<string, string> = { "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<string> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<void>;
|
||||||
|
logout: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [eingeloggt, setEingeloggt] = useState<boolean>(getAuthToken() !== null);
|
||||||
|
|
||||||
|
const value = useMemo<AuthContextValue>(
|
||||||
|
() => ({
|
||||||
|
eingeloggt,
|
||||||
|
login: async (username: string, password: string) => {
|
||||||
|
const token = await apiLogin(username, password);
|
||||||
|
setAuthToken(token);
|
||||||
|
setEingeloggt(true);
|
||||||
|
},
|
||||||
|
logout: () => {
|
||||||
|
setAuthToken(null);
|
||||||
|
setEingeloggt(false);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[eingeloggt]
|
||||||
|
);
|
||||||
|
|
||||||
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth(): AuthContextValue {
|
||||||
|
const ctx = useContext(AuthContext);
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error("useAuth muss innerhalb von AuthProvider verwendet werden");
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
|
<StrictMode>
|
||||||
|
<BrowserRouter>
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
|
</StrictMode>
|
||||||
|
);
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { createStore, del, entries, set } from "idb-keyval";
|
||||||
|
|
||||||
|
import { apiRequest, ApiError } from "../api/client";
|
||||||
|
import type { Kontrollposition } from "../api/types";
|
||||||
|
|
||||||
|
// Prompt 17: Eingaben werden lokal zwischengespeichert, sobald eingegeben - nicht
|
||||||
|
// erst bei Übertragung an den Server verworfen, wenn die Verbindung kurz fehlt.
|
||||||
|
// IndexedDB statt reinem Service-Worker-Cache, weil hier tatsächliche Nutzdaten
|
||||||
|
// (Ist-Mengen) über einen Neustart/Tab-Wechsel hinweg erhalten bleiben müssen.
|
||||||
|
const store = createStore("mabea-offline-queue", "kontrollpositionen");
|
||||||
|
|
||||||
|
export type QueueStatus = "wartet" | "wird_uebertragen" | "fehler";
|
||||||
|
|
||||||
|
export interface QueueItem {
|
||||||
|
key: string; // `${kontrolleId}:${materialId}`
|
||||||
|
kontrolleId: string;
|
||||||
|
materialId: number;
|
||||||
|
istmenge: string;
|
||||||
|
status: QueueStatus;
|
||||||
|
fehlerText: string | null;
|
||||||
|
fehlbestandId: string | null; // wird nach erfolgreicher Übertragung befüllt
|
||||||
|
}
|
||||||
|
|
||||||
|
function keyFor(kontrolleId: string, materialId: number): string {
|
||||||
|
return `${kontrolleId}:${materialId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function enqueue(kontrolleId: string, materialId: number, istmenge: string): Promise<void> {
|
||||||
|
const key = keyFor(kontrolleId, materialId);
|
||||||
|
const item: QueueItem = {
|
||||||
|
key,
|
||||||
|
kontrolleId,
|
||||||
|
materialId,
|
||||||
|
istmenge,
|
||||||
|
status: "wartet",
|
||||||
|
fehlerText: null,
|
||||||
|
fehlbestandId: null,
|
||||||
|
};
|
||||||
|
await set(key, item, store);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function alleEintraege(): Promise<QueueItem[]> {
|
||||||
|
const all = await entries<string, QueueItem>(store);
|
||||||
|
return all.map(([, value]) => value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function eintraegeFuerKontrolle(kontrolleId: string): Promise<QueueItem[]> {
|
||||||
|
const all = await alleEintraege();
|
||||||
|
return all.filter((item) => item.kontrolleId === kontrolleId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Prompt 16: Abschluss darf erst möglich sein, wenn nichts mehr unbestätigt in
|
||||||
|
* der Warteschlange liegt (weder "wartet" noch "wird_uebertragen"). Ein
|
||||||
|
* "fehler"-Eintrag blockiert ebenfalls - der Mitarbeiter muss ihn sehen und
|
||||||
|
* bewusst erneut versuchen, kein stiller Datenverlust. */
|
||||||
|
export async function hatUnbestaetigtePositionen(kontrolleId: string): Promise<boolean> {
|
||||||
|
const eintraege = await eintraegeFuerKontrolle(kontrolleId);
|
||||||
|
return eintraege.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendeEintrag(item: QueueItem): Promise<void> {
|
||||||
|
await set(item.key, { ...item, status: "wird_uebertragen" as QueueStatus }, store);
|
||||||
|
try {
|
||||||
|
const ergebnis = await apiRequest<Kontrollposition>(
|
||||||
|
`/kontrollen/${item.kontrolleId}/positionen/${item.materialId}`,
|
||||||
|
{ method: "PUT", body: { istmenge: item.istmenge } }
|
||||||
|
);
|
||||||
|
// Erfolgreich übertragen (idempotent - ein erneutes Senden desselben Werts
|
||||||
|
// durch die Queue vor einem Absturz führt zu keinem doppelten Fehlbestand,
|
||||||
|
// siehe Backend app/services/kontrolle.py erfasse_position) -> aus Queue entfernen.
|
||||||
|
void ergebnis;
|
||||||
|
await del(item.key, store);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ApiError && error.status >= 400 && error.status < 500) {
|
||||||
|
// Client-Fehler (z. B. 403 Objekt-Sperre an anderen Benutzer übergegangen,
|
||||||
|
// 404 Kontrolle nicht mehr aktiv) - erneutes Senden würde nichts ändern,
|
||||||
|
// aber der Eintrag bleibt sichtbar in der Queue, bis der Mitarbeiter reagiert.
|
||||||
|
await set(
|
||||||
|
item.key,
|
||||||
|
{
|
||||||
|
...item,
|
||||||
|
status: "fehler" as QueueStatus,
|
||||||
|
fehlerText: `Server hat abgelehnt (${error.status}) - bitte Kontrolle neu prüfen.`,
|
||||||
|
},
|
||||||
|
store
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Netzwerkfehler - bleibt "wartet", nächster Sync-Versuch übernimmt wieder.
|
||||||
|
await set(item.key, { ...item, status: "wartet" as QueueStatus }, store);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wird bei Reconnect (online-Event) und periodisch aufgerufen. Sendet alle
|
||||||
|
* wartenden Einträge, bricht bei Netzwerkfehlern early ab (weitere Versuche
|
||||||
|
* würden ohnehin scheitern, solange keine Verbindung besteht). */
|
||||||
|
export async function synchronisiereQueue(): Promise<void> {
|
||||||
|
const eintraege = await alleEintraege();
|
||||||
|
for (const item of eintraege) {
|
||||||
|
if (item.status === "fehler") continue; // braucht bewusste Nutzer-Aktion, kein Auto-Retry
|
||||||
|
try {
|
||||||
|
await sendeEintrag(item);
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof ApiError)) {
|
||||||
|
return; // vermutlich offline - restliche Queue in Ruhe lassen, nicht durchhämmern
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function entferneFehlerEintrag(kontrolleId: string, materialId: number): Promise<void> {
|
||||||
|
await del(keyFor(kontrolleId, materialId), store);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<Objekt | null>(null);
|
||||||
|
const [kontrolle, setKontrolle] = useState<Kontrolle | null>(null);
|
||||||
|
const [sperre, setSperre] = useState<Sperrmeldung | null>(null);
|
||||||
|
const [positionen, setPositionen] = useState<PositionZustand[]>([]);
|
||||||
|
const [startFehler, setStartFehler] = useState<string | null>(null);
|
||||||
|
const [wirdAbgeschlossen, setWirdAbgeschlossen] = useState(false);
|
||||||
|
const [abschlussFehler, setAbschlussFehler] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!objektId) return;
|
||||||
|
apiRequest<Objekt>(`/objekte/${objektId}`).then(setObjekt).catch(() => undefined);
|
||||||
|
}, [objektId]);
|
||||||
|
|
||||||
|
async function starteKontrolle(uebernehmen: boolean) {
|
||||||
|
if (!objektId) return;
|
||||||
|
setStartFehler(null);
|
||||||
|
try {
|
||||||
|
const neueKontrolle = await apiRequest<Kontrolle>(`/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<Objektposition[]>(`/objekte/${kontrolle.objekt_id}/positionen`),
|
||||||
|
apiRequest<Material[]>("/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 (
|
||||||
|
<main style={{ maxWidth: 480, margin: "0 auto", padding: "1rem" }}>
|
||||||
|
<h1>Objekt gesperrt</h1>
|
||||||
|
<p>
|
||||||
|
Wird gerade kontrolliert von <strong>{sperre.benutzer ?? "unbekannt"}</strong>, seit {sperre.seit}.
|
||||||
|
</p>
|
||||||
|
<button onClick={() => starteKontrolle(true)} style={{ padding: "0.75rem 1.5rem" }}>
|
||||||
|
Trotzdem übernehmen
|
||||||
|
</button>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (startFehler) {
|
||||||
|
return (
|
||||||
|
<main style={{ maxWidth: 480, margin: "0 auto", padding: "1rem" }}>
|
||||||
|
<p style={{ color: "crimson" }}>{startFehler}</p>
|
||||||
|
<button onClick={() => starteKontrolle(false)}>Erneut versuchen</button>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main style={{ maxWidth: 480, margin: "0 auto", padding: "1rem" }}>
|
||||||
|
<h1>{objekt?.name ?? "Kontrolle"}</h1>
|
||||||
|
{!online && (
|
||||||
|
<p style={{ background: "#fff3cd", padding: "0.5rem", borderRadius: 4 }}>
|
||||||
|
Kein Netz - Eingaben werden lokal gespeichert und automatisch übertragen, sobald wieder Verbindung besteht.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<ul style={{ listStyle: "none", padding: 0 }}>
|
||||||
|
{positionen.map((zustand) => (
|
||||||
|
<li
|
||||||
|
key={zustand.position.id}
|
||||||
|
style={{
|
||||||
|
border: "1px solid #ccc",
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: "0.75rem",
|
||||||
|
marginBottom: "0.5rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between" }}>
|
||||||
|
<strong>{zustand.material?.name ?? `Material ${zustand.position.material_id}`}</strong>
|
||||||
|
<StatusBadge status={zustand.status} />
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: "0.85rem", color: "#666" }}>
|
||||||
|
Soll: {zustand.position.sollmenge_effektiv} {zustand.material?.einheit ?? ""}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", gap: "0.5rem", marginTop: "0.5rem" }}>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={zustand.eingabe}
|
||||||
|
onChange={(e) => eingabeAendern(zustand.position.material_id, e.target.value)}
|
||||||
|
style={{ width: "5rem", fontSize: "1.1rem", padding: "0.5rem" }}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => positionSenden(zustand.position.material_id, zustand.eingabe)}
|
||||||
|
style={{ flex: 1, fontSize: "1.1rem" }}
|
||||||
|
>
|
||||||
|
Bestätigen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{zustand.status === "fehler" && (
|
||||||
|
<div style={{ marginTop: "0.5rem" }}>
|
||||||
|
<p style={{ color: "crimson", margin: 0 }}>{zustand.fehlerText}</p>
|
||||||
|
<button onClick={() => fehlerErneutVersuchen(zustand.position.material_id, zustand.eingabe)}>
|
||||||
|
Erneut versuchen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{abschlussFehler && <p style={{ color: "crimson" }}>{abschlussFehler}</p>}
|
||||||
|
<button
|
||||||
|
onClick={abschliessen}
|
||||||
|
disabled={!alleUebertragen || wirdAbgeschlossen}
|
||||||
|
style={{ width: "100%", padding: "1rem", fontSize: "1.2rem" }}
|
||||||
|
>
|
||||||
|
{alleUebertragen ? "Kontrolle abschließen" : "Noch nicht alle Positionen gespeichert…"}
|
||||||
|
</button>
|
||||||
|
<button onClick={abbrechen} style={{ width: "100%", padding: "0.75rem", marginTop: "0.5rem" }}>
|
||||||
|
Kontrolle abbrechen
|
||||||
|
</button>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusBadge({ status }: { status: PositionStatus }) {
|
||||||
|
const konfiguration: Record<PositionStatus, { text: string; farbe: string }> = {
|
||||||
|
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 <span style={{ color: farbe, fontWeight: "bold" }}>{text}</span>;
|
||||||
|
}
|
||||||
@@ -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<string | null>(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 (
|
||||||
|
<main style={{ maxWidth: 320, margin: "4rem auto", padding: "0 1rem" }}>
|
||||||
|
<h1>MABEA</h1>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<label>
|
||||||
|
Login
|
||||||
|
<input
|
||||||
|
style={{ display: "block", width: "100%", fontSize: "1.2rem", padding: "0.5rem" }}
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
autoComplete="username"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label style={{ display: "block", marginTop: "1rem" }}>
|
||||||
|
Passwort
|
||||||
|
<input
|
||||||
|
style={{ display: "block", width: "100%", fontSize: "1.2rem", padding: "0.5rem" }}
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
autoComplete="current-password"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{fehler && <p style={{ color: "crimson" }}>{fehler}</p>}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={wirdGeladen}
|
||||||
|
style={{ marginTop: "1rem", width: "100%", fontSize: "1.2rem", padding: "0.75rem" }}
|
||||||
|
>
|
||||||
|
{wirdGeladen ? "Wird geprüft…" : "Anmelden"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<Objekt[]>([]);
|
||||||
|
const [suche, setSuche] = useState("");
|
||||||
|
const [ladefehler, setLadefehler] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
apiRequest<Objekt[]>("/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 (
|
||||||
|
<main style={{ maxWidth: 480, margin: "0 auto", padding: "1rem" }}>
|
||||||
|
<h1>Objekte</h1>
|
||||||
|
<input
|
||||||
|
placeholder="Suche oder Code scannen…"
|
||||||
|
value={suche}
|
||||||
|
onChange={(e) => setSuche(e.target.value)}
|
||||||
|
style={{ width: "100%", fontSize: "1.2rem", padding: "0.75rem", marginBottom: "1rem" }}
|
||||||
|
/>
|
||||||
|
{ladefehler && <p style={{ color: "crimson" }}>{ladefehler}</p>}
|
||||||
|
<ul style={{ listStyle: "none", padding: 0 }}>
|
||||||
|
{gefiltert.map((objekt) => (
|
||||||
|
<li key={objekt.id} style={{ marginBottom: "0.5rem" }}>
|
||||||
|
<Link
|
||||||
|
to={`/objekte/${objekt.id}/kontrolle`}
|
||||||
|
style={{
|
||||||
|
display: "block",
|
||||||
|
padding: "1rem",
|
||||||
|
border: "1px solid #ccc",
|
||||||
|
borderRadius: 8,
|
||||||
|
textDecoration: "none",
|
||||||
|
color: "inherit",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<strong>{objekt.name}</strong>
|
||||||
|
<div style={{ fontSize: "0.9rem", color: "#666" }}>{objekt.code}</div>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
Vendored
+9
@@ -0,0 +1,9 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly VITE_API_BASE_URL: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv;
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user