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 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
|
||||
---
|
||||
## 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