diff --git a/app/lib/cargo_items_view.dart b/app/lib/cargo_items_view.dart index 690e586..3d47890 100644 --- a/app/lib/cargo_items_view.dart +++ b/app/lib/cargo_items_view.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:votianlt_app/models/cargo_item.dart'; import 'app_theme.dart'; import 'l10n/app_localizations.dart'; @@ -81,8 +82,7 @@ class _CargoItemsViewState extends State { Expanded( child: Padding( padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + child: ListView( children: [ // Job summary card Card( @@ -122,11 +122,13 @@ class _CargoItemsViewState extends State { color: Colors.green[600], ), const SizedBox(width: 4), - Text( - widget.job.pickupCity, - style: TextStyle( - fontSize: 12, - color: Colors.grey[700], + Flexible( + child: Text( + widget.job.pickupCity, + style: TextStyle( + fontSize: 12, + color: Colors.grey[700], + ), ), ), const SizedBox(width: 8), @@ -142,13 +144,15 @@ class _CargoItemsViewState extends State { color: AppColors.primary, ), const SizedBox(width: 4), - Text( - widget.job.deliveryCitiesDisplay.isNotEmpty - ? widget.job.deliveryCitiesDisplay - : widget.job.deliveryCity, - style: TextStyle( - fontSize: 12, - color: Colors.grey[700], + Flexible( + child: Text( + widget.job.deliveryCitiesDisplay.isNotEmpty + ? widget.job.deliveryCitiesDisplay + : widget.job.deliveryCity, + style: TextStyle( + fontSize: 12, + color: Colors.grey[700], + ), ), ), ], @@ -157,6 +161,30 @@ class _CargoItemsViewState extends State { ), ), ), + if (widget.job.cargoItems.isNotEmpty) ...[ + Row( + children: [ + Icon( + Icons.inventory_2_outlined, + size: 24, + color: AppColors.primary, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + l10n.cargo, + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + const SizedBox(height: 8), + ...widget.job.cargoItems.map(_buildCargoItemCard), + const SizedBox(height: 16), + ], // Delivery stations section header Row( children: [ @@ -166,17 +194,19 @@ class _CargoItemsViewState extends State { color: AppColors.primary, ), const SizedBox(width: 8), - Text( - l10n.deliveryStationsCount(_deliveryStations.length), - style: const TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, + Expanded( + child: Text( + l10n.deliveryStationsCount(_deliveryStations.length), + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), ), ), ], ), const SizedBox(height: 16), - Expanded(child: _buildDeliveryStationsList()), + _buildDeliveryStationsList(), ], ), ), @@ -186,6 +216,39 @@ class _CargoItemsViewState extends State { ); } + Widget _buildCargoItemCard(CargoItem item) { + final l10n = AppLocalizations.of(context); + + return Card( + margin: const EdgeInsets.symmetric(vertical: 8), + elevation: 2, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.description.trim().isNotEmpty + ? item.description + : l10n.cargo, + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + Text('${l10n.quantity}: ${item.quantity}'), + if (item.hasDimensions) ...[ + const SizedBox(height: 4), + Text('${l10n.dimensions}: ${item.formattedDimensions}'), + ], + if (item.weightKg.isFinite && item.weightKg > 0) ...[ + const SizedBox(height: 4), + Text('${l10n.weight}: ${item.formattedWeight}'), + ], + ], + ), + ), + ); + } + List get _deliveryStations { if (widget.job.deliveryStations.isNotEmpty) { return widget.job.deliveryStations; @@ -238,12 +301,9 @@ class _CargoItemsViewState extends State { ); } - return ListView.builder( - itemCount: _deliveryStations.length, - itemBuilder: (context, index) { - final station = _deliveryStations[index]; - return _buildDeliveryStationCard(station); - }, + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: _deliveryStations.map(_buildDeliveryStationCard).toList(), ); } diff --git a/app/lib/models/cargo_item.dart b/app/lib/models/cargo_item.dart index 9e89491..3cc91ea 100644 --- a/app/lib/models/cargo_item.dart +++ b/app/lib/models/cargo_item.dart @@ -87,9 +87,18 @@ class CargoItem { @override int get hashCode => id.hashCode; + bool get hasDimensions => + [lengthCm, widthCm, heightCm].any((value) => value.isFinite && value > 0); + /// Get formatted dimensions string for display String get formattedDimensions { - return '${lengthCm.toInt()} × ${widthCm.toInt()} × ${heightCm.toInt()} cm'; + final dimensions = [lengthCm, widthCm, heightCm].map((value) { + if (!value.isFinite || value <= 0) return '–'; + return value == value.truncateToDouble() + ? value.toStringAsFixed(0) + : value.toString(); + }); + return '${dimensions.join(' × ')} cm'; } /// Get formatted weight string for display diff --git a/app/lib/services/websocket_service.dart b/app/lib/services/websocket_service.dart index 434bc6f..c11e29a 100644 --- a/app/lib/services/websocket_service.dart +++ b/app/lib/services/websocket_service.dart @@ -1216,6 +1216,7 @@ class WebSocketService { name: 'WebSocketService', ); developer.log('Stack: $st', name: 'WebSocketService'); + rethrow; } } diff --git a/app/lib/task_view.dart b/app/lib/task_view.dart index f3d47b4..e55e709 100644 --- a/app/lib/task_view.dart +++ b/app/lib/task_view.dart @@ -42,6 +42,9 @@ class TaskView extends StatefulWidget { class _TaskViewState extends State { final Set _completedTasks = {}; final DatabaseService _databaseService = DatabaseService(); + bool _statusesLoaded = false; + bool _stationCompleted = false; + bool _isCompletingStation = false; // Store SVG representations of signatures per task for later use final Map _signatureSvgByTask = {}; @@ -61,10 +64,19 @@ class _TaskViewState extends State { .toList(); } + String get _stationCompletionKey => + 'station_completed:${widget.job.id}:${widget.stationOrder ?? 0}'; + /// Load task completion and skipped statuses from database and merge with JSON task states Future _loadTaskStatuses() async { final statuses = await _databaseService.loadAllTaskStatuses(); + final stationCompleted = + await _databaseService.loadKeyValue(_stationCompletionKey) == '1'; + if (!mounted) return; setState(() { + _stationCompleted = + stationCompleted || widget.job.status.toUpperCase() == 'COMPLETED'; + _statusesLoaded = true; _completedTasks.clear(); // 1) Add all completed from DB for (final entry in statuses.entries) { @@ -148,9 +160,11 @@ class _TaskViewState extends State { } bool get _canCompleteStation { + if (!_statusesLoaded || _stationCompleted || _isCompletingStation) { + return false; + } // Station kann abgeschlossen werden, wenn alle nicht-optionalen Aufgaben - // erledigt sind. Sind nur optionale Aufgaben vorhanden, ist der Button - // immer aktiv. + // erledigt sind und der Stationsabschluss noch nicht gesendet wurde. for (final t in _visibleTasks) { if (!t.optional && !_completedTasks.contains(t.id)) { return false; @@ -184,6 +198,7 @@ class _TaskViewState extends State { } void _onCompleteStationPressed() { + if (!_canCompleteStation) return; if (_hasIncompleteOptionalTasks) { showDialog( context: context, @@ -214,21 +229,30 @@ class _TaskViewState extends State { } } - void _sendStationCompleted() { + Future _sendStationCompleted() async { + if (!_canCompleteStation) return; + setState(() => _isCompletingStation = true); final stationOrder = widget.stationOrder ?? 0; try { - StompService().sendStationCompleted( + await StompService().sendStationCompleted( jobId: widget.job.id, jobNumber: widget.job.jobNumber, stationOrder: stationOrder, hasIncompleteOptionalTasks: _hasIncompleteOptionalTasks, ); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Station abgeschlossen')), - ); + _stationCompleted = true; + await _databaseService.saveKeyValue(_stationCompletionKey, '1'); + if (!mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('Station abgeschlossen'))); Navigator.of(context).pop(); } catch (e) { developer.log('Error sending station completion: $e', name: 'TaskView'); + } finally { + if (mounted) { + setState(() => _isCompletingStation = false); + } } } @@ -372,18 +396,18 @@ class _TaskViewState extends State { child: Align( alignment: Alignment.centerRight, child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 4, - ), - decoration: BoxDecoration( - color: AppColors.warningSoft, - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: AppColors.warning.withValues(alpha: 0.5), - width: 1, + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 4, + ), + decoration: BoxDecoration( + color: AppColors.warningSoft, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: AppColors.warning.withValues(alpha: 0.5), + width: 1, + ), ), - ), child: const Text( 'Optional', style: TextStyle( @@ -756,7 +780,7 @@ class _TaskViewState extends State { // den Benutzer fragen, ob er die Station jetzt abschließen möchte. if (hadOpenMandatoryBefore && !_hasOpenMandatoryTasks) { WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { + if (mounted && _canCompleteStation) { _showLastMandatoryCompletedDialog(); } }); @@ -899,7 +923,9 @@ class _TaskViewState extends State { ? localizeKnownText(context, task.description!) : null; final String? signatureNote = - (task is SignatureTask && task.note != null && task.note!.trim().isNotEmpty) + (task is SignatureTask && + task.note != null && + task.note!.trim().isNotEmpty) ? task.note!.trim() : null; diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 2d7d458..c981b61 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.22+922 +version: 0.9.23+923 environment: sdk: ^3.7.0 diff --git a/backend/pom.xml b/backend/pom.xml index dc42791..4e72fe9 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -11,7 +11,7 @@ jar - 0.9.22 + 0.9.23 21 21 21 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 2a902b6..bfc6cf1 100644 --- a/backend/src/main/java/de/assecutor/votianlt/controller/MessageController.java +++ b/backend/src/main/java/de/assecutor/votianlt/controller/MessageController.java @@ -31,6 +31,10 @@ import de.assecutor.votianlt.model.JobStatus; import lombok.extern.slf4j.Slf4j; import de.assecutor.votianlt.messaging.MessagingPublisher; import org.springframework.stereotype.Component; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.data.mongodb.core.query.Update; import java.time.LocalDateTime; import java.util.List; @@ -53,6 +57,7 @@ public class MessageController { private final AppUserService appUserService; private final JobRepository jobRepository; + private final MongoTemplate mongoTemplate; private final CargoItemRepository cargoItemRepository; @@ -72,11 +77,12 @@ public class MessageController { TaskRepository taskRepository, PhotoRepository photoRepository, BarcodeRepository barcodeRepository, SignatureRepository signatureRepository, CommentRepository commentRepository, JobHistoryService jobHistoryService, JobUpdateBroadcaster jobUpdateBroadcaster, EmailService emailService, - MessageService messageService, TaskAssignmentService taskAssignmentService) { + MessageService messageService, TaskAssignmentService taskAssignmentService, MongoTemplate mongoTemplate) { this.messagingPublisher = messagingPublisher; this.appUserRepository = appUserRepository; this.appUserService = appUserService; this.jobRepository = jobRepository; + this.mongoTemplate = mongoTemplate; this.cargoItemRepository = cargoItemRepository; this.taskRepository = taskRepository; this.photoRepository = photoRepository; @@ -420,7 +426,9 @@ public class MessageController { boolean allCompleted = mandatoryTasks.stream().allMatch(task -> task.isCompleted()); if (allCompleted) { - updateJobStatusToCompleted(jobId); + if (!updateJobStatusToCompleted(jobId)) { + return; + } try { emailService.sendJobCompletionNotification(jobId, completedBy); } catch (Exception e) { @@ -438,23 +446,18 @@ public class MessageController { } } - private void updateJobStatusToCompleted(ObjectId jobId) { + private boolean updateJobStatusToCompleted(ObjectId jobId) { try { - Optional jobOpt = jobRepository.findById(jobId); - if (jobOpt.isEmpty()) { - log.warn("[JOB] Cannot mark job {} as completed: job not found", jobId); - return; - } - - Job job = jobOpt.get(); - if (job.getStatus() != JobStatus.COMPLETED) { - job.setStatus(JobStatus.COMPLETED); - job.setUpdatedAt(LocalDateTime.now()); - jobRepository.save(job); + Query query = Query.query(Criteria.where("_id").is(jobId).and("status").ne(JobStatus.COMPLETED)); + Update update = new Update().set("status", JobStatus.COMPLETED).set("updatedAt", LocalDateTime.now()); + boolean completed = mongoTemplate.updateFirst(query, update, Job.class).getModifiedCount() > 0; + if (completed) { log.info("[JOB] Job {} marked as COMPLETED", jobId); } + return completed; } catch (Exception e) { log.error("[JOB] Could not update status of job {} to COMPLETED: {}", jobId, e.getMessage(), e); + return false; } } diff --git a/backend/src/main/java/de/assecutor/votianlt/pages/base/ui/component/DeliveryStationDialog.java b/backend/src/main/java/de/assecutor/votianlt/pages/base/ui/component/DeliveryStationDialog.java index cd8a59d..2ef9b20 100644 --- a/backend/src/main/java/de/assecutor/votianlt/pages/base/ui/component/DeliveryStationDialog.java +++ b/backend/src/main/java/de/assecutor/votianlt/pages/base/ui/component/DeliveryStationDialog.java @@ -1600,6 +1600,7 @@ public class DeliveryStationDialog extends Dialog { if (copy != null) { copy.setDescription(original.getDescription()); + copy.setOptional(original.isOptional()); copy.setTaskOrder(original.getTaskOrder() != null ? original.getTaskOrder() : 0); copy.setCompleted(original.isCompleted()); copy.setCompletedAt(original.getCompletedAt()); diff --git a/backend/src/main/java/de/assecutor/votianlt/pages/base/ui/component/PickupStationDialog.java b/backend/src/main/java/de/assecutor/votianlt/pages/base/ui/component/PickupStationDialog.java index f28c158..81b2a69 100644 --- a/backend/src/main/java/de/assecutor/votianlt/pages/base/ui/component/PickupStationDialog.java +++ b/backend/src/main/java/de/assecutor/votianlt/pages/base/ui/component/PickupStationDialog.java @@ -310,7 +310,7 @@ public class PickupStationDialog extends Dialog { customerLabelMap.clear(); for (Customer c : customers) { - CustomerAddressLabelHelper.putName(customerLabelMap, c, + CustomerAddressLabelHelper.put(customerLabelMap, c, translationHelper.getTranslation("addjob.customer.unnamed")); } customerComboBox.setItems(new ArrayList<>(customerLabelMap.keySet())); diff --git a/backend/src/main/java/de/assecutor/votianlt/pages/view/AddJobView.java b/backend/src/main/java/de/assecutor/votianlt/pages/view/AddJobView.java index 9545a57..54e940c 100644 --- a/backend/src/main/java/de/assecutor/votianlt/pages/view/AddJobView.java +++ b/backend/src/main/java/de/assecutor/votianlt/pages/view/AddJobView.java @@ -243,7 +243,7 @@ public class AddJobView extends Main implements HasDynamicTitle { List ownerCustomers = customerService.findAllForCurrentOwner(); customerLabelToEntity.clear(); for (Customer c : ownerCustomers) { - CustomerAddressLabelHelper.putName(customerLabelToEntity, c, getTranslation("addjob.customer.unnamed")); + CustomerAddressLabelHelper.put(customerLabelToEntity, c, getTranslation("addjob.customer.unnamed")); } customerSelection.setItems(new ArrayList<>(customerLabelToEntity.keySet())); diff --git a/backend/src/test/java/de/assecutor/votianlt/controller/StationCompletionTest.java b/backend/src/test/java/de/assecutor/votianlt/controller/StationCompletionTest.java new file mode 100644 index 0000000..18ac8a9 --- /dev/null +++ b/backend/src/test/java/de/assecutor/votianlt/controller/StationCompletionTest.java @@ -0,0 +1,112 @@ +package de.assecutor.votianlt.controller; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import com.mongodb.client.result.UpdateResult; +import de.assecutor.votianlt.model.Job; +import de.assecutor.votianlt.model.JobStatus; +import de.assecutor.votianlt.model.task.ConfirmationTask; +import de.assecutor.votianlt.repository.JobRepository; +import de.assecutor.votianlt.service.EmailService; +import de.assecutor.votianlt.service.JobUpdateBroadcaster; +import de.assecutor.votianlt.service.TaskAssignmentService; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.bson.Document; +import org.bson.types.ObjectId; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.data.mongodb.core.query.Update; + +@ExtendWith(MockitoExtension.class) +class StationCompletionTest { + + @Mock + private JobRepository jobRepository; + @Mock + private TaskAssignmentService taskAssignmentService; + @Mock + private EmailService emailService; + @Mock + private JobUpdateBroadcaster jobUpdateBroadcaster; + @Mock + private MongoTemplate mongoTemplate; + @InjectMocks + private MessageController controller; + + private final ObjectId jobId = new ObjectId(); + private ConfirmationTask mandatoryTask; + private Map payload; + + @BeforeEach + void setUp() { + Job job = new Job(); + job.setId(jobId); + job.setJobNumber("JOB-001"); + when(jobRepository.findById(jobId)).thenReturn(Optional.of(job)); + mandatoryTask = new ConfirmationTask("Lieferung bestätigen"); + mandatoryTask.setCompleted(true); + when(taskAssignmentService.findTasksForJob(jobId)).thenReturn(List.of(mandatoryTask)); + payload = Map.of("jobId", jobId.toHexString(), "jobNumber", "JOB-001", "stationOrder", 0); + } + + @Test + void repeatedStationCompletionSendsOnlyOneEmail() { + when(mongoTemplate.updateFirst(any(Query.class), any(Update.class), eq(Job.class))) + .thenReturn(UpdateResult.acknowledged(1, 1L, null), UpdateResult.acknowledged(0, 0L, null)); + + controller.handleStationCompleted("driver-1", payload); + controller.handleStationCompleted("driver-1", payload); + + verify(emailService).sendJobCompletionNotification(jobId, "driver-1"); + } + + @Test + void alreadyCompletedJobDoesNotSendAnotherEmail() { + when(mongoTemplate.updateFirst(any(Query.class), any(Update.class), eq(Job.class))) + .thenReturn(UpdateResult.acknowledged(0, 0L, null)); + + controller.handleStationCompleted("driver-1", payload); + + verifyNoInteractions(emailService); + ArgumentCaptor query = ArgumentCaptor.forClass(Query.class); + verify(mongoTemplate).updateFirst(query.capture(), any(Update.class), eq(Job.class)); + assertThat(query.getValue().getQueryObject()).containsEntry("_id", jobId) + .containsEntry("status", new Document("$ne", JobStatus.COMPLETED)); + } + + @Test + void incompleteMandatoryTaskPreventsJobCompletion() { + mandatoryTask.setCompleted(false); + + controller.handleStationCompleted("driver-1", payload); + + verifyNoInteractions(mongoTemplate, emailService); + } + + @Test + void unfinishedOptionalTaskDoesNotPreventExplicitCompletion() { + ConfirmationTask optionalTask = new ConfirmationTask("Zusätzliche Bestätigung"); + optionalTask.setOptional(true); + when(taskAssignmentService.findTasksForJob(jobId)).thenReturn(List.of(mandatoryTask, optionalTask)); + when(mongoTemplate.updateFirst(any(Query.class), any(Update.class), eq(Job.class))) + .thenReturn(UpdateResult.acknowledged(1, 1L, null)); + + controller.handleStationCompleted("driver-1", payload); + + verify(emailService).sendJobCompletionNotification(jobId, "driver-1"); + } +}