diff --git a/app/lib/objectbox-model.json b/app/lib/objectbox-model.json index 50f54ee..e696264 100644 --- a/app/lib/objectbox-model.json +++ b/app/lib/objectbox-model.json @@ -191,7 +191,7 @@ }, { "id": "5:2194624907249454848", - "lastPropertyId": "6:5035828038544573244", + "lastPropertyId": "7:5673785903451668117", "name": "TaskStatusEntity", "properties": [ { @@ -275,7 +275,9 @@ "modelVersionParserMinimum": 5, "retiredEntityUids": [], "retiredIndexUids": [], - "retiredPropertyUids": [], + "retiredPropertyUids": [ + 5673785903451668117 + ], "retiredRelationUids": [], "version": 1 } \ No newline at end of file diff --git a/app/lib/objectbox.g.dart b/app/lib/objectbox.g.dart index 16b2eb8..3053162 100644 --- a/app/lib/objectbox.g.dart +++ b/app/lib/objectbox.g.dart @@ -240,7 +240,7 @@ final _entities = [ obx_int.ModelEntity( id: const obx_int.IdUid(5, 2194624907249454848), name: 'TaskStatusEntity', - lastPropertyId: const obx_int.IdUid(6, 5035828038544573244), + lastPropertyId: const obx_int.IdUid(7, 5673785903451668117), flags: 0, properties: [ obx_int.ModelProperty( @@ -371,7 +371,7 @@ obx_int.ModelDefinition getObjectBoxModel() { lastSequenceId: const obx_int.IdUid(0, 0), retiredEntityUids: const [], retiredIndexUids: const [], - retiredPropertyUids: const [], + retiredPropertyUids: const [5673785903451668117], retiredRelationUids: const [], modelVersion: 5, modelVersionParserMinimum: 5, @@ -632,7 +632,7 @@ obx_int.ModelDefinition getObjectBoxModel() { }, objectToFB: (TaskStatusEntity object, fb.Builder fbb) { final taskIdOffset = fbb.writeString(object.taskId); - fbb.startTable(7); + fbb.startTable(8); fbb.addInt64(0, object.id); fbb.addOffset(1, taskIdOffset); fbb.addBool(2, object.completed); diff --git a/app/lib/task_view.dart b/app/lib/task_view.dart index ab4b5a2..eda821b 100644 --- a/app/lib/task_view.dart +++ b/app/lib/task_view.dart @@ -40,7 +40,6 @@ class TaskView extends StatefulWidget { class _TaskViewState extends State { final Set _completedTasks = {}; - final Set _skippedTasks = {}; final DatabaseService _databaseService = DatabaseService(); // Store SVG representations of signatures per task for later use final Map _signatureSvgByTask = {}; @@ -61,7 +60,7 @@ class _TaskViewState extends State { .toList(); } - /// Load task completion statuses from database and merge with JSON task states + /// Load task completion and skipped statuses from database and merge with JSON task states Future _loadTaskStatuses() async { final statuses = await _databaseService.loadAllTaskStatuses(); setState(() { @@ -170,33 +169,26 @@ class _TaskViewState extends State { itemBuilder: (context, index) { final task = _visibleTasks[index]; final isCompleted = _completedTasks.contains(task.id); - final isSkipped = _skippedTasks.contains(task.id); final canBeCompletedNow = - !isCompleted && !isSkipped && _arePreviousTasksCompleted(index); + !isCompleted && _arePreviousTasksCompleted(index); // Hintergrundfarbe je nach Status: - // abgeschlossen → hellgrün, übersprungen → hellgelb, bearbeitbar → weiß, gesperrt → hellgrau + // abgeschlossen → hellgrün, bearbeitbar → weiß, gesperrt → hellgrau final Color cardColor = isCompleted ? const Color(0xFFE8F5E9) // hellgrün - : isSkipped - ? const Color(0xFFFFF8E1) // hellgelb : canBeCompletedNow ? Colors.white : const Color(0xFFF5F5F5); // hellgrau final Color borderColor = isCompleted ? Colors.green[300]! - : isSkipped - ? Colors.amber[300]! : canBeCompletedNow ? Colors.grey[300]! : Colors.grey[200]!; final Color circleColor = isCompleted ? Colors.green[600]! - : isSkipped - ? Colors.amber[600]! : canBeCompletedNow ? Colors.deepPurple[400]! : Colors.grey[400]!; @@ -250,7 +242,7 @@ class _TaskViewState extends State { children: [ _buildTaskDisplayText( task, - isCompleted || isSkipped, + isCompleted, index, ), if (_getTaskStationLabel(task) != null) ...[ @@ -264,6 +256,28 @@ class _TaskViewState extends State { ), ), ], + if (task.optional) ...[ + const SizedBox(height: 4), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 2, + ), + decoration: BoxDecoration( + color: Colors.amber[50], + border: Border.all(color: Colors.amber[300]!), + borderRadius: BorderRadius.circular(12), + ), + child: Text( + AppLocalizations.of(context).optional, + style: TextStyle( + fontSize: 11, + color: Colors.amber[800], + fontWeight: FontWeight.w600, + ), + ), + ), + ], ], ), ), @@ -271,10 +285,6 @@ class _TaskViewState extends State { const SizedBox(width: 8), Icon(Icons.check_circle, color: Colors.green[600]), ], - if (isSkipped) ...[ - const SizedBox(width: 8), - Icon(Icons.skip_next, color: Colors.amber[600]), - ], ], ), ), @@ -634,9 +644,7 @@ class _TaskViewState extends State { if (index <= 0) return true; for (int i = 0; i < index; i++) { final t = _visibleTasks[i]; - if (!t.optional && - !_completedTasks.contains(t.id) && - !_skippedTasks.contains(t.id)) { + if (!t.optional && !_completedTasks.contains(t.id)) { return false; } } diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 85f4d98..cd3fe1d 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.15+1 +version: 0.9.16+1 environment: sdk: ^3.7.0 diff --git a/backend/docker_push.sh b/backend/docker_push.sh index 5884ee8..f3289fe 100755 --- a/backend/docker_push.sh +++ b/backend/docker_push.sh @@ -4,7 +4,7 @@ set -euo pipefail readonly SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" readonly REGISTRY_IMAGE="registry.assecutor.org/votianlt" -readonly BACKEND_DIR="${SCRIPT_DIR}/backend" +readonly BACKEND_DIR="${SCRIPT_DIR}" usage() { cat <<'EOF' diff --git a/backend/pom.xml b/backend/pom.xml index 16001d6..52e8a7e 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -11,7 +11,7 @@ jar - 0.9.15 + 0.9.16 21 21 21 diff --git a/backend/src/main/java/de/assecutor/votianlt/messaging/WebSocketConfig.java b/backend/src/main/java/de/assecutor/votianlt/messaging/WebSocketConfig.java index 7ae45f7..cfd222d 100644 --- a/backend/src/main/java/de/assecutor/votianlt/messaging/WebSocketConfig.java +++ b/backend/src/main/java/de/assecutor/votianlt/messaging/WebSocketConfig.java @@ -1,10 +1,12 @@ package de.assecutor.votianlt.messaging; import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.web.socket.config.annotation.EnableWebSocket; import org.springframework.web.socket.config.annotation.WebSocketConfigurer; import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry; +import org.springframework.web.socket.server.standard.ServletServerContainerFactoryBean; import org.springframework.web.socket.server.support.HttpSessionHandshakeInterceptor; /** @@ -23,6 +25,12 @@ public class WebSocketConfig implements WebSocketConfigurer { @Value("${app.messaging.websocket.allowed-origins:*}") private String allowedOrigins; + @Value("${app.messaging.websocket.max-text-message-size:10485760}") + private int maxTextMessageSize; + + @Value("${app.messaging.websocket.max-session-idle-timeout:300000}") + private long maxSessionIdleTimeout; + public WebSocketConfig(WebSocketService webSocketService) { this.webSocketService = webSocketService; } @@ -32,4 +40,13 @@ public class WebSocketConfig implements WebSocketConfigurer { registry.addHandler(webSocketService, wsPath).setAllowedOrigins(allowedOrigins.split(",")) .addInterceptors(new HttpSessionHandshakeInterceptor()); } + + @Bean + public ServletServerContainerFactoryBean createWebSocketContainer() { + ServletServerContainerFactoryBean container = new ServletServerContainerFactoryBean(); + container.setMaxTextMessageBufferSize(maxTextMessageSize); + container.setMaxBinaryMessageBufferSize(maxTextMessageSize); + container.setMaxSessionIdleTimeout(maxSessionIdleTimeout); + return container; + } } 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 5be8489..12a097b 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 @@ -2093,7 +2093,103 @@ public class AddJobView extends Main implements HasDynamicTitle { return null; } + /** + * Entfernt alle leeren (nicht editierten) Lieferstationen, sofern mindestens + * eine valide Lieferstation übrig bleibt. Wird aufgerufen bevor die Stationen + * übernommen werden. + */ + private void removeEmptyDeliveryStations() { + // Indizes der leeren Stationen sammeln (absteigend, damit beim Entfernen die Indizes stabil bleiben) + List emptyIndices = new ArrayList<>(); + for (int i = 0; i < deliveryStationsState.size(); i++) { + if (hasDeliveryStationValidationErrors(deliveryStationsState.get(i))) { + emptyIndices.add(i); + } + } + + // Mindestens eine valide Station muss übrig bleiben + if (emptyIndices.size() >= deliveryStationsState.size()) { + return; + } + + // Von hinten nach vorne entfernen, damit Indizes stabil bleiben + for (int k = emptyIndices.size() - 1; k >= 0; k--) { + int idx = emptyIndices.get(k); + + deliveryStationTilesList.remove(idx); + deliveryStationsState.remove(idx); + deliveryStationsSaveAddress.remove(idx); + deliveryStationsMailState.remove(idx); + deliveryStationsValidatedByGoogle.remove(idx); + deliveryStationTasksState.remove(idx); + Div removedSlot = deliveryStationSlotList.remove(idx); + deliveryStationDistanceChips.remove(idx); + pickupToDeliveryRouteResults.remove(idx); + stationsGridContainer.remove(removedSlot); + } + + // Tasks und Routen-Maps re-indizieren + Map> reindexedTasks = new HashMap<>(); + for (Map.Entry> entry : deliveryStationTasksState.entrySet()) { + int oldIdx = entry.getKey(); + int newIdx = oldIdx - (int) emptyIndices.stream().filter(ei -> ei < oldIdx).count(); + reindexedTasks.put(newIdx, entry.getValue()); + } + deliveryStationTasksState.clear(); + deliveryStationTasksState.putAll(reindexedTasks); + + Map reindexedRoutes = new HashMap<>(); + for (Map.Entry entry : pickupToDeliveryRouteResults.entrySet()) { + int oldIdx = entry.getKey(); + int newIdx = oldIdx - (int) emptyIndices.stream().filter(ei -> ei < oldIdx).count(); + reindexedRoutes.put(newIdx, entry.getValue()); + } + pickupToDeliveryRouteResults.clear(); + pickupToDeliveryRouteResults.putAll(reindexedRoutes); + + // Service-Zuordnungen anpassen + for (SelectedServiceEntry selectedService : selectedServices) { + Integer stationOrder = selectedService.getDeliveryStationOrder(); + if (stationOrder == null) { + continue; + } + if (emptyIndices.contains(stationOrder)) { + selectedService.setDeliveryStationOrder(deliveryStationsState.isEmpty() ? null : 0); + } else { + int newOrder = stationOrder - (int) emptyIndices.stream().filter(ei -> ei < stationOrder).count(); + selectedService.setDeliveryStationOrder(newOrder); + } + } + + // Tiles neu nummerieren und Click-Listener aktualisieren + for (int i = 0; i < deliveryStationTilesList.size(); i++) { + StationTile t = deliveryStationTilesList.get(i); + int newNumber = i + 1; + t.updateStationNumber(newNumber); + t.updateTitle(getTranslation("addjob.station.delivery", newNumber)); + final int newIdx = i; + t.setClickListener(tt -> openDeliveryDialog(tt, newIdx)); + if (i == 0) { + t.setDeleteListener(null); + } + } + + // "+" Button wieder anzeigen falls unter Maximum + if (deliveryStationTilesList.size() < MAX_DELIVERY_STATIONS + && addStationButtonSlot.getParent().isEmpty()) { + stationsGridContainer.add(addStationButtonSlot); + } + + if (servicesGrid != null) { + servicesGrid.getDataProvider().refreshAll(); + } + updatePriceSummary(); + triggerValidation(); + updateTabLabels(); + } + private void handleApplyStations() { + removeEmptyDeliveryStations(); revealPriceAndDetailsSection(); if (!areAllStationsValidatedByGoogle()) { diff --git a/backend/src/main/java/de/assecutor/votianlt/pages/view/JobSummaryView.java b/backend/src/main/java/de/assecutor/votianlt/pages/view/JobSummaryView.java index 970452e..15136a7 100644 --- a/backend/src/main/java/de/assecutor/votianlt/pages/view/JobSummaryView.java +++ b/backend/src/main/java/de/assecutor/votianlt/pages/view/JobSummaryView.java @@ -14,6 +14,7 @@ import com.vaadin.flow.component.notification.Notification; import com.vaadin.flow.component.notification.NotificationVariant; import com.vaadin.flow.component.icon.Icon; import com.vaadin.flow.component.icon.VaadinIcon; +import com.vaadin.flow.component.textfield.TextArea; import com.vaadin.flow.component.AttachEvent; import com.vaadin.flow.component.DetachEvent; import com.vaadin.flow.component.UI; @@ -59,6 +60,8 @@ import de.assecutor.votianlt.service.JobUpdateBroadcaster; import de.assecutor.votianlt.service.LocationService; import de.assecutor.votianlt.service.MessageService; import de.assecutor.votianlt.service.TaskAssignmentService; +import de.assecutor.votianlt.model.JobHistoryType; +import de.assecutor.votianlt.security.SecurityService; import de.assecutor.votianlt.util.DateTimeFormatUtil; import jakarta.annotation.security.RolesAllowed; import org.bson.types.ObjectId; @@ -88,6 +91,7 @@ public class JobSummaryView extends Main implements HasUrlParameter, Has private final LocationService locationService; private final ServiceRepository serviceRepository; private final TaskAssignmentService taskAssignmentService; + private final SecurityService securityService; @Value("${app.google.maps.api-key}") private String googleMapsApiKey; @@ -103,7 +107,8 @@ public class JobSummaryView extends Main implements HasUrlParameter, Has PhotoRepository photoRepository, CommentRepository commentRepository, AppUserService appUserService, MessageService messageService, JobHistoryService jobHistoryService, JobUpdateBroadcaster jobUpdateBroadcaster, LocationService locationService, - ServiceRepository serviceRepository, TaskAssignmentService taskAssignmentService) { + ServiceRepository serviceRepository, TaskAssignmentService taskAssignmentService, + SecurityService securityService) { this.jobRepository = jobRepository; this.cargoItemRepository = cargoItemRepository; this.signatureRepository = signatureRepository; @@ -116,6 +121,7 @@ public class JobSummaryView extends Main implements HasUrlParameter, Has this.locationService = locationService; this.serviceRepository = serviceRepository; this.taskAssignmentService = taskAssignmentService; + this.securityService = securityService; setSizeFull(); addClassNames(LumoUtility.BoxSizing.BORDER, LumoUtility.Display.FLEX, LumoUtility.FlexDirection.COLUMN, @@ -212,6 +218,15 @@ public class JobSummaryView extends Main implements HasUrlParameter, Has getUI().ifPresent(ui -> ui.navigate("message-details/" + appUserId + "/" + conversationId)); }); + // Create Manual Completion Button for app jobs (digital processing) + Button manualCompleteButton = null; + if (job.isDigitalProcessing() && job.getStatus() != JobStatus.COMPLETED + && job.getStatus() != JobStatus.CANCELLED) { + manualCompleteButton = new Button(getTranslation("jobsummary.button.manualcomplete")); + manualCompleteButton.addThemeVariants(ButtonVariant.LUMO_ERROR); + manualCompleteButton.addClickListener(e -> openManualCompleteDialog(job)); + } + // Create Job History Button for toolbar Button jobHistoryButton = new Button(getTranslation("jobsummary.button.jobhistory")); jobHistoryButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY); @@ -219,8 +234,13 @@ public class JobSummaryView extends Main implements HasUrlParameter, Has getUI().ifPresent(ui -> ui.navigate("job_history/" + job.getId().toHexString())); }); - // Add toolbar with both buttons in top right (Send Message button on the left) - add(new ViewToolbar(getTranslation("jobsummary.title"), sendMessageButton, jobHistoryButton)); + // Add toolbar with buttons + if (manualCompleteButton != null) { + add(new ViewToolbar(getTranslation("jobsummary.title"), manualCompleteButton, sendMessageButton, + jobHistoryButton)); + } else { + add(new ViewToolbar(getTranslation("jobsummary.title"), sendMessageButton, jobHistoryButton)); + } List cargo = cargoItemRepository.findByJobId(currentJobId); List tasks = taskAssignmentService.findTasksForJob(job); @@ -359,6 +379,75 @@ public class JobSummaryView extends Main implements HasUrlParameter, Has } } + private void openManualCompleteDialog(Job job) { + Dialog dialog = DialogStylingHelper.createStyledDialog( + getTranslation("jobsummary.dialog.manualcomplete.title"), "560px"); + + VerticalLayout dialogContent = DialogStylingHelper.createContentLayout("520px"); + + Span warningText = new Span(getTranslation("jobsummary.dialog.manualcomplete.text", job.getJobNumber())); + warningText.getStyle().set("color", "var(--lumo-error-text-color)"); + + TextArea reasonField = new TextArea(getTranslation("jobsummary.dialog.manualcomplete.reason")); + reasonField.setWidthFull(); + reasonField.setMinHeight("100px"); + reasonField.setRequired(true); + + dialogContent.add(warningText, reasonField); + dialog.add(DialogStylingHelper.wrapContent(dialogContent)); + + HorizontalLayout buttonBar = new HorizontalLayout(); + buttonBar.setWidthFull(); + buttonBar.setJustifyContentMode(HorizontalLayout.JustifyContentMode.END); + buttonBar.setSpacing(true); + + Button cancelButton = new Button(getTranslation("jobsummary.dialog.manualcomplete.cancel"), + e -> dialog.close()); + + Button confirmButton = new Button(getTranslation("jobsummary.dialog.manualcomplete.confirm")); + confirmButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY, ButtonVariant.LUMO_ERROR); + confirmButton.addClickListener(e -> { + String reason = reasonField.getValue(); + if (reason == null || reason.trim().isEmpty()) { + reasonField.setInvalid(true); + reasonField.setErrorMessage(getTranslation("jobsummary.dialog.manualcomplete.reason.required")); + return; + } + + try { + JobStatus oldStatus = job.getStatus(); + job.setStatus(JobStatus.COMPLETED); + job.setUpdatedAt(LocalDateTime.now()); + jobRepository.save(job); + + String currentUser = securityService.getCurrentUsername(); + jobHistoryService.logStatusChange(job, oldStatus, JobStatus.COMPLETED, currentUser); + + String description = String.format("Auftrag manuell beendet von %s. Begründung: %s", + currentUser, reason.trim()); + jobHistoryService.logCustomEvent(job.getId(), + getTranslation("jobsummary.history.manualcomplete.reason"), + description, currentUser, JobHistoryType.STATUS_CHANGE); + + dialog.close(); + Notification + .show(getTranslation("jobsummary.notification.completed", job.getJobNumber()), 3000, + Notification.Position.BOTTOM_END) + .addThemeVariants(NotificationVariant.LUMO_SUCCESS); + getUI().ifPresent(ui -> ui.navigate("job_summary/" + job.getId().toHexString())); + } catch (Exception ex) { + Notification + .show(getTranslation("jobsummary.notification.complete.error", ex.getMessage()), 5000, + Notification.Position.BOTTOM_END) + .addThemeVariants(NotificationVariant.LUMO_ERROR); + } + }); + + buttonBar.add(cancelButton, confirmButton); + dialog.getFooter().add(buttonBar); + dialog.open(); + } + private VerticalLayout borderedBox() { VerticalLayout box = new VerticalLayout(); box.addClassName("summary-card"); diff --git a/backend/src/main/java/de/assecutor/votianlt/service/EmailService.java b/backend/src/main/java/de/assecutor/votianlt/service/EmailService.java index 432bdf0..8bbf97f 100644 --- a/backend/src/main/java/de/assecutor/votianlt/service/EmailService.java +++ b/backend/src/main/java/de/assecutor/votianlt/service/EmailService.java @@ -1,7 +1,9 @@ package de.assecutor.votianlt.service; +import de.assecutor.votianlt.model.AppUser; import de.assecutor.votianlt.model.Job; import de.assecutor.votianlt.model.User; +import de.assecutor.votianlt.repository.AppUserRepository; import de.assecutor.votianlt.repository.JobRepository; import de.assecutor.votianlt.repository.UserRepository; import lombok.RequiredArgsConstructor; @@ -22,6 +24,7 @@ public class EmailService { private final UserRepository userRepository; private final JobRepository jobRepository; private final TaskAssignmentService taskAssignmentService; + private final AppUserRepository appUserRepository; private final JavaMailSender mailSender; @Value("${spring.mail.username}") @@ -52,8 +55,10 @@ public class EmailService { return; } + String completedByName = resolveCompletedByName(job, completedBy); + // Send email - sendEmail(user, job, taskType); + sendEmail(user, job, taskType, completedByName); log.info("Task completion notification sent to {} for job {} task {}", user.getEmail(), job.getJobNumber(), taskId); @@ -63,7 +68,7 @@ public class EmailService { } } - private void sendEmail(User user, Job job, String taskType) { + private void sendEmail(User user, Job job, String taskType, String completedByName) { SimpleMailMessage message = new SimpleMailMessage(); message.setFrom(smtpUsername); message.setTo(user.getEmail()); @@ -71,18 +76,17 @@ public class EmailService { "Aufgabe abgeschlossen - " + (job.getJobNumber() != null ? job.getJobNumber() : "Job " + job.getId())); String fullName = buildFullName(user); - String appUserName = buildAppUserName(user); String taskTypeName = getTaskTypeDisplayName(taskType); StringBuilder body = new StringBuilder(); body.append("Hallo ").append(fullName).append(",\n\n"); - body.append("eine Aufgabe wurde von ").append(appUserName).append(" abgeschlossen:\n\n"); + body.append("eine Aufgabe wurde von ").append(completedByName).append(" abgeschlossen:\n\n"); body.append("Job: ").append(job.getJobNumber() != null ? job.getJobNumber() : "Unbekannt").append("\n"); if (job.getDeliveryCompany() != null) { body.append("Kunde: ").append(job.getDeliveryCompany()).append("\n"); } body.append("Aufgabe: ").append(taskTypeName).append("\n"); - body.append("Abgeschlossen von: ").append(appUserName).append("\n\n"); + body.append("Abgeschlossen von: ").append(completedByName).append("\n\n"); String deliveryCities = job.getDeliveryCitiesDisplay(); if (job.getPickupCity() != null || deliveryCities != null) { @@ -121,16 +125,55 @@ public class EmailService { return fullName.isEmpty() ? "Benutzer" : fullName; } - private String buildAppUserName(User user) { + private String buildAppUserName(AppUser appUser) { StringBuilder name = new StringBuilder(); - if (user.getFirstname() != null && !user.getFirstname().isBlank()) { - name.append(user.getFirstname()).append(" "); + if (appUser.getVorname() != null && !appUser.getVorname().isBlank()) { + name.append(appUser.getVorname()).append(" "); } - if (user.getName() != null && !user.getName().isBlank()) { - name.append(user.getName()); + if (appUser.getNachname() != null && !appUser.getNachname().isBlank()) { + name.append(appUser.getNachname()); } + String fullName = name.toString().trim(); - return fullName.isEmpty() ? "App-Benutzer" : fullName; + if (!fullName.isEmpty()) { + return fullName; + } + if (appUser.getBezeichnung() != null && !appUser.getBezeichnung().isBlank()) { + return appUser.getBezeichnung().trim(); + } + if (appUser.getEmail() != null && !appUser.getEmail().isBlank()) { + return appUser.getEmail().trim(); + } + return "App-Benutzer"; + } + + private String resolveCompletedByName(Job job, String completedBy) { + Optional assignedAppUser = findAppUserById(job != null ? job.getAppUser() : null); + if (assignedAppUser.isPresent()) { + return buildAppUserName(assignedAppUser.get()); + } + + if (completedBy != null && !completedBy.isBlank() && !"Unknown".equalsIgnoreCase(completedBy)) { + Optional completingAppUser = findAppUserById(completedBy); + if (completingAppUser.isPresent()) { + return buildAppUserName(completingAppUser.get()); + } + return completedBy; + } + + return "App-Benutzer"; + } + + private Optional findAppUserById(String appUserId) { + if (appUserId == null || appUserId.isBlank()) { + return Optional.empty(); + } + + try { + return appUserRepository.findById(new ObjectId(appUserId)); + } catch (IllegalArgumentException e) { + return Optional.empty(); + } } private String getTaskTypeDisplayName(String taskType) { @@ -173,8 +216,10 @@ public class EmailService { return; } + String completedByName = resolveCompletedByName(job, completedBy); + // Send job completion email - sendJobCompletionEmail(user, job); + sendJobCompletionEmail(user, job, completedByName); log.info("Job completion notification sent to {} for job {}", user.getEmail(), job.getJobNumber()); } catch (Exception e) { @@ -182,7 +227,7 @@ public class EmailService { } } - private void sendJobCompletionEmail(User user, Job job) { + private void sendJobCompletionEmail(User user, Job job, String completedByName) { SimpleMailMessage message = new SimpleMailMessage(); message.setFrom(smtpUsername); message.setTo(user.getEmail()); @@ -190,7 +235,6 @@ public class EmailService { "Job abgeschlossen - " + (job.getJobNumber() != null ? job.getJobNumber() : "Job " + job.getId())); String fullName = buildFullName(user); - String appUserName = buildAppUserName(user); // Count completed tasks var allTasks = taskAssignmentService.findTasksForJob(job); @@ -220,7 +264,7 @@ public class EmailService { } body.append("Anzahl erledigter Aufgaben: ").append(taskCount).append("\n"); - body.append("Abgeschlossen von: ").append(appUserName).append("\n\n"); + body.append("Abgeschlossen von: ").append(completedByName).append("\n\n"); body.append("Der Job ist nun vollständig erledigt und kann weiterverarbeitet werden.\n\n"); body.append("Mit freundlichen Grüßen,\n"); diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index 8e9cf23..ad53bf9 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -62,7 +62,7 @@ app.security.two-factor.enabled=true # WebSocket Configuration app.messaging.websocket.path=/ws/messaging -app.messaging.websocket.max-text-message-size=65536 +app.messaging.websocket.max-text-message-size=10485760 app.messaging.websocket.max-session-idle-timeout=300000 app.messaging.websocket.allowed-origins=* diff --git a/backend/src/main/resources/messages_de.properties b/backend/src/main/resources/messages_de.properties index 6ae737c..db9e202 100644 --- a/backend/src/main/resources/messages_de.properties +++ b/backend/src/main/resources/messages_de.properties @@ -610,6 +610,14 @@ jobsummary.task.photo.taken=Aufgenommene Fotos ({0}) jobsummary.task.button.text=Button-Text jobsummary.button.schliessen=Schließen jobsummary.route.planned=Geplante Route +jobsummary.button.manualcomplete=Manuell beenden +jobsummary.dialog.manualcomplete.title=Auftrag manuell beenden +jobsummary.dialog.manualcomplete.text=Der Auftrag {0} wird jetzt manuell abgeschlossen. Danach kann er nicht mehr per App weiter bearbeitet werden. +jobsummary.dialog.manualcomplete.reason=Begründung +jobsummary.dialog.manualcomplete.reason.required=Bitte geben Sie eine Begründung ein +jobsummary.dialog.manualcomplete.cancel=Abbrechen +jobsummary.dialog.manualcomplete.confirm=Akzeptiert +jobsummary.history.manualcomplete.reason=Manuell beendet # Jobs jobs.title=Aufträge diff --git a/backend/src/main/resources/messages_ee.properties b/backend/src/main/resources/messages_ee.properties index 2079d42..76f864a 100644 --- a/backend/src/main/resources/messages_ee.properties +++ b/backend/src/main/resources/messages_ee.properties @@ -557,6 +557,14 @@ jobsummary.task.photo.taken=Tehtud fotod ({0}) jobsummary.task.button.text=Nupu tekst jobsummary.button.schliessen=Sulge jobsummary.route.planned=Planeeritud marsruut +jobsummary.button.manualcomplete=Lõpeta käsitsi +jobsummary.dialog.manualcomplete.title=Lõpeta tellimus käsitsi +jobsummary.dialog.manualcomplete.text=Tellimus {0} lõpetatakse nüüd käsitsi. Pärast seda ei saa seda enam rakenduse kaudu töödelda. +jobsummary.dialog.manualcomplete.reason=Põhjendus +jobsummary.dialog.manualcomplete.reason.required=Palun sisestage põhjendus +jobsummary.dialog.manualcomplete.cancel=Tühista +jobsummary.dialog.manualcomplete.confirm=Nõustu +jobsummary.history.manualcomplete.reason=Käsitsi lõpetatud jobs.title=Tellimused jobs.filter.search=Otsi jobs.filter.search.placeholder=Otsi tellimuse numbri j\u00e4rgi... diff --git a/backend/src/main/resources/messages_en.properties b/backend/src/main/resources/messages_en.properties index 85f389a..12b5c35 100644 --- a/backend/src/main/resources/messages_en.properties +++ b/backend/src/main/resources/messages_en.properties @@ -610,6 +610,14 @@ jobsummary.task.photo.taken=Photos taken ({0}) jobsummary.task.button.text=Button Text jobsummary.button.schliessen=Close jobsummary.route.planned=Planned Route +jobsummary.button.manualcomplete=Complete manually +jobsummary.dialog.manualcomplete.title=Complete job manually +jobsummary.dialog.manualcomplete.text=Job {0} will now be completed manually. It can no longer be processed via the app afterwards. +jobsummary.dialog.manualcomplete.reason=Reason +jobsummary.dialog.manualcomplete.reason.required=Please enter a reason +jobsummary.dialog.manualcomplete.cancel=Cancel +jobsummary.dialog.manualcomplete.confirm=Accept +jobsummary.history.manualcomplete.reason=Manually completed # Jobs jobs.title=Jobs diff --git a/backend/src/main/resources/messages_es.properties b/backend/src/main/resources/messages_es.properties index 369c152..47e4583 100644 --- a/backend/src/main/resources/messages_es.properties +++ b/backend/src/main/resources/messages_es.properties @@ -610,6 +610,14 @@ jobsummary.task.photo.taken=Fotos tomadas ({0}) jobsummary.task.button.text=Texto del bot\u00f3n jobsummary.button.schliessen=Cerrar jobsummary.route.planned=Ruta planificada +jobsummary.button.manualcomplete=Finalizar manualmente +jobsummary.dialog.manualcomplete.title=Finalizar pedido manualmente +jobsummary.dialog.manualcomplete.text=El pedido {0} se completar\u00e1 manualmente. Despu\u00e9s ya no podr\u00e1 ser procesado a trav\u00e9s de la aplicaci\u00f3n. +jobsummary.dialog.manualcomplete.reason=Motivo +jobsummary.dialog.manualcomplete.reason.required=Por favor, introduzca un motivo +jobsummary.dialog.manualcomplete.cancel=Cancelar +jobsummary.dialog.manualcomplete.confirm=Aceptar +jobsummary.history.manualcomplete.reason=Finalizado manualmente # Jobs jobs.title=Pedidos diff --git a/backend/src/main/resources/messages_fr.properties b/backend/src/main/resources/messages_fr.properties index d8ed262..4d467e6 100644 --- a/backend/src/main/resources/messages_fr.properties +++ b/backend/src/main/resources/messages_fr.properties @@ -610,6 +610,14 @@ jobsummary.task.photo.taken=Photos prises ({0}) jobsummary.task.button.text=Texte du bouton jobsummary.button.schliessen=Fermer jobsummary.route.planned=Itin\u00e9raire pr\u00e9vu +jobsummary.button.manualcomplete=Terminer manuellement +jobsummary.dialog.manualcomplete.title=Terminer la commande manuellement +jobsummary.dialog.manualcomplete.text=La commande {0} va maintenant \u00eatre termin\u00e9e manuellement. Elle ne pourra plus \u00eatre trait\u00e9e via l\u2019application par la suite. +jobsummary.dialog.manualcomplete.reason=Motif +jobsummary.dialog.manualcomplete.reason.required=Veuillez saisir un motif +jobsummary.dialog.manualcomplete.cancel=Annuler +jobsummary.dialog.manualcomplete.confirm=Accepter +jobsummary.history.manualcomplete.reason=Termin\u00e9 manuellement # Jobs jobs.title=Missions diff --git a/backend/src/main/resources/messages_lt.properties b/backend/src/main/resources/messages_lt.properties index add31f1..e8a3f6a 100644 --- a/backend/src/main/resources/messages_lt.properties +++ b/backend/src/main/resources/messages_lt.properties @@ -610,6 +610,14 @@ jobsummary.task.photo.taken=Padarytos nuotraukos ({0}) jobsummary.task.button.text=Mygtuko tekstas jobsummary.button.schliessen=Uždaryti jobsummary.route.planned=Planuotas maršrutas +jobsummary.button.manualcomplete=Užbaigti rankiniu būdu +jobsummary.dialog.manualcomplete.title=Užbaigti užsakymą rankiniu būdu +jobsummary.dialog.manualcomplete.text=Užsakymas {0} dabar bus užbaigtas rankiniu būdu. Po to jo nebebus galima apdoroti per programėlę. +jobsummary.dialog.manualcomplete.reason=Priežastis +jobsummary.dialog.manualcomplete.reason.required=Prašome įvesti priežastį +jobsummary.dialog.manualcomplete.cancel=Atšaukti +jobsummary.dialog.manualcomplete.confirm=Priimti +jobsummary.history.manualcomplete.reason=Užbaigta rankiniu būdu # Jobs jobs.title=Užsakymai diff --git a/backend/src/main/resources/messages_lv.properties b/backend/src/main/resources/messages_lv.properties index 01e93a5..044a722 100644 --- a/backend/src/main/resources/messages_lv.properties +++ b/backend/src/main/resources/messages_lv.properties @@ -610,6 +610,14 @@ jobsummary.task.photo.taken=Uzņemtās fotogrāfijas ({0}) jobsummary.task.button.text=Pogas teksts jobsummary.button.schliessen=Aizvērt jobsummary.route.planned=Plānotais maršruts +jobsummary.button.manualcomplete=Pabeigt manuāli +jobsummary.dialog.manualcomplete.title=Pabeigt pasūtījumu manuāli +jobsummary.dialog.manualcomplete.text=Pasūtījums {0} tagad tiks pabeigts manuāli. Pēc tam to vairs nevarēs apstrādāt, izmantojot lietotni. +jobsummary.dialog.manualcomplete.reason=Pamatojums +jobsummary.dialog.manualcomplete.reason.required=Lūdzu, ievadiet pamatojumu +jobsummary.dialog.manualcomplete.cancel=Atcelt +jobsummary.dialog.manualcomplete.confirm=Apstiprināt +jobsummary.history.manualcomplete.reason=Pabeigts manuāli # Jobs jobs.title=Uzdevumi diff --git a/backend/src/main/resources/messages_pl.properties b/backend/src/main/resources/messages_pl.properties index ba185ba..f70b98f 100644 --- a/backend/src/main/resources/messages_pl.properties +++ b/backend/src/main/resources/messages_pl.properties @@ -610,6 +610,14 @@ jobsummary.task.photo.taken=Wykonane zdj\u0119cia ({0}) jobsummary.task.button.text=Tekst przycisku jobsummary.button.schliessen=Zamknij jobsummary.route.planned=Planowana trasa +jobsummary.button.manualcomplete=Zako\u0144cz r\u0119cznie +jobsummary.dialog.manualcomplete.title=Zako\u0144cz zlecenie r\u0119cznie +jobsummary.dialog.manualcomplete.text=Zlecenie {0} zostanie teraz zako\u0144czone r\u0119cznie. Po tym nie b\u0119dzie mo\u017cna go dalej obs\u0142ugiwa\u0107 przez aplikacj\u0119. +jobsummary.dialog.manualcomplete.reason=Uzasadnienie +jobsummary.dialog.manualcomplete.reason.required=Prosz\u0119 poda\u0107 uzasadnienie +jobsummary.dialog.manualcomplete.cancel=Anuluj +jobsummary.dialog.manualcomplete.confirm=Akceptuj +jobsummary.history.manualcomplete.reason=Zako\u0144czono r\u0119cznie # Jobs jobs.title=Zlecenia diff --git a/backend/src/main/resources/messages_ru.properties b/backend/src/main/resources/messages_ru.properties index 78ec4a7..bca4a0d 100644 --- a/backend/src/main/resources/messages_ru.properties +++ b/backend/src/main/resources/messages_ru.properties @@ -610,6 +610,14 @@ jobsummary.task.photo.taken=Сделанные фотографии ({0}) jobsummary.task.button.text=Текст кнопки jobsummary.button.schliessen=Закрыть jobsummary.route.planned=Запланированный маршрут +jobsummary.button.manualcomplete=Завершить вручную +jobsummary.dialog.manualcomplete.title=Завершить заказ вручную +jobsummary.dialog.manualcomplete.text=Заказ {0} будет завершён вручную. После этого его больше нельзя будет обрабатывать через приложение. +jobsummary.dialog.manualcomplete.reason=Обоснование +jobsummary.dialog.manualcomplete.reason.required=Пожалуйста, укажите обоснование +jobsummary.dialog.manualcomplete.cancel=Отмена +jobsummary.dialog.manualcomplete.confirm=Принять +jobsummary.history.manualcomplete.reason=Завершено вручную # Jobs jobs.title=Заказы diff --git a/backend/src/main/resources/messages_tr.properties b/backend/src/main/resources/messages_tr.properties index 2c9cd79..a88fd12 100644 --- a/backend/src/main/resources/messages_tr.properties +++ b/backend/src/main/resources/messages_tr.properties @@ -610,6 +610,14 @@ jobsummary.task.photo.taken=\u00c7ekilen Foto\u011fraflar ({0}) jobsummary.task.button.text=Buton Metni jobsummary.button.schliessen=Kapat jobsummary.route.planned=Planlanan Rota +jobsummary.button.manualcomplete=Manuel olarak tamamla +jobsummary.dialog.manualcomplete.title=Siparişi manuel olarak tamamla +jobsummary.dialog.manualcomplete.text=Sipariş {0} şimdi manuel olarak tamamlanacak. Bundan sonra uygulama üzerinden işlenemez. +jobsummary.dialog.manualcomplete.reason=Gerekçe +jobsummary.dialog.manualcomplete.reason.required=Lütfen bir gerekçe girin +jobsummary.dialog.manualcomplete.cancel=İptal +jobsummary.dialog.manualcomplete.confirm=Kabul et +jobsummary.history.manualcomplete.reason=Manuel olarak tamamlandı # Jobs jobs.title=\u0130\u015fler diff --git a/backend/src/test/java/de/assecutor/votianlt/service/EmailServiceTest.java b/backend/src/test/java/de/assecutor/votianlt/service/EmailServiceTest.java new file mode 100644 index 0000000..32a88ca --- /dev/null +++ b/backend/src/test/java/de/assecutor/votianlt/service/EmailServiceTest.java @@ -0,0 +1,134 @@ +package de.assecutor.votianlt.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import de.assecutor.votianlt.model.AppUser; +import de.assecutor.votianlt.model.Job; +import de.assecutor.votianlt.model.User; +import de.assecutor.votianlt.model.task.ConfirmationTask; +import de.assecutor.votianlt.repository.AppUserRepository; +import de.assecutor.votianlt.repository.JobRepository; +import de.assecutor.votianlt.repository.TaskRepository; +import de.assecutor.votianlt.repository.UserRepository; +import java.util.List; +import java.util.Optional; +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.Captor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mail.SimpleMailMessage; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class EmailServiceTest { + + @Mock + private UserRepository userRepository; + @Mock + private JobRepository jobRepository; + @Mock + private TaskRepository taskRepository; + @Mock + private AppUserRepository appUserRepository; + @Mock + private JavaMailSender mailSender; + + @Captor + private ArgumentCaptor mailCaptor; + + private EmailService emailService; + private TaskAssignmentService taskAssignmentService; + + @BeforeEach + void setUp() { + taskAssignmentService = new TaskAssignmentService(taskRepository, jobRepository); + emailService = new EmailService(userRepository, jobRepository, taskAssignmentService, appUserRepository, + mailSender); + ReflectionTestUtils.setField(emailService, "smtpUsername", "noreply@example.com"); + } + + @Test + void sendTaskCompletionNotificationUsesAssignedAppUserName() { + User webUser = createWebUser(); + AppUser assignedAppUser = createAssignedAppUser(); + Job job = createJob(webUser, assignedAppUser); + + when(jobRepository.findById(job.getId())).thenReturn(Optional.of(job)); + when(userRepository.findById(webUser.getId())).thenReturn(Optional.of(webUser)); + when(appUserRepository.findById(assignedAppUser.getId())).thenReturn(Optional.of(assignedAppUser)); + + emailService.sendTaskCompletionNotification(job.getId(), "PHOTO", "task-1", "ignored"); + + verify(mailSender).send(mailCaptor.capture()); + String body = mailCaptor.getValue().getText(); + assertThat(body).contains("eine Aufgabe wurde von Max Mustermann abgeschlossen:"); + assertThat(body).contains("Abgeschlossen von: Max Mustermann"); + assertThat(body).contains("Hallo Dr. Anna Unternehmer,"); + } + + @Test + void sendJobCompletionNotificationUsesAssignedAppUserName() { + User webUser = createWebUser(); + AppUser assignedAppUser = createAssignedAppUser(); + Job job = createJob(webUser, assignedAppUser); + + when(jobRepository.findById(job.getId())).thenReturn(Optional.of(job)); + when(userRepository.findById(webUser.getId())).thenReturn(Optional.of(webUser)); + when(appUserRepository.findById(assignedAppUser.getId())).thenReturn(Optional.of(assignedAppUser)); + when(taskRepository.findByJobIdOrderByTaskOrderAsc(job.getId())).thenReturn(List.of( + createConfirmationTask(0, "OK"), + createConfirmationTask(1, "Weiter"))); + + emailService.sendJobCompletionNotification(job.getId(), "ignored"); + + verify(mailSender).send(mailCaptor.capture()); + String body = mailCaptor.getValue().getText(); + assertThat(body).contains("Anzahl erledigter Aufgaben: 2"); + assertThat(body).contains("Abgeschlossen von: Max Mustermann"); + assertThat(body).contains("Hallo Dr. Anna Unternehmer,"); + } + + private User createWebUser() { + User user = new User(); + user.setId(new ObjectId()); + user.setTitle("Dr."); + user.setFirstname("Anna"); + user.setName("Unternehmer"); + user.setEmail("anna@example.com"); + return user; + } + + private AppUser createAssignedAppUser() { + AppUser appUser = new AppUser(); + appUser.setId(new ObjectId()); + appUser.setVorname("Max"); + appUser.setNachname("Mustermann"); + appUser.setBezeichnung("Fahrer Max"); + return appUser; + } + + private Job createJob(User webUser, AppUser assignedAppUser) { + Job job = new Job(); + job.setId(new ObjectId()); + job.setCreatedBy(webUser.getId().toHexString()); + job.setAppUser(assignedAppUser.getId().toHexString()); + job.setJobNumber("JOB-2026-001"); + job.setDeliveryCompany("Beispiel GmbH"); + job.setPickupCity("Berlin"); + job.setDeliveryCity("Hamburg"); + return job; + } + + private ConfirmationTask createConfirmationTask(int taskOrder, String buttonText) { + ConfirmationTask task = new ConfirmationTask(buttonText); + task.setTaskOrder(taskOrder); + return task; + } +}