diff --git a/app/lib/cargo_items_view.dart b/app/lib/cargo_items_view.dart index 3d47890..ceafb64 100644 --- a/app/lib/cargo_items_view.dart +++ b/app/lib/cargo_items_view.dart @@ -6,6 +6,7 @@ import 'l10n/app_localizations.dart'; import 'l10n/localization_helpers.dart'; import 'models/delivery_station.dart'; import 'models/job.dart'; +import 'models/job_progress.dart'; import 'services/database_service.dart'; import 'task_view.dart'; import 'widgets/offline_banner.dart'; @@ -13,8 +14,10 @@ import 'widgets/offline_banner.dart'; @visibleForTesting Color? deliveryStationCardBackgroundColor( DeliveryStation station, - Map taskStatuses, -) { + Map taskStatuses, { + bool isClosed = false, +}) { + if (isClosed) return AppColors.successSoft; if (station.tasks.isEmpty) { return null; } @@ -37,6 +40,7 @@ class CargoItemsView extends StatefulWidget { class _CargoItemsViewState extends State { final DatabaseService _databaseService = DatabaseService(); Map _taskStatuses = const {}; + Set _completedStations = const {}; @override void initState() { @@ -46,9 +50,13 @@ class _CargoItemsViewState extends State { Future _loadLocalTaskStatuses() async { final map = await _databaseService.loadAllTaskStatuses(); + final completedStations = await _databaseService.loadCompletedStationOrders( + widget.job, + ); if (!mounted) return; setState(() { _taskStatuses = map; + _completedStations = completedStations; }); } @@ -311,6 +319,11 @@ class _CargoItemsViewState extends State { final backgroundColor = deliveryStationCardBackgroundColor( station, _taskStatuses, + isClosed: JobProgress( + widget.job, + taskStatuses: _taskStatuses, + localCompletedStations: _completedStations, + ).isStationClosed(station.stationOrder), ); final title = station.displayName.isNotEmpty ? station.displayName : station.company; diff --git a/app/lib/jobs_view.dart b/app/lib/jobs_view.dart index d7e5b37..1730476 100644 --- a/app/lib/jobs_view.dart +++ b/app/lib/jobs_view.dart @@ -8,6 +8,7 @@ import 'services/dart_mq.dart'; import 'services/chat_service.dart'; import 'models/delivery_station.dart'; import 'models/job.dart'; +import 'models/job_progress.dart'; import 'models/task.dart'; import 'models/tasks/confirmation_task.dart'; import 'models/tasks/photo_task.dart'; @@ -23,6 +24,13 @@ import 'services/database_service.dart'; import 'navigation_observer.dart'; import 'routing_view.dart'; +@visibleForTesting +Color? jobCardBackgroundColor(JobProgress progress) { + if (progress.isCompleted) return AppColors.successSoft; + if (progress.hasProgress) return AppColors.warningSoft; + return null; +} + class JobsView extends StatefulWidget { const JobsView({super.key}); @@ -63,6 +71,7 @@ class _JobsViewState extends State with RouteAware { bool _isLoggingOut = false; final DatabaseService _databaseService = DatabaseService(); Map _taskStatuses = const {}; + Map> _completedStations = const {}; Map _jobSeen = const {}; Map _jobSwipeOffsets = const {}; String? _openJobId; @@ -479,9 +488,15 @@ class _JobsViewState extends State with RouteAware { Future _loadLocalTaskStatuses() async { final map = await _databaseService.loadAllTaskStatuses(); + final completedStations = >{}; + for (final job in _appState.assignedJobs) { + completedStations[job.id] = await _databaseService + .loadCompletedStationOrders(job); + } if (!mounted) return; setState(() { _taskStatuses = map; + _completedStations = completedStations; }); } @@ -950,25 +965,15 @@ class _JobsViewState extends State with RouteAware { statusColor = AppColors.textMuted; } - // Determine card background color based on task completion + final progress = JobProgress( + job, + taskStatuses: _taskStatuses, + localCompletedStations: _completedStations[job.id] ?? const {}, + ); final totalTasks = job.tasks.length; - int completedTasks = 0; - for (final t in job.tasks) { - final isCompleted = _taskStatuses[t.id] ?? t.completed; - if (isCompleted) completedTasks++; - } - - // Check if all tasks are completed (job is done) - final bool isJobCompleted = totalTasks > 0 && completedTasks == totalTasks; - - Color? cardBg; - if (totalTasks == 0 || completedTasks == 0) { - cardBg = null; // unchanged (default) - } else if (completedTasks > 0 && completedTasks < totalTasks) { - cardBg = AppColors.warningSoft; - } else if (completedTasks == totalTasks) { - cardBg = AppColors.successSoft; - } + final completedTasks = progress.completedTaskCount; + final isJobCompleted = progress.isCompleted; + final cardBg = jobCardBackgroundColor(progress); // Build robust display strings with fallbacks final pickupName = _joinNonEmpty([job.pickupFirstName, job.pickupLastName]); final pickupDisplayName = @@ -1211,7 +1216,17 @@ class _JobsViewState extends State with RouteAware { ), ], ), - // Progress bar for tasks + if (isJobCompleted) ...[ + const SizedBox(height: 8), + Text( + AppLocalizations.of(context).jobCompleted, + style: const TextStyle( + color: AppColors.success, + fontWeight: FontWeight.w600, + ), + ), + ], + // Progress bar for tasks (optional tasks retain their actual state). if (totalTasks > 0) ...[ const SizedBox(height: 8), Text( @@ -1235,7 +1250,8 @@ class _JobsViewState extends State with RouteAware { minHeight: 8, backgroundColor: AppColors.border, valueColor: AlwaysStoppedAnimation( - completedTasks >= totalTasks + isJobCompleted || + completedTasks >= totalTasks ? AppColors.success : (completedTasks > 0 ? AppColors.warning @@ -1447,6 +1463,14 @@ class _JobsViewState extends State with RouteAware { } void _toggleTaskCompletion(Job job, int taskIndex) { + final progress = JobProgress( + job, + taskStatuses: _taskStatuses, + localCompletedStations: _completedStations[job.id] ?? const {}, + ); + if (progress.isStationClosed(job.tasks[taskIndex].stationOrder ?? 0)) { + return; + } // Create a new task with toggled completion status final updatedTask = job.tasks[taskIndex].copyWith( completed: !job.tasks[taskIndex].completed, @@ -1488,6 +1512,7 @@ class _JobsViewState extends State with RouteAware { id: job.id, jobNumber: job.jobNumber, status: job.status, + completedStationOrders: job.completedStationOrders, createdAt: job.createdAt, updatedAt: job.updatedAt, createdBy: job.createdBy, @@ -1690,7 +1715,15 @@ class _JobsViewState extends State with RouteAware { const SizedBox(height: 8), ...job.tasks.asMap().entries.map( (entry) => GestureDetector( - onTap: () => _toggleTaskCompletion(job, entry.key), + onTap: + JobProgress( + job, + taskStatuses: _taskStatuses, + localCompletedStations: + _completedStations[job.id] ?? const {}, + ).isStationClosed(entry.value.stationOrder ?? 0) + ? null + : () => _toggleTaskCompletion(job, entry.key), child: Container( margin: const EdgeInsets.only(bottom: 4), padding: const EdgeInsets.symmetric( diff --git a/app/lib/models/delivery_station.dart b/app/lib/models/delivery_station.dart index 403cdd5..7e6b4f2 100644 --- a/app/lib/models/delivery_station.dart +++ b/app/lib/models/delivery_station.dart @@ -52,7 +52,7 @@ class DeliveryStation { final tasks = (json['tasks'] as List? ?? const []).map((rawTask) { final taskJson = Map.from(rawTask as Map); - taskJson['stationOrder'] ??= stationOrder; + taskJson['stationOrder'] = stationOrder; return Task.fromJson(taskJson); }).toList() ..sort((a, b) => (a.taskOrder ?? 0).compareTo(b.taskOrder ?? 0)); diff --git a/app/lib/models/job.dart b/app/lib/models/job.dart index cf22702..0682069 100644 --- a/app/lib/models/job.dart +++ b/app/lib/models/job.dart @@ -6,6 +6,7 @@ class Job { final String id; // Will store the timestamp as string final String jobNumber; final String status; + final List completedStationOrders; final DateTime createdAt; final DateTime updatedAt; final String createdBy; @@ -61,6 +62,7 @@ class Job { required this.id, required this.jobNumber, required this.status, + this.completedStationOrders = const [], required this.createdAt, required this.updatedAt, required this.createdBy, @@ -332,6 +334,12 @@ class Job { id: idValue, jobNumber: jobJson['jobNumber']?.toString() ?? '', status: jobJson['status']?.toString() ?? 'UNKNOWN', + completedStationOrders: + (jobJson['completedStationOrders'] as List? ?? const []) + .map((value) => int.tryParse(value.toString())) + .whereType() + .toSet() + .toList(), createdAt: _parseDateTime(jobJson['createdAt']) ?? DateTime.now(), updatedAt: _parseDateTime(jobJson['updatedAt']) ?? DateTime.now(), createdBy: jobJson['createdBy']?.toString() ?? '', @@ -391,6 +399,7 @@ class Job { id: id, jobNumber: t(jobNumber), status: t(status), + completedStationOrders: List.of(completedStationOrders), createdAt: createdAt, updatedAt: updatedAt, createdBy: t(createdBy), @@ -446,6 +455,7 @@ class Job { 'id': id, 'jobNumber': jobNumber, 'status': status, + 'completedStationOrders': completedStationOrders, 'createdAt': createdAt.toIso8601String(), 'updatedAt': updatedAt.toIso8601String(), 'createdBy': createdBy, diff --git a/app/lib/models/job_progress.dart b/app/lib/models/job_progress.dart new file mode 100644 index 0000000..6c56a80 --- /dev/null +++ b/app/lib/models/job_progress.dart @@ -0,0 +1,52 @@ +import 'job.dart'; +import 'task.dart'; + +/// Combines server state with work confirmed locally while offline. +class JobProgress { + final Job job; + final Map taskStatuses; + final Set completedStationOrders; + + JobProgress( + this.job, { + this.taskStatuses = const {}, + Set localCompletedStations = const {}, + }) : completedStationOrders = { + ...job.completedStationOrders, + ...localCompletedStations, + }; + + bool isTaskCompleted(Task task) => + task.completed || taskStatuses[task.id] == true; + + bool get isCancelled => job.status.toUpperCase() == 'CANCELLED'; + + bool get isCompleted { + if (isCancelled) return false; + if (const {'COMPLETED', 'DONE'}.contains(job.status.toUpperCase())) { + return true; + } + // Same rule as the backend: explicit final-station confirmation and all + // mandatory tasks completed. Open optional tasks do not prevent completion. + final lastStationOrder = + job.deliveryStations.isEmpty + ? 0 + : job.deliveryStations + .map((station) => station.stationOrder) + .reduce((a, b) => a > b ? a : b); + return completedStationOrders.contains(lastStationOrder) && + job.tasks.where((task) => !task.optional).every(isTaskCompleted); + } + + bool isStationClosed(int stationOrder) => + isCancelled || + isCompleted || + completedStationOrders.contains(stationOrder); + + bool canCompleteTask(Task task) => + !isStationClosed(task.stationOrder ?? 0) && !isTaskCompleted(task); + + int get completedTaskCount => job.tasks.where(isTaskCompleted).length; + bool get hasProgress => + completedTaskCount > 0 || completedStationOrders.isNotEmpty; +} diff --git a/app/lib/services/database_service.dart b/app/lib/services/database_service.dart index 7388c28..e076347 100644 --- a/app/lib/services/database_service.dart +++ b/app/lib/services/database_service.dart @@ -368,6 +368,23 @@ class DatabaseService { } } + /// Merge server confirmations and locally persisted offline confirmations. + Future> loadCompletedStationOrders(Job job) async { + final orders = + job.deliveryStations.isEmpty + ? {0} + : job.deliveryStations + .map((station) => station.stationOrder) + .toSet(); + final completed = {...job.completedStationOrders}; + for (final order in orders) { + if (await loadKeyValue('station_completed:${job.id}:$order') == '1') { + completed.add(order); + } + } + return completed; + } + /// Load all task completion statuses Future> loadAllTaskStatuses() async { try { diff --git a/app/lib/task_view.dart b/app/lib/task_view.dart index e55e709..c7d088b 100644 --- a/app/lib/task_view.dart +++ b/app/lib/task_view.dart @@ -6,9 +6,11 @@ import 'package:image/image.dart' as img; import 'package:flutter/material.dart'; import 'package:flutter/foundation.dart'; import 'app_theme.dart'; +import 'app_state.dart'; import 'l10n/app_localizations.dart'; import 'l10n/localization_helpers.dart'; import 'models/job.dart'; +import 'models/job_progress.dart'; import 'models/task.dart'; import 'models/tasks/confirmation_task.dart'; import 'models/tasks/photo_task.dart'; @@ -42,6 +44,9 @@ class TaskView extends StatefulWidget { class _TaskViewState extends State { final Set _completedTasks = {}; final DatabaseService _databaseService = DatabaseService(); + late Job _job; + Set _localCompletedStations = {}; + StreamSubscription? _jobsUpdatedSubscription; bool _statusesLoaded = false; bool _stationCompleted = false; bool _isCompletingStation = false; @@ -51,31 +56,73 @@ class _TaskViewState extends State { @override void initState() { super.initState(); + _job = widget.job; + _jobsUpdatedSubscription = AppState().jobsUpdated.listen( + (_) => _loadTaskStatuses(), + ); _loadTaskStatuses(); } + @override + void didUpdateWidget(covariant TaskView oldWidget) { + super.didUpdateWidget(oldWidget); + if (!identical(oldWidget.job, widget.job) || + oldWidget.stationOrder != widget.stationOrder) { + _job = widget.job; + _statusesLoaded = false; + _loadTaskStatuses(); + } + } + + @override + void dispose() { + _jobsUpdatedSubscription?.cancel(); + super.dispose(); + } + + JobProgress get _progress => JobProgress( + _job, + taskStatuses: {for (final id in _completedTasks) id: true}, + localCompletedStations: _localCompletedStations, + ); + + bool _canCompleteTask(Task task) => + mounted && + _statusesLoaded && + !_isCompletingStation && + !_stationCompleted && + _progress.canCompleteTask(task); + List get _visibleTasks { final stationOrder = widget.stationOrder; if (stationOrder == null) { - return widget.job.tasks; + return _job.tasks; } - return widget.job.tasks + return _job.tasks .where((task) => task.stationOrder == stationOrder) .toList(); } String get _stationCompletionKey => - 'station_completed:${widget.job.id}:${widget.stationOrder ?? 0}'; + 'station_completed:${_job.id}:${widget.stationOrder ?? 0}'; /// Load task completion and skipped statuses from database and merge with JSON task states Future _loadTaskStatuses() async { + var latestJob = _job; + for (final savedJob in await _databaseService.loadJobs()) { + if (savedJob.id == _job.id && + !savedJob.updatedAt.isBefore(latestJob.updatedAt)) { + latestJob = savedJob; + } + } final statuses = await _databaseService.loadAllTaskStatuses(); - final stationCompleted = - await _databaseService.loadKeyValue(_stationCompletionKey) == '1'; + final completedStations = await _databaseService.loadCompletedStationOrders( + latestJob, + ); if (!mounted) return; setState(() { - _stationCompleted = - stationCompleted || widget.job.status.toUpperCase() == 'COMPLETED'; + _job = latestJob; + _localCompletedStations = completedStations; _statusesLoaded = true; _completedTasks.clear(); // 1) Add all completed from DB @@ -85,11 +132,12 @@ class _TaskViewState extends State { } } // 2) Merge: also mark tasks completed if the job JSON already had them completed - for (final t in widget.job.tasks) { + for (final t in _job.tasks) { if (t.completed) { _completedTasks.add(t.id); } } + _stationCompleted = _progress.isStationClosed(widget.stationOrder ?? 0); }); } @@ -100,7 +148,7 @@ class _TaskViewState extends State { title: Text( widget.stationTitle?.isNotEmpty == true ? '${AppLocalizations.of(context).tasks} - ${widget.stationTitle}' - : '${AppLocalizations.of(context).tasks} - ${widget.job.jobNumber}', + : '${AppLocalizations.of(context).tasks} - ${_job.jobNumber}', ), leading: IconButton( icon: const Icon(Icons.arrow_back), @@ -189,7 +237,9 @@ class _TaskViewState extends State { child: ElevatedButton.icon( onPressed: enabled ? _onCompleteStationPressed : null, icon: const Icon(Icons.flag), - label: const Text('Station abschließen'), + label: Text( + _stationCompleted ? 'Station abgeschlossen' : 'Station abschließen', + ), style: ElevatedButton.styleFrom( padding: const EdgeInsets.symmetric(vertical: 14), ), @@ -235,12 +285,13 @@ class _TaskViewState extends State { final stationOrder = widget.stationOrder ?? 0; try { await StompService().sendStationCompleted( - jobId: widget.job.id, - jobNumber: widget.job.jobNumber, + jobId: _job.id, + jobNumber: _job.jobNumber, stationOrder: stationOrder, hasIncompleteOptionalTasks: _hasIncompleteOptionalTasks, ); _stationCompleted = true; + _localCompletedStations.add(stationOrder); await _databaseService.saveKeyValue(_stationCompletionKey, '1'); if (!mounted) return; ScaffoldMessenger.of( @@ -289,7 +340,7 @@ class _TaskViewState extends State { final task = _visibleTasks[index]; final isCompleted = _completedTasks.contains(task.id); final canBeCompletedNow = - !isCompleted && _arePreviousTasksCompleted(index); + _canCompleteTask(task) && _arePreviousTasksCompleted(index); // Hintergrundfarbe je nach Status: // abgeschlossen → hellgrün, bearbeitbar → weiß, gesperrt → hellgrau @@ -429,6 +480,7 @@ class _TaskViewState extends State { } void _showTaskCompletionDialog(Task task, int taskIndex) { + if (!_canCompleteTask(task)) return; switch (task) { case ConfirmationTask(): _showConfirmationDialog(task, taskIndex); @@ -542,10 +594,12 @@ class _TaskViewState extends State { (context) => PhotoCaptureScreen( task: task, onPhotosCompleted: (List photoData) async { + if (!_canCompleteTask(task)) return; // Compress + encode photos for network send (limit payload) final List base64List = await _compressAndEncodePhotos( photoData, ); + if (!_canCompleteTask(task)) return; final bool truncated = base64List.length < photoData.length; // Try to persist full-quality (encoded) photos to DB for offline/backup @@ -647,6 +701,7 @@ class _TaskViewState extends State { (context) => SignatureCaptureScreen( task: task, onSignatureCompleted: (String svg, String note) async { + if (!_canCompleteTask(task)) return; try { // Persist SVG only (no PNG) await _databaseService.saveTaskSignature(task.id, svg); @@ -658,6 +713,7 @@ class _TaskViewState extends State { ); developer.log('Stack trace: $stackTrace', name: 'TaskView'); } + if (!_canCompleteTask(task)) return; // Store SVG for later use in this TaskView session setState(() { _signatureSvgByTask[task.id] = svg; @@ -690,6 +746,7 @@ class _TaskViewState extends State { (context) => BarcodeCaptureScreen( task: task, onBarcodesCompleted: (List barcodes) async { + if (!_canCompleteTask(task)) return; try { // Save barcodes to database for later use await _databaseService.saveTaskBarcodes(task.id, barcodes); @@ -758,6 +815,8 @@ class _TaskViewState extends State { String? taskType, Map? extraData, }) { + final tasks = _visibleTasks.where((task) => task.id == taskId); + if (tasks.isEmpty || !_canCompleteTask(tasks.first)) return; final bool hadOpenMandatoryBefore = _hasOpenMandatoryTasks; setState(() { _completedTasks.add(taskId); @@ -1000,7 +1059,7 @@ class _TaskViewState extends State { } } - String _getRemark() => widget.job.remark; + String _getRemark() => _job.remark; String? _getTaskStationLabel(Task task) { if (widget.stationOrder != null) { @@ -1011,7 +1070,7 @@ class _TaskViewState extends State { return null; } - for (final station in widget.job.deliveryStations) { + for (final station in _job.deliveryStations) { if (station.stationOrder == stationOrder) { final suffix = station.displayName.isNotEmpty ? station.displayName : station.city; diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 8308efc..26d64e4 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 0.9.24+924 +version: 0.9.25+925 environment: sdk: ^3.7.0 diff --git a/app/test/models/job_progress_test.dart b/app/test/models/job_progress_test.dart new file mode 100644 index 0000000..f315e78 --- /dev/null +++ b/app/test/models/job_progress_test.dart @@ -0,0 +1,160 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:votianlt_app/models/job.dart'; +import 'package:votianlt_app/models/job_progress.dart'; + +Map task( + String id, + int order, { + bool completed = false, + bool optional = false, +}) => { + 'id': id, + 'jobId': 'job-1', + 'stationOrder': order, + 'completed': completed, + 'optional': optional, + 'taskSpecificData': {'taskType': 'CONFIRMATION', 'buttonText': 'Bestätigen'}, +}; + +Job exampleJob({ + String status = 'IN_PROGRESS', + List completedStations = const [], + bool mandatoryDone = true, +}) => Job.fromJson({ + 'id': 'job-1', + 'status': status, + 'completedStationOrders': completedStations, + 'deliveryStations': [ + { + 'stationOrder': 0, + 'tasks': [ + task('required', 0, completed: mandatoryDone), + task('optional', 0, optional: true), + ], + }, + { + 'stationOrder': 2, + 'tasks': [task('final', 2, completed: true)], + }, + ], +}); + +void main() { + test( + 'closed intermediate station locks optional tasks but leaves the next station open', + () { + final job = exampleJob(completedStations: [0]); + final progress = JobProgress(job); + expect(progress.isCompleted, isFalse); + expect(progress.canCompleteTask(job.tasks[1]), isFalse); + expect(progress.isStationClosed(0), isTrue); + expect(progress.isStationClosed(2), isFalse); + }, + ); + + test( + 'server completion is authoritative despite unfinished optional tasks', + () { + final job = exampleJob(status: 'COMPLETED'); + final progress = JobProgress(job); + expect(progress.isCompleted, isTrue); + expect(progress.completedTaskCount, 2); + expect(progress.canCompleteTask(job.tasks[1]), isFalse); + expect(job.tasks[1].completed, isFalse); + }, + ); + + test( + 'all mandatory tasks alone do not complete the job without station confirmation', + () { + expect(JobProgress(exampleJob()).isCompleted, isFalse); + }, + ); + + test( + 'final station confirmation completes the job even with optional tasks open', + () { + expect( + JobProgress(exampleJob(completedStations: [0, 2])).isCompleted, + isTrue, + ); + }, + ); + + test( + 'offline final station confirmation uses locally completed mandatory tasks', + () { + final progress = JobProgress( + exampleJob(mandatoryDone: false), + localCompletedStations: {0, 2}, + taskStatuses: {'required': true}, + ); + expect(progress.isCompleted, isTrue); + expect(progress.completedTaskCount, 2); + }, + ); + + test( + 'final confirmation cannot bypass unfinished mandatory work at an earlier station', + () { + expect( + JobProgress( + exampleJob(mandatoryDone: false), + localCompletedStations: {2}, + ).isCompleted, + isFalse, + ); + }, + ); + + test( + 'completed station confirmations survive normalization and JSON storage', + () { + final saved = + exampleJob( + completedStations: [0, '2', 2, 'invalid'], + ).normalized().toJson(); + final restored = Job.fromJson(saved); + expect(restored.completedStationOrders, [0, 2]); + expect(JobProgress(restored).isStationClosed(0), isTrue); + }, + ); + + test( + 'legacy job with no stations completes only after explicit station zero confirmation', + () { + final job = Job.fromJson({ + 'id': 'legacy', + 'status': 'IN_PROGRESS', + 'tasks': [task('optional', 0, optional: true)], + }); + expect(JobProgress(job).isCompleted, isFalse); + expect(JobProgress(job, localCompletedStations: {0}).isCompleted, isTrue); + }, + ); + + test('cancelled jobs remain locked and are not reported as completed', () { + final progress = JobProgress( + exampleJob(status: 'CANCELLED'), + localCompletedStations: {0, 2}, + ); + expect(progress.isCompleted, isFalse); + expect(progress.isStationClosed(2), isTrue); + }); + + test('embedded station order takes precedence over a stale task order', () { + final job = Job.fromJson({ + 'id': 'job-1', + 'completedStationOrders': [0], + 'deliveryStations': [ + { + 'stationOrder': 0, + 'tasks': [task('optional', 8, optional: true)], + }, + {'stationOrder': 1, 'tasks': []}, + ], + }); + expect(job.tasks.single.stationOrder, 0); + expect(JobProgress(job).canCompleteTask(job.tasks.single), isFalse); + }); +} diff --git a/app/test/views/completed_station_test.dart b/app/test/views/completed_station_test.dart new file mode 100644 index 0000000..d7a61f7 --- /dev/null +++ b/app/test/views/completed_station_test.dart @@ -0,0 +1,145 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:votianlt_app/app_theme.dart'; +import 'package:votianlt_app/cargo_items_view.dart'; +import 'package:votianlt_app/jobs_view.dart'; +import 'package:votianlt_app/models/job.dart'; +import 'package:votianlt_app/models/job_progress.dart'; +import 'package:votianlt_app/task_view.dart'; + +Job jobWithOptionalTask({ + bool closedStation = true, + String status = 'IN_PROGRESS', +}) => Job.fromJson({ + 'id': 'job-1', + 'jobNumber': 'TEST-1', + 'status': status, + 'completedStationOrders': closedStation ? [0] : [], + 'deliveryStations': [ + { + 'stationOrder': 0, + 'tasks': [ + { + 'id': 'optional', + 'jobId': 'job-1', + 'optional': true, + 'completed': false, + 'description': 'Optionale Bestätigung', + 'taskOrder': 0, + 'taskSpecificData': { + 'taskType': 'CONFIRMATION', + 'buttonText': 'Jetzt bestätigen', + }, + }, + ], + }, + {'stationOrder': 1, 'tasks': []}, + ], +}); + +void main() { + for (final status in ['IN_PROGRESS', 'COMPLETED']) { + testWidgets( + 'optional task cannot open a dialog after station closure ($status)', + (tester) async { + final job = jobWithOptionalTask(status: status); + await tester.pumpWidget( + MaterialApp(home: TaskView(job: job, stationOrder: 0)), + ); + await tester.pump(); + final task = find.text('Optionale Bestätigung'); + expect(task, findsOneWidget); + final tile = tester.widget( + find.ancestor(of: task, matching: find.byType(InkWell)).first, + ); + expect(tile.onTap, isNull); + await tester.tap(task); + await tester.pump(); + expect(find.byType(AlertDialog), findsNothing); + expect(find.text('Station abgeschlossen'), findsOneWidget); + await tester.pumpWidget(const SizedBox.shrink()); + }, + ); + } + + testWidgets('optional task remains executable at an open station', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: TaskView( + job: jobWithOptionalTask(closedStation: false), + stationOrder: 0, + ), + ), + ); + await tester.pump(); + await tester.tap(find.text('Optionale Bestätigung')); + await tester.pump(); + expect(find.byType(AlertDialog), findsOneWidget); + expect(find.text('Jetzt bestätigen'), findsOneWidget); + await tester.pumpWidget(const SizedBox.shrink()); + }); + + testWidgets( + 'an already open dialog cannot complete a task after the job closes', + (tester) async { + Widget view(Job job) => MaterialApp( + home: TaskView( + key: const ValueKey('task-view'), + job: job, + stationOrder: 0, + ), + ); + await tester.pumpWidget(view(jobWithOptionalTask(closedStation: false))); + await tester.pump(); + await tester.tap(find.text('Optionale Bestätigung')); + await tester.pump(); + expect(find.byType(AlertDialog), findsOneWidget); + + await tester.pumpWidget(view(jobWithOptionalTask(status: 'COMPLETED'))); + await tester.pump(); + await tester.tap(find.text('Jetzt bestätigen')); + await tester.pumpAndSettle(); + + expect(find.byType(AlertDialog), findsNothing); + final taskCard = tester.widget( + find + .ancestor( + of: find.text('Optionale Bestätigung'), + matching: find.byType(Card), + ) + .first, + ); + expect( + taskCard.elevation, + 0, + ); // still open and locked, never marked completed + expect(find.text('Station abgeschlossen'), findsOneWidget); + await tester.pumpWidget(const SizedBox.shrink()); + }, + ); + + test('completed job card is green with optional tasks still open', () { + final job = jobWithOptionalTask(status: 'COMPLETED'); + expect(jobCardBackgroundColor(JobProgress(job)), AppColors.successSoft); + expect(job.tasks.single.completed, isFalse); + }); + + test('offline final station completion makes the job card green', () { + final job = jobWithOptionalTask(); + expect( + jobCardBackgroundColor(JobProgress(job, localCompletedStations: {1})), + AppColors.successSoft, + ); + expect(jobCardBackgroundColor(JobProgress(job)), AppColors.warningSoft); + expect( + deliveryStationCardBackgroundColor( + job.deliveryStations.first, + {}, + isClosed: true, + ), + AppColors.successSoft, + ); + }); +} diff --git a/backend/pom.xml b/backend/pom.xml index 00cc5db..c9737b7 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -11,7 +11,7 @@ jar - 0.9.25 + 0.9.26 21 21 21 diff --git a/backend/src/main/bundles/prod.bundle b/backend/src/main/bundles/prod.bundle index f16e5fd..e326ab6 100644 Binary files a/backend/src/main/bundles/prod.bundle and b/backend/src/main/bundles/prod.bundle differ diff --git a/backend/src/main/java/de/assecutor/votianlt/controller/MessageController.java b/backend/src/main/java/de/assecutor/votianlt/controller/MessageController.java index 53c9a91..2c2635d 100644 --- a/backend/src/main/java/de/assecutor/votianlt/controller/MessageController.java +++ b/backend/src/main/java/de/assecutor/votianlt/controller/MessageController.java @@ -191,6 +191,17 @@ public class MessageController { } public void handleTaskCompleted(Map payload, String taskType, String appUserId) { + Object taskId = payload != null ? payload.get("taskId") : null; + if (taskId == null || !ObjectId.isValid(taskId.toString())) { + return; + } + Optional task = taskRepository.findById(new ObjectId(taskId.toString())); + if (task.isEmpty() || taskAssignmentService.findJobForTask(task.get()) + .filter(job -> canCompleteTask(task.get(), job)).isEmpty()) { + // Check before dispatch: photo/signature/barcode/comment handlers also + // persist attachments before completing the task itself. + return; + } String key = taskType == null ? "" : taskType.trim().toUpperCase(); switch (key) { @@ -378,15 +389,14 @@ public class MessageController { return; } BaseTask task = opt.get(); + Optional jobOpt = taskAssignmentService.findJobForTask(task); + if (jobOpt.isEmpty() || !canCompleteTask(task, jobOpt.get())) { + return; + } task.setCompletedBy(resolveTaskActor(task, appUserId)); task.setCompleted(true); task.setCompletedAt(LocalDateTime.now()); taskRepository.save(task); - Optional jobOpt = taskAssignmentService.findJobForTask(task); - if (jobOpt.isEmpty()) { - log.warn("[TASK] Could not resolve job for task {}", taskIdStr); - return; - } ObjectId jobId = jobOpt.get().getId(); // Log detailed task completion in job history @@ -421,6 +431,19 @@ public class MessageController { } } + private boolean canCompleteTask(BaseTask task, Job job) { + if (task.isCompleted() || job.getStatus() == JobStatus.COMPLETED || job.getStatus() == JobStatus.CANCELLED) { + return false; + } + int stationOrder = task.getStationOrder() != null ? task.getStationOrder() : 0; + if (task.getStationId() != null && job.getDeliveryStations() != null) { + stationOrder = job.getDeliveryStations().stream().filter(Objects::nonNull) + .filter(station -> task.getStationId().equals(station.getStationId())) + .mapToInt(DeliveryStation::getStationOrder).findFirst().orElse(stationOrder); + } + return job.getCompletedStationOrders() == null || !job.getCompletedStationOrders().contains(stationOrder); + } + private String resolveTaskActor(BaseTask task, String appUserId) { if (appUserId != null && !appUserId.isBlank()) { return appUserId; diff --git a/backend/src/main/java/de/assecutor/votianlt/pages/view/AddCustomerView.java b/backend/src/main/java/de/assecutor/votianlt/pages/view/AddCustomerView.java index 10d521c..e8f3f60 100644 --- a/backend/src/main/java/de/assecutor/votianlt/pages/view/AddCustomerView.java +++ b/backend/src/main/java/de/assecutor/votianlt/pages/view/AddCustomerView.java @@ -52,15 +52,12 @@ public class AddCustomerView extends Main implements HasDynamicTitle { companyName = new TextField(getTranslation("profile.company")); companyName.setWidthFull(); - // Steuernummer (Pflichtfeld) + // Steuernummer (optional) taxNumber = new TextField(getTranslation("profile.billing.taxnumber")); - taxNumber.setRequiredIndicatorVisible(true); taxNumber.setWidthFull(); - taxNumber.addBlurListener(e -> validateField(taxNumber)); - // USt-IdNr. (Pflichtfeld) + // USt-IdNr. (optional) ustId = new TextField(getTranslation("profile.billing.ustid")); - ustId.setRequiredIndicatorVisible(true); ustId.setPlaceholder("DE123456789"); ustId.setWidthFull(); ustId.addBlurListener(e -> validateUstId()); @@ -181,11 +178,11 @@ public class AddCustomerView extends Main implements HasDynamicTitle { private void configureBinder() { binder.forField(companyName).bind(Customer::getCompanyName, Customer::setCompanyName); - binder.forField(taxNumber).asRequired(getTranslation("profile.validation.taxnumber.required")) - .bind(Customer::getTaxNumber, Customer::setTaxNumber); + binder.forField(taxNumber).bind(Customer::getTaxNumber, Customer::setTaxNumber); - binder.forField(ustId).asRequired(getTranslation("profile.validation.ustid.required")) - .withValidator(de.assecutor.votianlt.util.VatIdUtil::isValid, + binder.forField(ustId) + .withValidator(value -> value == null || value.isBlank() + || de.assecutor.votianlt.util.VatIdUtil.isValid(value), getTranslation("profile.validation.ustid.invalid")) .bind(Customer::getUstId, Customer::setUstId); @@ -265,15 +262,12 @@ public class AddCustomerView extends Main implements HasDynamicTitle { } /** - * Prüft die USt-IdNr. auf Pflichtangabe und gültiges Format + * Prüft eine angegebene USt-IdNr. auf gültiges Format * (Länderkennzeichen + länderspezifischer Aufbau, z. B. DE123456789). */ private void validateUstId() { String value = ustId.getValue(); - if (value == null || value.trim().isEmpty()) { - ustId.setInvalid(true); - ustId.setErrorMessage(getTranslation("profile.validation.ustid.required")); - } else if (!de.assecutor.votianlt.util.VatIdUtil.isValid(value)) { + if (value != null && !value.isBlank() && !de.assecutor.votianlt.util.VatIdUtil.isValid(value)) { ustId.setInvalid(true); ustId.setErrorMessage(getTranslation("profile.validation.ustid.invalid")); } else { @@ -305,7 +299,6 @@ public class AddCustomerView extends Main implements HasDynamicTitle { } private boolean validateAllFields() { - validateField(taxNumber); validateUstId(); validateField(firstName); validateField(lastName); @@ -316,7 +309,7 @@ public class AddCustomerView extends Main implements HasDynamicTitle { validateField(city); validateEmail(); - return !taxNumber.isInvalid() && !ustId.isInvalid() && !firstName.isInvalid() && !lastName.isInvalid() + return !ustId.isInvalid() && !firstName.isInvalid() && !lastName.isInvalid() && !telephone.isInvalid() && !mail.isInvalid() && !street.isInvalid() && !houseNumber.isInvalid() && !zip.isInvalid() && !city.isInvalid(); } diff --git a/backend/src/main/java/de/assecutor/votianlt/pages/view/EditCustomerView.java b/backend/src/main/java/de/assecutor/votianlt/pages/view/EditCustomerView.java index 16e4320..d8bcfbe 100644 --- a/backend/src/main/java/de/assecutor/votianlt/pages/view/EditCustomerView.java +++ b/backend/src/main/java/de/assecutor/votianlt/pages/view/EditCustomerView.java @@ -2,6 +2,7 @@ package de.assecutor.votianlt.pages.view; import com.vaadin.flow.component.button.Button; import com.vaadin.flow.component.button.ButtonVariant; +import com.vaadin.flow.component.combobox.ComboBox; import com.vaadin.flow.component.dialog.Dialog; import com.vaadin.flow.component.formlayout.FormLayout; import com.vaadin.flow.component.html.H2; @@ -34,7 +35,7 @@ public class EditCustomerView extends VerticalLayout implements HasUrlParameter< private final Binder binder = new Binder<>(Customer.class); // Form fields - labels set in constructor via setLabel() - private final TextField titleField = new TextField(); + private final ComboBox titleField = new ComboBox<>(); private final TextField companyNameField = new TextField(); private final TextField taxNumberField = new TextField(); private final TextField ustIdField = new TextField(); @@ -62,11 +63,12 @@ public class EditCustomerView extends VerticalLayout implements HasUrlParameter< // Set field labels via i18n titleField.setLabel(getTranslation("addjob.address.salutation")); + titleField.setItems(getTranslation("addjob.salutation.mr"), getTranslation("addjob.salutation.ms"), + getTranslation("addjob.salutation.other")); + titleField.setPlaceholder(getTranslation("addjob.address.salutation.placeholder")); companyNameField.setLabel(getTranslation("profile.company")); taxNumberField.setLabel(getTranslation("profile.billing.taxnumber")); - taxNumberField.setRequiredIndicatorVisible(true); ustIdField.setLabel(getTranslation("profile.billing.ustid")); - ustIdField.setRequiredIndicatorVisible(true); ustIdField.setPlaceholder("DE123456789"); firstnameField.setLabel(getTranslation("profile.firstname")); lastNameField.setLabel(getTranslation("profile.lastname")); @@ -143,10 +145,10 @@ public class EditCustomerView extends VerticalLayout implements HasUrlParameter< private void setupBinder() { binder.forField(titleField).bind(Customer::getTitle, Customer::setTitle); binder.forField(companyNameField).bind(Customer::getCompanyName, Customer::setCompanyName); - binder.forField(taxNumberField).asRequired(getTranslation("profile.validation.taxnumber.required")) - .bind(Customer::getTaxNumber, Customer::setTaxNumber); - binder.forField(ustIdField).asRequired(getTranslation("profile.validation.ustid.required")) - .withValidator(de.assecutor.votianlt.util.VatIdUtil::isValid, + binder.forField(taxNumberField).bind(Customer::getTaxNumber, Customer::setTaxNumber); + binder.forField(ustIdField) + .withValidator(value -> value == null || value.isBlank() + || de.assecutor.votianlt.util.VatIdUtil.isValid(value), getTranslation("profile.validation.ustid.invalid")) .bind(Customer::getUstId, Customer::setUstId); binder.forField(firstnameField).bind(Customer::getFirstname, Customer::setFirstname); diff --git a/backend/src/test/java/de/assecutor/votianlt/controller/TaskCompletionHistoryTest.java b/backend/src/test/java/de/assecutor/votianlt/controller/TaskCompletionHistoryTest.java index 0939248..8e72c25 100644 --- a/backend/src/test/java/de/assecutor/votianlt/controller/TaskCompletionHistoryTest.java +++ b/backend/src/test/java/de/assecutor/votianlt/controller/TaskCompletionHistoryTest.java @@ -5,9 +5,14 @@ import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.when; import de.assecutor.votianlt.model.Job; +import de.assecutor.votianlt.model.JobStatus; +import de.assecutor.votianlt.model.DeliveryStation; import de.assecutor.votianlt.model.task.BaseTask; import de.assecutor.votianlt.model.task.BarcodeTask; import de.assecutor.votianlt.model.task.CommentTask; @@ -73,6 +78,7 @@ class TaskCompletionHistoryTest { task.setCompletedBy("Unknown"); Job job = new Job(); job.setId(new ObjectId()); + job.setCompletedStationOrders(List.of(1)); when(taskRepository.findById(task.getId())).thenReturn(Optional.of(task)); when(taskAssignmentService.findJobForTask(task)).thenReturn(Optional.of(job)); Map extra = Map.of("photos", List.of("image-data"), "barcodes", List.of("CODE-1"), @@ -100,4 +106,60 @@ class TaskCompletionHistoryTest { verify(commentRepository).save(argThat(comment -> actor.equals(comment.getCompletedBy()))); } } + + @ParameterizedTest + @MethodSource("tasks") + void optionalTasksOfClosedStationCannotPersistDataOrSendEmail(BaseTask task) { + task.setId(new ObjectId()); + task.setOptional(true); + task.setStationOrder(0); + Job job = new Job(); + job.setCompletedStationOrders(List.of(0)); + rejectCompletion(task, job); + } + + @ParameterizedTest + @MethodSource("tasks") + void completedJobRejectsTasksEvenWithoutStationConfirmation(BaseTask task) { + task.setId(new ObjectId()); + task.setOptional(true); + Job job = new Job(); + job.setStatus(JobStatus.COMPLETED); + rejectCompletion(task, job); + } + + @ParameterizedTest + @MethodSource("tasks") + void repeatedCompletionCannotAddAttachmentsOrSendAnotherEmail(BaseTask task) { + task.setId(new ObjectId()); + task.setCompleted(true); + rejectCompletion(task, new Job()); + } + + @ParameterizedTest + @MethodSource("tasks") + void closedStationIsResolvedByIdEvenWhenTaskOrderIsStale(BaseTask task) { + task.setId(new ObjectId()); + task.setStationId(new ObjectId()); + task.setStationOrder(7); + DeliveryStation station = new DeliveryStation(); + station.setStationId(task.getStationId()); + station.setStationOrder(0); + Job job = new Job(); + job.setDeliveryStations(List.of(station)); + job.setCompletedStationOrders(List.of(0)); + rejectCompletion(task, job); + } + + private void rejectCompletion(BaseTask task, Job job) { + when(taskRepository.findById(task.getId())).thenReturn(Optional.of(task)); + when(taskAssignmentService.findJobForTask(task)).thenReturn(Optional.of(job)); + Map extra = Map.of("photos", List.of("image-data"), "barcodes", List.of("CODE-1"), + "signatureSvg", "", "commentText", "Late comment"); + controller.handleTaskCompleted(Map.of("taskId", task.getIdAsString(), "extraData", extra), + task.getTaskType(), "driver-1"); + verify(taskRepository, never()).save(any()); + verifyNoInteractions(photoRepository, signatureRepository, barcodeRepository, commentRepository, + emailService, jobHistoryService, jobUpdateBroadcaster); + } }