Sprint 7: React-PWA-Frontend (Offline-Härtung) + Flutter-Android-Grundgerüst
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:
2026-09-04 00:10:26 +02:00
co-authored by Claude Sonnet 5
parent aba991bbe2
commit 564e98e5c6
28 changed files with 1624 additions and 0 deletions
+56
View File
@@ -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 +++++++++++++++++++++++++++++++
---
+8
View File
@@ -0,0 +1,8 @@
.dart_tool/
.packages
build/
.flutter-plugins
.flutter-plugins-dependencies
android/
ios/
*.iml
+40
View File
@@ -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.
+1
View File
@@ -0,0 +1 @@
include: package:flutter_lints/flutter.yaml
+101
View File
@@ -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;
}
}
}
+45
View File
@@ -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));
}
}
+75
View File
@@ -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,
),
),
),
);
},
),
),
],
),
);
}
}
+21
View File
@@ -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
+5
View File
@@ -0,0 +1,5 @@
node_modules/
dist/
dev-dist/
.env
.env.local
+44
View File
@@ -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.
+1
View File
@@ -0,0 +1 @@
VITE_API_BASE_URL=http://192.168.1.238/api/v1
+13
View File
@@ -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>
+25
View File
@@ -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"
}
}
+42
View File
@@ -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>
);
}
+79
View File
@@ -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;
}
+49
View File
@@ -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;
}
+41
View File
@@ -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;
}
+13
View File
@@ -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>
);
+114
View File
@@ -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);
}
+37
View File
@@ -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;
}
+271
View File
@@ -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>;
}
+62
View File
@@ -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>
);
}
+59
View File
@@ -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>
);
}
+9
View File
@@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_BASE_URL: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
+19
View File
@@ -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"]
}
+36
View File
@@ -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,
},
});