From c39b4f8b523eddd10735d2c56eaf0648a30ab4b1 Mon Sep 17 00:00:00 2001 From: Sven Carstensen Date: Tue, 10 Mar 2026 09:25:12 +0100 Subject: [PATCH] feat: refresh job summary after task updates - broadcast job updates after mobile task completions are persisted\n- rerender job_summary live for the affected job via UI access\n- show pickup and delivery tile detail lines like on add_job\n- highlight delivery tiles in light green when all station tasks are completed --- .../controller/MessageController.java | 11 +- .../votianlt/pages/view/JobSummaryView.java | 241 ++++++++++++++++-- .../service/JobUpdateBroadcaster.java | 44 ++++ 3 files changed, 274 insertions(+), 22 deletions(-) create mode 100644 src/main/java/de/assecutor/votianlt/service/JobUpdateBroadcaster.java diff --git a/src/main/java/de/assecutor/votianlt/controller/MessageController.java b/src/main/java/de/assecutor/votianlt/controller/MessageController.java index 47cce95..512cf0b 100644 --- a/src/main/java/de/assecutor/votianlt/controller/MessageController.java +++ b/src/main/java/de/assecutor/votianlt/controller/MessageController.java @@ -22,6 +22,7 @@ import de.assecutor.votianlt.model.Barcode; import de.assecutor.votianlt.model.Signature; import de.assecutor.votianlt.model.Comment; import de.assecutor.votianlt.service.JobHistoryService; +import de.assecutor.votianlt.service.JobUpdateBroadcaster; import de.assecutor.votianlt.service.EmailService; import de.assecutor.votianlt.service.MessageService; import de.assecutor.votianlt.model.JobStatus; @@ -59,6 +60,7 @@ public class MessageController { private final SignatureRepository signatureRepository; private final CommentRepository commentRepository; private final JobHistoryService jobHistoryService; + private final JobUpdateBroadcaster jobUpdateBroadcaster; private final EmailService emailService; private final MessageService messageService; @@ -66,7 +68,8 @@ public class MessageController { AppUserService appUserService, JobRepository jobRepository, CargoItemRepository cargoItemRepository, TaskRepository taskRepository, PhotoRepository photoRepository, BarcodeRepository barcodeRepository, SignatureRepository signatureRepository, CommentRepository commentRepository, - JobHistoryService jobHistoryService, EmailService emailService, MessageService messageService) { + JobHistoryService jobHistoryService, JobUpdateBroadcaster jobUpdateBroadcaster, EmailService emailService, + MessageService messageService) { this.messagingPublisher = messagingPublisher; this.appUserRepository = appUserRepository; this.appUserService = appUserService; @@ -78,6 +81,7 @@ public class MessageController { this.signatureRepository = signatureRepository; this.commentRepository = commentRepository; this.jobHistoryService = jobHistoryService; + this.jobUpdateBroadcaster = jobUpdateBroadcaster; this.emailService = emailService; this.messageService = messageService; } @@ -345,10 +349,10 @@ public class MessageController { task.setCompleted(true); task.setCompletedAt(LocalDateTime.now()); taskRepository.save(task); + ObjectId jobId = new ObjectId(task.getJobIdAsString()); // Log detailed task completion in job history try { - ObjectId jobId = new ObjectId(task.getJobIdAsString()); String taskType = task.getTaskType() != null ? task.getTaskType().toString() : "Unknown"; String taskDisplayName = task.getDisplayName() != null ? task.getDisplayName() : taskType; String completedBy = task.getCompletedBy() != null ? task.getCompletedBy() : "Unknown"; @@ -360,7 +364,6 @@ public class MessageController { // Send email notification for task completion try { - ObjectId jobId = new ObjectId(task.getJobIdAsString()); String taskType = task.getTaskType() != null ? task.getTaskType().toString() : "Unknown"; String completedBy = task.getCompletedBy() != null ? task.getCompletedBy() : "Unknown"; emailService.sendTaskCompletionNotification(jobId, taskType, taskIdStr, completedBy); @@ -368,6 +371,8 @@ public class MessageController { } catch (Exception e) { // Ignore email notification errors } + + jobUpdateBroadcaster.broadcast(jobId); } catch (Exception ex) { log.error("[TASK] Completion error: {}", ex.getMessage()); } diff --git a/src/main/java/de/assecutor/votianlt/pages/view/JobSummaryView.java b/src/main/java/de/assecutor/votianlt/pages/view/JobSummaryView.java index 26bd4a6..9b177a8 100644 --- a/src/main/java/de/assecutor/votianlt/pages/view/JobSummaryView.java +++ b/src/main/java/de/assecutor/votianlt/pages/view/JobSummaryView.java @@ -14,10 +14,14 @@ 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.AttachEvent; +import com.vaadin.flow.component.DetachEvent; +import com.vaadin.flow.component.UI; import com.vaadin.flow.router.BeforeEvent; import com.vaadin.flow.router.HasUrlParameter; import com.vaadin.flow.router.HasDynamicTitle; import com.vaadin.flow.router.Route; +import com.vaadin.flow.shared.Registration; import com.vaadin.flow.theme.lumo.LumoUtility; import de.assecutor.votianlt.model.CargoItem; import de.assecutor.votianlt.model.DeliveryStation; @@ -51,6 +55,7 @@ import de.assecutor.votianlt.model.Comment; import de.assecutor.votianlt.model.JobStatus; import de.assecutor.votianlt.pages.service.AppUserService; import de.assecutor.votianlt.service.JobHistoryService; +import de.assecutor.votianlt.service.JobUpdateBroadcaster; import de.assecutor.votianlt.service.LocationService; import de.assecutor.votianlt.service.MessageService; import de.assecutor.votianlt.util.DateTimeFormatUtil; @@ -80,6 +85,7 @@ public class JobSummaryView extends Main implements HasUrlParameter, Has private final CommentRepository commentRepository; private final AppUserService appUserService; private final JobHistoryService jobHistoryService; + private final JobUpdateBroadcaster jobUpdateBroadcaster; private final LocationService locationService; private final ServiceRepository serviceRepository; @@ -88,11 +94,14 @@ public class JobSummaryView extends Main implements HasUrlParameter, Has private final VerticalLayout content; private final List
taskCards = new ArrayList<>(); + private Registration jobUpdateRegistration; + private ObjectId currentJobId; public JobSummaryView(JobRepository jobRepository, CargoItemRepository cargoItemRepository, TaskRepository taskRepository, SignatureRepository signatureRepository, BarcodeRepository barcodeRepository, PhotoRepository photoRepository, CommentRepository commentRepository, AppUserService appUserService, - MessageService messageService, JobHistoryService jobHistoryService, LocationService locationService, + MessageService messageService, JobHistoryService jobHistoryService, + JobUpdateBroadcaster jobUpdateBroadcaster, LocationService locationService, ServiceRepository serviceRepository) { this.jobRepository = jobRepository; this.cargoItemRepository = cargoItemRepository; @@ -103,6 +112,7 @@ public class JobSummaryView extends Main implements HasUrlParameter, Has this.commentRepository = commentRepository; this.appUserService = appUserService; this.jobHistoryService = jobHistoryService; + this.jobUpdateBroadcaster = jobUpdateBroadcaster; this.locationService = locationService; this.serviceRepository = serviceRepository; @@ -118,30 +128,62 @@ public class JobSummaryView extends Main implements HasUrlParameter, Has @Override public void setParameter(BeforeEvent event, String parameter) { - content.removeAll(); - removeAll(); // Remove existing toolbar - if (parameter == null || parameter.isBlank()) { + content.removeAll(); + removeAll(); add(new ViewToolbar("Zusammenfassung")); content.add(new Span("Fehler: Keine Job-ID angegeben")); add(content); return; } - ObjectId jobId; try { - jobId = new ObjectId(parameter); + currentJobId = new ObjectId(parameter); } catch (Exception e) { + content.removeAll(); + removeAll(); add(new ViewToolbar("Zusammenfassung")); content.add(new Span("Fehler: Ungültige Job-ID Format: " + parameter)); add(content); return; } - Job job = jobRepository.findById(jobId).orElse(null); + refreshCurrentJobSummary(); + } + + @Override + protected void onAttach(AttachEvent attachEvent) { + super.onAttach(attachEvent); + UI ui = attachEvent.getUI(); + jobUpdateRegistration = jobUpdateBroadcaster.register(jobId -> { + if (currentJobId == null || jobId == null || !currentJobId.equals(jobId)) { + return; + } + ui.access(this::refreshCurrentJobSummary); + }); + } + + @Override + protected void onDetach(DetachEvent detachEvent) { + if (jobUpdateRegistration != null) { + jobUpdateRegistration.remove(); + jobUpdateRegistration = null; + } + super.onDetach(detachEvent); + } + + private void refreshCurrentJobSummary() { + if (currentJobId == null) { + return; + } + + content.removeAll(); + removeAll(); + + Job job = jobRepository.findById(currentJobId).orElse(null); if (job == null) { add(new ViewToolbar("Zusammenfassung")); - content.add(new Span("Fehler: Job mit ID " + parameter + " nicht gefunden")); + content.add(new Span("Fehler: Job mit ID " + currentJobId.toHexString() + " nicht gefunden")); add(content); return; } @@ -177,8 +219,8 @@ public class JobSummaryView extends Main implements HasUrlParameter, Has // Add toolbar with both buttons in top right (Send Message button on the left) add(new ViewToolbar("Zusammenfassung", sendMessageButton, jobHistoryButton)); - List cargo = cargoItemRepository.findByJobId(jobId); - List tasks = taskRepository.findByJobIdOrderByTaskOrderAsc(jobId); + List cargo = cargoItemRepository.findByJobId(currentJobId); + List tasks = taskRepository.findByJobIdOrderByTaskOrderAsc(currentJobId); render(job, cargo, tasks); add(content); @@ -187,7 +229,7 @@ public class JobSummaryView extends Main implements HasUrlParameter, Has private void render(Job job, List cargoItems, List tasks) { content.removeAll(); - content.add(createStationTilesSection(job, tasks)); + content.add(createStationTilesSection(job, cargoItems, tasks)); // Fracht und weitere Infos HorizontalLayout midRow = new HorizontalLayout(); @@ -305,14 +347,14 @@ public class JobSummaryView extends Main implements HasUrlParameter, Has return box; } - private Div createStationTilesSection(Job job, List tasks) { + private Div createStationTilesSection(Job job, List cargoItems, List tasks) { Div stationGrid = new Div(); stationGrid.getStyle().set("display", "grid"); stationGrid.getStyle().set("grid-template-columns", "repeat(auto-fit, minmax(220px, 1fr))"); stationGrid.getStyle().set("gap", "var(--lumo-space-m)"); stationGrid.setWidthFull(); - stationGrid.add(createPickupSummaryTile(job)); + stationGrid.add(createPickupSummaryTile(job, cargoItems)); List stations = job.getDeliveryStations(); if (stations != null && !stations.isEmpty()) { @@ -326,10 +368,10 @@ public class JobSummaryView extends Main implements HasUrlParameter, Has return stationGrid; } - private StationTile createPickupSummaryTile(Job job) { + private StationTile createPickupSummaryTile(Job job, List cargoItems) { String title = getTranslation("jobsummary.section.pickup") + " " + formatDateWithTime(job.getPickupDate(), job.getPickupTime()); - List additionalLines = new ArrayList<>(); + List additionalLines = buildPickupSummaryDetails(job, cargoItems); if (job.getPickupPhone() != null && !job.getPickupPhone().isBlank()) { additionalLines.add(getTranslation("jobsummary.station.phone") + ": " + job.getPickupPhone()); } @@ -345,7 +387,8 @@ public class JobSummaryView extends Main implements HasUrlParameter, Has String title = getTranslation("jobsummary.section.delivery") + " " + (stationCount > 1 ? (index + 1) + " " : "") + formatDateWithTime(station.getDeliveryDate(), station.getDeliveryTime()); - List additionalLines = new ArrayList<>(); + List stationTasks = getTasksForStation(station, tasks, false); + List additionalLines = buildDeliverySummaryDetails(stationTasks); if (station.getPhone() != null && !station.getPhone().isBlank()) { additionalLines.add(getTranslation("jobsummary.station.phone") + ": " + station.getPhone()); } @@ -353,7 +396,7 @@ public class JobSummaryView extends Main implements HasUrlParameter, Has StationTile tile = createSummaryTile(StationTile.StationType.DELIVERY, index + 1, title, station.getCompany(), buildDisplayName(station.getSalutation(), station.getFirstName(), station.getLastName()), station.getStreet(), station.getHouseNumber(), station.getZip(), station.getCity(), additionalLines); - List stationTasks = getTasksForStation(station, tasks, false); + tile.setAddressValidated(areAllTasksCompleted(stationTasks)); tile.setInteractive(true); tile.setClickListener(clickedTile -> showStationTasksDialog(title, stationTasks)); return tile; @@ -362,7 +405,8 @@ public class JobSummaryView extends Main implements HasUrlParameter, Has private StationTile createLegacyDeliverySummaryTile(Job job, List tasks) { String title = getTranslation("jobsummary.section.delivery") + " " + formatDateWithTime(job.getDeliveryDate(), job.getDeliveryTime()); - List additionalLines = new ArrayList<>(); + List stationTasks = getTasksForStation(null, tasks, true); + List additionalLines = buildDeliverySummaryDetails(stationTasks); if (job.getDeliveryPhone() != null && !job.getDeliveryPhone().isBlank()) { additionalLines.add(getTranslation("jobsummary.station.phone") + ": " + job.getDeliveryPhone()); } @@ -371,7 +415,7 @@ public class JobSummaryView extends Main implements HasUrlParameter, Has buildDisplayName(job.getDeliverySalutation(), job.getDeliveryFirstName(), job.getDeliveryLastName()), job.getDeliveryStreet(), job.getDeliveryHouseNumber(), job.getDeliveryZip(), job.getDeliveryCity(), additionalLines); - List stationTasks = getTasksForStation(null, tasks, true); + tile.setAddressValidated(areAllTasksCompleted(stationTasks)); tile.setInteractive(true); tile.setClickListener(clickedTile -> showStationTasksDialog(title, stationTasks)); return tile; @@ -386,6 +430,161 @@ public class JobSummaryView extends Main implements HasUrlParameter, Has return tile; } + private List buildPickupSummaryDetails(Job job, List cargoItems) { + List additionalLines = new ArrayList<>(); + additionalLines.add(getTranslation("addjob.tab.cargo") + ": " + summarizeCargoItems(cargoItems)); + + String pickupAppointment = formatPickupAppointment(job.getPickupDate(), job.getPickupTime()); + if (pickupAppointment != null) { + additionalLines.add(getTranslation("addjob.appointment.pickup") + ": " + pickupAppointment); + } + + additionalLines.add(buildDigitalProcessingPreview(job.isDigitalProcessing(), job.getAppUser())); + return additionalLines; + } + + private String summarizeCargoItems(List cargoItems) { + if (cargoItems == null || cargoItems.isEmpty()) { + return getTranslation("jobsummary.cargo.none"); + } + + List summaries = new ArrayList<>(); + for (CargoItem cargoItem : cargoItems) { + if (cargoItem == null) { + continue; + } + + String description = cargoItem.getDescription() != null ? cargoItem.getDescription().trim() : ""; + Integer quantity = cargoItem.getQuantity(); + if (description.isEmpty() && quantity == null) { + continue; + } + + StringBuilder summary = new StringBuilder(); + if (quantity != null) { + summary.append(quantity).append("x "); + } + summary.append(description.isEmpty() ? getTranslation("addjob.tab.cargo") : description); + summaries.add(summary.toString().trim()); + } + + if (summaries.isEmpty()) { + return getTranslation("jobsummary.cargo.none"); + } + if (summaries.size() <= 2) { + return String.join(", ", summaries); + } + return String.join(", ", summaries.subList(0, 2)) + " +" + (summaries.size() - 2); + } + + private String formatPickupAppointment(java.time.LocalDate appointmentDate, java.time.LocalTime appointmentTime) { + if (appointmentDate == null) { + return null; + } + + String formattedDate = formatLocalDate(appointmentDate); + if (appointmentTime == null) { + return formattedDate; + } + return formattedDate + " " + formatLocalTime(appointmentTime); + } + + private String buildDigitalProcessingPreview(boolean digitalProcessingEnabled, String appUserId) { + StringBuilder preview = new StringBuilder(); + preview.append(getTranslation("profile.settings.digitalprocess")).append(": ") + .append(getTranslation(digitalProcessingEnabled ? "common.yes" : "common.no")); + + if (digitalProcessingEnabled && appUserId != null && !appUserId.isBlank()) { + preview.append(" (").append(resolveAppUserName(appUserId)).append(")"); + } + return preview.toString(); + } + + private List buildDeliverySummaryDetails(List tasks) { + if (tasks == null || tasks.isEmpty()) { + return List.of(getTranslation("addjob.tab.tasks") + ": " + getTranslation("jobsummary.tasks.none")); + } + + List summaries = new ArrayList<>(); + for (BaseTask task : tasks) { + if (task != null) { + summaries.add(summarizeDeliveryTask(task)); + } + } + + if (summaries.isEmpty()) { + return List.of(getTranslation("addjob.tab.tasks") + ": " + getTranslation("jobsummary.tasks.none")); + } + return summaries; + } + + private String summarizeDeliveryTask(BaseTask task) { + if (task instanceof ConfirmationTask confirmationTask) { + String buttonText = trimToNull(confirmationTask.getButtonText()); + if (buttonText != null) { + return confirmationTask.getDisplayName() + " \"" + buttonText + "\""; + } + + String description = trimToNull(confirmationTask.getDescription()); + return description != null ? confirmationTask.getDisplayName() + " \"" + description + "\"" + : confirmationTask.getDisplayName(); + } + + if (task instanceof TodoListTask todoListTask) { + long itemCount = todoListTask.getTodoItems() == null ? 0 + : todoListTask.getTodoItems().stream().filter(item -> item != null && !item.trim().isEmpty()) + .count(); + return itemCount > 0 ? task.getDisplayName() + " (" + itemCount + ")" : task.getDisplayName(); + } + + if (task instanceof PhotoTask photoTask) { + String range = formatMinMaxRange(photoTask.getMinPhotoCount(), photoTask.getMaxPhotoCount()); + return range.isBlank() ? task.getDisplayName() : task.getDisplayName() + " " + range; + } + + if (task instanceof BarcodeTask barcodeTask) { + String range = formatMinMaxRange(barcodeTask.getMinBarcodeCount(), barcodeTask.getMaxBarcodeCount()); + return range.isBlank() ? task.getDisplayName() : task.getDisplayName() + " " + range; + } + + if (task instanceof CommentTask commentTask) { + String commentText = trimToNull(commentTask.getCommentText()); + if (commentText != null) { + return task.getDisplayName() + " \"" + commentText + "\""; + } + return commentTask.isRequired() ? task.getDisplayName() + " (" + getTranslation("common.required") + ")" + : task.getDisplayName(); + } + + if (task instanceof SignatureTask) { + return task.getDisplayName(); + } + + String description = trimToNull(task.getDescription()); + return description != null ? task.getDisplayName() + " \"" + description + "\"" : task.getDisplayName(); + } + + private String formatMinMaxRange(Integer minValue, Integer maxValue) { + if (minValue != null && maxValue != null) { + return minValue.equals(maxValue) ? String.valueOf(minValue) : minValue + "-" + maxValue; + } + if (minValue != null) { + return String.valueOf(minValue); + } + if (maxValue != null) { + return String.valueOf(maxValue); + } + return ""; + } + + private String trimToNull(String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + private String buildDisplayName(String salutation, String firstName, String lastName) { List parts = new ArrayList<>(); if (salutation != null && !salutation.isBlank()) { @@ -432,6 +631,10 @@ public class JobSummaryView extends Main implements HasUrlParameter, Has return stationTasks; } + private boolean areAllTasksCompleted(List tasks) { + return tasks != null && !tasks.isEmpty() && tasks.stream().allMatch(task -> task != null && task.isCompleted()); + } + private void showStationTasksDialog(String stationTitle, List tasks) { Dialog dialog = new Dialog(); dialog.setWidth("720px"); diff --git a/src/main/java/de/assecutor/votianlt/service/JobUpdateBroadcaster.java b/src/main/java/de/assecutor/votianlt/service/JobUpdateBroadcaster.java new file mode 100644 index 0000000..cde2174 --- /dev/null +++ b/src/main/java/de/assecutor/votianlt/service/JobUpdateBroadcaster.java @@ -0,0 +1,44 @@ +package de.assecutor.votianlt.service; + +import com.vaadin.flow.shared.Registration; +import lombok.extern.slf4j.Slf4j; +import org.bson.types.ObjectId; +import org.springframework.stereotype.Service; + +import java.util.LinkedHashSet; +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; +import java.util.function.Consumer; + +@Service +@Slf4j +public class JobUpdateBroadcaster { + + private final Executor executor = Executors.newSingleThreadExecutor(); + private final LinkedHashSet> listeners = new LinkedHashSet<>(); + + public synchronized Registration register(Consumer listener) { + listeners.add(listener); + return () -> { + synchronized (JobUpdateBroadcaster.this) { + listeners.remove(listener); + } + }; + } + + public synchronized void broadcast(ObjectId jobId) { + if (jobId == null) { + return; + } + + for (Consumer listener : listeners) { + executor.execute(() -> { + try { + listener.accept(jobId); + } catch (Exception e) { + log.error("Error broadcasting job update for {}", jobId.toHexString(), e); + } + }); + } + } +}