diff --git a/src/main/java/de/assecutor/votianlt/messaging/MessagingPublisher.java b/src/main/java/de/assecutor/votianlt/messaging/MessagingPublisher.java index 3e49d70..4009db5 100644 --- a/src/main/java/de/assecutor/votianlt/messaging/MessagingPublisher.java +++ b/src/main/java/de/assecutor/votianlt/messaging/MessagingPublisher.java @@ -1,23 +1,10 @@ package de.assecutor.votianlt.messaging; import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.node.ArrayNode; -import com.fasterxml.jackson.databind.node.ObjectNode; -import de.assecutor.votianlt.dto.JobWithRelatedDataDTO; -import de.assecutor.votianlt.model.CargoItem; -import de.assecutor.votianlt.model.task.BaseTask; -import de.assecutor.votianlt.model.task.ConfirmationTask; -import de.assecutor.votianlt.model.task.TodoListTask; -import de.assecutor.votianlt.service.TranslationService; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import java.nio.charset.StandardCharsets; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; - -// Force recompile /** * Publishing helper to send JSON payloads to clients via WebSocket. @@ -32,13 +19,10 @@ class MessagingPublisherImpl implements MessagingPublisher { private final WebSocketService webSocketService; private final ObjectMapper objectMapper; - private final TranslationService translationService; - public MessagingPublisherImpl(WebSocketService webSocketService, ObjectMapper objectMapper, - TranslationService translationService) { + public MessagingPublisherImpl(WebSocketService webSocketService, ObjectMapper objectMapper) { this.webSocketService = webSocketService; this.objectMapper = objectMapper; - this.translationService = translationService; } @Override @@ -53,10 +37,7 @@ class MessagingPublisherImpl implements MessagingPublisher { return; } - // Verarbeite Payload und füge Übersetzungen hinzu wenn nötig - Object processedPayload = processPayloadWithTranslations(payload); - - String json = objectMapper.writeValueAsString(processedPayload); + String json = objectMapper.writeValueAsString(payload); byte[] data = json.getBytes(StandardCharsets.UTF_8); webSocketService.sendToClient(clientId, messageType, data).thenRun(() -> { @@ -70,184 +51,4 @@ class MessagingPublisherImpl implements MessagingPublisher { log.error("[Messaging] Failed to publish to {}/{}: {}", clientId, messageType, e.getMessage(), e); } } - - /** - * Collects all translatable texts from the payload, fetches all translations in - * one batch (at most one LLM call), then applies them to the JSON tree. - */ - private Object processPayloadWithTranslations(Object payload) { - try { - if (payload instanceof JobWithRelatedDataDTO dto) { - List texts = collectTexts(dto); - Map> translations = translationService - .translateBatch(texts); - return convertToTranslatedJson(dto, translations); - } - - if (payload instanceof List list && !list.isEmpty() && list.get(0) instanceof JobWithRelatedDataDTO) { - @SuppressWarnings("unchecked") - List dtoList = (List) list; - - // Collect all texts from all DTOs and translate in one batch - List allTexts = dtoList.stream().flatMap(d -> collectTexts(d).stream()).distinct().toList(); - Map> translations = translationService - .translateBatch(allTexts); - - return dtoList.stream().map(d -> convertToTranslatedJson(d, translations)).toList(); - } - - return payload; - } catch (Exception e) { - log.warn("[Messaging] Failed to process translations: {}", e.getMessage()); - return payload; - } - } - - /** - * Collects all non-blank translatable strings from a DTO. - */ - private List collectTexts(JobWithRelatedDataDTO dto) { - List texts = new ArrayList<>(); - - if (dto.getJob() != null && isNonBlank(dto.getJob().getRemark())) { - texts.add(dto.getJob().getRemark()); - } - - if (dto.getTasks() != null) { - for (BaseTask task : dto.getTasks()) { - if (isNonBlank(task.getDescription())) { - texts.add(task.getDescription()); - } - if (isNonBlank(task.getDisplayName())) { - texts.add(task.getDisplayName()); - } - if (task instanceof ConfirmationTask ct && isNonBlank(ct.getButtonText())) { - texts.add(ct.getButtonText()); - } - if (task instanceof TodoListTask tlt && tlt.getTodoItems() != null) { - for (String item : tlt.getTodoItems()) { - if (isNonBlank(item)) { - texts.add(item); - } - } - } - } - } - - if (dto.getCargoItems() != null) { - for (CargoItem item : dto.getCargoItems()) { - if (isNonBlank(item.getDescription())) { - texts.add(item.getDescription()); - } - } - } - - return texts; - } - - /** - * Converts a DTO to a JSON tree and replaces translatable string fields with - * translation arrays, using the pre-fetched translation map. - */ - private ObjectNode convertToTranslatedJson(JobWithRelatedDataDTO dto, - Map> translations) { - - ObjectNode root = objectMapper.valueToTree(dto); - - // Job remark - if (dto.getJob() != null && isNonBlank(dto.getJob().getRemark())) { - List t = translations.get(dto.getJob().getRemark()); - if (t != null) { - root.withObject("job").set("remark", createTranslationArray(t)); - } - } - - // Tasks - if (dto.getTasks() != null && !dto.getTasks().isEmpty()) { - ArrayNode tasksNode = root.withArray("tasks"); - for (int i = 0; i < dto.getTasks().size(); i++) { - BaseTask task = dto.getTasks().get(i); - ObjectNode taskNode = (ObjectNode) tasksNode.get(i); - - if (isNonBlank(task.getDescription())) { - List t = translations.get(task.getDescription()); - if (t != null) { - taskNode.set("description", createTranslationArray(t)); - } - } - - if (isNonBlank(task.getDisplayName())) { - List t = translations.get(task.getDisplayName()); - if (t != null) { - taskNode.set("displayName", createTranslationArray(t)); - } - } - - if (task instanceof ConfirmationTask ct && isNonBlank(ct.getButtonText())) { - List t = translations.get(ct.getButtonText()); - if (t != null) { - taskNode.set("buttonText", createTranslationArray(t)); - if (taskNode.has("taskSpecificData")) { - ObjectNode tsd = (ObjectNode) taskNode.get("taskSpecificData"); - if (tsd.has("buttonText")) { - tsd.set("buttonText", createTranslationArray(t)); - } - } - } - } - - if (task instanceof TodoListTask tlt && tlt.getTodoItems() != null - && taskNode.has("taskSpecificData")) { - ObjectNode tsd = (ObjectNode) taskNode.get("taskSpecificData"); - if (tsd.has("todoItems")) { - ArrayNode translatedItems = objectMapper.createArrayNode(); - for (String item : tlt.getTodoItems()) { - if (isNonBlank(item)) { - List t = translations.get(item); - translatedItems.add(t != null ? createTranslationArray(t) - : objectMapper.createArrayNode().add(item)); - } - } - tsd.set("todoItems", translatedItems); - } - } - } - } - - // Cargo items - if (dto.getCargoItems() != null && !dto.getCargoItems().isEmpty()) { - ArrayNode cargoItemsNode = root.withArray("cargoItems"); - for (int i = 0; i < dto.getCargoItems().size(); i++) { - CargoItem item = dto.getCargoItems().get(i); - ObjectNode itemNode = (ObjectNode) cargoItemsNode.get(i); - - if (isNonBlank(item.getDescription())) { - List t = translations.get(item.getDescription()); - if (t != null) { - itemNode.set("description", createTranslationArray(t)); - } - } - } - } - - return root; - } - - /** - * Creates a JSON array from translations. - */ - private ArrayNode createTranslationArray(List translations) { - ArrayNode array = objectMapper.createArrayNode(); - for (TranslationService.Translation t : translations) { - ObjectNode node = objectMapper.createObjectNode(); - node.put("language", t.language()); - node.put("text", t.text()); - array.add(node); - } - return array; - } - - private static boolean isNonBlank(String s) { - return s != null && !s.isBlank(); - } } diff --git a/src/main/java/de/assecutor/votianlt/model/DeliveryStation.java b/src/main/java/de/assecutor/votianlt/model/DeliveryStation.java index 938a65d..0ee6cb5 100644 --- a/src/main/java/de/assecutor/votianlt/model/DeliveryStation.java +++ b/src/main/java/de/assecutor/votianlt/model/DeliveryStation.java @@ -1,5 +1,6 @@ package de.assecutor.votianlt.model; +import de.assecutor.votianlt.model.task.BaseTask; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; @@ -7,6 +8,8 @@ import org.springframework.data.mongodb.core.mapping.Field; import java.time.LocalDate; import java.time.LocalTime; +import java.util.ArrayList; +import java.util.List; /** * Embedded delivery station within a Job. Each job can have up to 25 delivery @@ -56,4 +59,7 @@ public class DeliveryStation { @Field("delivery_time") private LocalTime deliveryTime; + + @Field("tasks") + private List tasks = new ArrayList<>(); } diff --git a/src/main/java/de/assecutor/votianlt/pages/base/ui/component/DeliveryStationDialog.java b/src/main/java/de/assecutor/votianlt/pages/base/ui/component/DeliveryStationDialog.java index 3039d8e..a57331b 100644 --- a/src/main/java/de/assecutor/votianlt/pages/base/ui/component/DeliveryStationDialog.java +++ b/src/main/java/de/assecutor/votianlt/pages/base/ui/component/DeliveryStationDialog.java @@ -54,6 +54,7 @@ public class DeliveryStationDialog extends Dialog { private boolean saveAddress; private List tasks = new ArrayList<>(); private boolean addressValidatedByGoogle; + private AddressValidationResult addressValidationResult; public boolean isAddressValidatedByGoogle() { return addressValidatedByGoogle; @@ -63,6 +64,14 @@ public class DeliveryStationDialog extends Dialog { this.addressValidatedByGoogle = addressValidatedByGoogle; } + public AddressValidationResult getAddressValidationResult() { + return addressValidationResult; + } + + public void setAddressValidationResult(AddressValidationResult addressValidationResult) { + this.addressValidationResult = addressValidationResult; + } + public String getCompany() { return company; } @@ -191,6 +200,7 @@ public class DeliveryStationDialog extends Dialog { private final DeliveryStationTile.TranslationHelper translationHelper; private final AddressValidationService addressValidationService; + private final java.util.Map companyAddressOptions = new java.util.LinkedHashMap<>(); public DeliveryStationDialog(String dialogTitle, List customers, DeliveryStationTile.TranslationHelper translationHelper, SaveListener saveListener, @@ -359,6 +369,7 @@ public class DeliveryStationDialog extends Dialog { if (validationResult.isValid()) { data.setAddressValidatedByGoogle(true); + data.setAddressValidationResult(validationResult); if (saveListener != null) { saveListener.onSave(data); } @@ -378,6 +389,7 @@ public class DeliveryStationDialog extends Dialog { translationHelper.getTranslation("addjob.validation.address.correct")); confirmDialog.addConfirmListener(ev -> { data.setAddressValidatedByGoogle(false); + data.setAddressValidationResult(validationResult); if (saveListener != null) { saveListener.onSave(data); } @@ -406,8 +418,12 @@ public class DeliveryStationDialog extends Dialog { public void setData(DeliveryData data) { if (data == null) return; - if (data.getCompany() != null) + String companyOption = findCompanyOptionLabel(data); + if (companyOption != null) { + company.setValue(companyOption); + } else if (data.getCompany() != null) { company.setValue(data.getCompany()); + } if (data.getSalutation() != null) salutation.setValue(data.getSalutation()); if (data.getFirstName() != null) @@ -448,7 +464,7 @@ public class DeliveryStationDialog extends Dialog { private DeliveryData collectData() { DeliveryData data = new DeliveryData(); - data.setCompany(company.getValue()); + data.setCompany(resolveCompanyValue(company.getValue())); data.setSalutation(salutation.getValue()); data.setFirstName(firstName.getValue()); data.setLastName(lastName.getValue()); @@ -527,49 +543,126 @@ public class DeliveryStationDialog extends Dialog { } private void setupCompanyAutocomplete(ComboBox companyField, List customers) { - List companyNames = customers.stream().map(Customer::getCompanyName) - .filter(name -> name != null && !name.trim().isEmpty()).distinct().sorted().toList(); + companyAddressOptions.clear(); + for (Customer customer : customers) { + String label = buildCompanyAddressLabel(customer); + if (label == null) { + continue; + } - companyField.setItems(companyNames); + String uniqueLabel = label; + int counter = 2; + while (companyAddressOptions.containsKey(uniqueLabel)) { + uniqueLabel = label + " (" + counter++ + ")"; + } + companyAddressOptions.put(uniqueLabel, customer); + } + + companyField.setItems(new ArrayList<>(companyAddressOptions.keySet())); companyField.addValueChangeListener(event -> { - String selectedCompany = event.getValue(); - if (selectedCompany == null || selectedCompany.trim().isEmpty()) { + Customer customer = companyAddressOptions.get(event.getValue()); + if (customer == null) { return; } - Optional matchingCustomer = customers.stream() - .filter(c -> selectedCompany.equals(c.getCompanyName())).findFirst(); - - if (matchingCustomer.isPresent()) { - Customer customer = matchingCustomer.get(); - if (customer.getTitle() != null - && ("Herr".equalsIgnoreCase(customer.getTitle()) || "Frau".equalsIgnoreCase(customer.getTitle()) - || "Divers".equalsIgnoreCase(customer.getTitle()))) { - salutation.setValue(customer.getTitle()); - } - if (customer.getFirstname() != null) - firstName.setValue(customer.getFirstname()); - if (customer.getLastName() != null) - lastName.setValue(customer.getLastName()); - if (customer.getTelephone() != null) - phone.setValue(customer.getTelephone()); - if (customer.getStreet() != null) - street.setValue(customer.getStreet()); - if (customer.getHouseNumber() != null) - houseNumber.setValue(customer.getHouseNumber()); - if (customer.getAddressAddition() != null) - addressAddition.setValue(customer.getAddressAddition()); - if (customer.getZip() != null) - zip.setValue(customer.getZip()); - if (customer.getCity() != null) - city.setValue(customer.getCity()); + if (customer.getTitle() != null + && ("Herr".equalsIgnoreCase(customer.getTitle()) || "Frau".equalsIgnoreCase(customer.getTitle()) + || "Divers".equalsIgnoreCase(customer.getTitle()))) { + salutation.setValue(customer.getTitle()); } + if (customer.getFirstname() != null) + firstName.setValue(customer.getFirstname()); + if (customer.getLastName() != null) + lastName.setValue(customer.getLastName()); + if (customer.getTelephone() != null) + phone.setValue(customer.getTelephone()); + if (customer.getStreet() != null) + street.setValue(customer.getStreet()); + if (customer.getHouseNumber() != null) + houseNumber.setValue(customer.getHouseNumber()); + if (customer.getAddressAddition() != null) + addressAddition.setValue(customer.getAddressAddition()); + if (customer.getZip() != null) + zip.setValue(customer.getZip()); + if (customer.getCity() != null) + city.setValue(customer.getCity()); }); companyField.addCustomValueSetListener(event -> companyField.setValue(event.getDetail())); } + private String buildCompanyAddressLabel(Customer customer) { + if (customer == null) { + return null; + } + + List leftParts = new ArrayList<>(); + if (customer.getCompanyName() != null && !customer.getCompanyName().isBlank()) { + leftParts.add(customer.getCompanyName().trim()); + } + + String fullName = ((customer.getFirstname() != null ? customer.getFirstname() : "") + " " + + (customer.getLastName() != null ? customer.getLastName() : "")).trim(); + if (!fullName.isBlank()) { + leftParts.add(fullName); + } + + List rightParts = new ArrayList<>(); + String streetLine = ((customer.getStreet() != null ? customer.getStreet() : "") + " " + + (customer.getHouseNumber() != null ? customer.getHouseNumber() : "")).trim(); + if (!streetLine.isBlank()) { + rightParts.add(streetLine); + } + + String cityLine = ((customer.getZip() != null ? customer.getZip() : "") + " " + + (customer.getCity() != null ? customer.getCity() : "")).trim(); + if (!cityLine.isBlank()) { + rightParts.add(cityLine); + } + + String left = String.join(" | ", leftParts); + String right = String.join(", ", rightParts); + String label = left; + if (!right.isBlank()) { + label = label.isBlank() ? right : left + " | " + right; + } + + return label.isBlank() ? null : label; + } + + private String resolveCompanyValue(String comboValue) { + Customer customer = companyAddressOptions.get(comboValue); + if (customer != null && customer.getCompanyName() != null && !customer.getCompanyName().isBlank()) { + return customer.getCompanyName(); + } + return comboValue; + } + + private String findCompanyOptionLabel(DeliveryData data) { + for (java.util.Map.Entry entry : companyAddressOptions.entrySet()) { + Customer customer = entry.getValue(); + if (matchesCustomer(customer, data)) { + return entry.getKey(); + } + } + return null; + } + + private boolean matchesCustomer(Customer customer, DeliveryData data) { + return equalsNormalized(customer.getCompanyName(), data.getCompany()) + && equalsNormalized(customer.getStreet(), data.getStreet()) + && equalsNormalized(customer.getHouseNumber(), data.getHouseNumber()) + && equalsNormalized(customer.getZip(), data.getZip()) + && equalsNormalized(customer.getCity(), data.getCity()); + } + + private boolean equalsNormalized(String left, String right) { + String normalizedLeft = left != null ? left.trim() : ""; + String normalizedRight = right != null ? right.trim() : ""; + return normalizedLeft.equalsIgnoreCase(normalizedRight); + } + // ============================================ // Task Management // ============================================ diff --git a/src/main/java/de/assecutor/votianlt/pages/base/ui/component/PickupStationDialog.java b/src/main/java/de/assecutor/votianlt/pages/base/ui/component/PickupStationDialog.java index 8f2ff50..663f459 100644 --- a/src/main/java/de/assecutor/votianlt/pages/base/ui/component/PickupStationDialog.java +++ b/src/main/java/de/assecutor/votianlt/pages/base/ui/component/PickupStationDialog.java @@ -66,6 +66,7 @@ public class PickupStationDialog extends Dialog { private AppUser appUser; private List cargoItems = new ArrayList<>(); private boolean addressValidatedByGoogle; + private AddressValidationResult addressValidationResult; public boolean isAddressValidatedByGoogle() { return addressValidatedByGoogle; @@ -75,6 +76,14 @@ public class PickupStationDialog extends Dialog { this.addressValidatedByGoogle = addressValidatedByGoogle; } + public AddressValidationResult getAddressValidationResult() { + return addressValidationResult; + } + + public void setAddressValidationResult(AddressValidationResult addressValidationResult) { + this.addressValidationResult = addressValidationResult; + } + public String getCompany() { return company; } @@ -485,6 +494,7 @@ public class PickupStationDialog extends Dialog { if (validationResult.isValid()) { data.setAddressValidatedByGoogle(true); + data.setAddressValidationResult(validationResult); if (saveListener != null) { saveListener.onSave(data); } @@ -504,6 +514,7 @@ public class PickupStationDialog extends Dialog { translationHelper.getTranslation("addjob.validation.address.correct")); confirmDialog.addConfirmListener(ev -> { data.setAddressValidatedByGoogle(false); + data.setAddressValidationResult(validationResult); if (saveListener != null) { saveListener.onSave(data); } diff --git a/src/main/java/de/assecutor/votianlt/pages/base/ui/component/StationTile.java b/src/main/java/de/assecutor/votianlt/pages/base/ui/component/StationTile.java index cb45290..ea157af 100644 --- a/src/main/java/de/assecutor/votianlt/pages/base/ui/component/StationTile.java +++ b/src/main/java/de/assecutor/votianlt/pages/base/ui/component/StationTile.java @@ -9,6 +9,7 @@ import com.vaadin.flow.component.icon.VaadinIcon; import com.vaadin.flow.component.orderedlayout.FlexComponent; import com.vaadin.flow.component.orderedlayout.HorizontalLayout; import com.vaadin.flow.component.orderedlayout.VerticalLayout; +import java.util.List; /** * A compact tile representing a station (pickup or delivery) in a grid layout. @@ -40,7 +41,7 @@ public class StationTile extends VerticalLayout { this.type = type; this.stationNumber = stationNumber; - setPadding(true); + setPadding(false); setSpacing(false); getStyle().set("border", "1px solid var(--lumo-contrast-20pct)"); getStyle().set("border-radius", "var(--lumo-border-radius-m)"); @@ -48,6 +49,7 @@ public class StationTile extends VerticalLayout { getStyle().set("cursor", "pointer"); getStyle().set("aspect-ratio", "1 / 1"); getStyle().set("overflow", "hidden"); + getStyle().set("padding", "var(--lumo-space-m)"); // Header with title and optional delete button title = new H3(titleText); @@ -82,7 +84,8 @@ public class StationTile extends VerticalLayout { previewContent = new VerticalLayout(); previewContent.setPadding(false); previewContent.setSpacing(false); - previewContent.getStyle().set("gap", "var(--lumo-space-xs)"); + previewContent.getStyle().set("gap", "0.15rem"); + previewContent.getStyle().set("margin-top", "10px"); previewContent.getStyle().set("flex-grow", "1"); add(previewContent); @@ -99,6 +102,11 @@ public class StationTile extends VerticalLayout { public void updatePreview(String company, String firstName, String lastName, String street, String houseNumber, String zip, String city) { + updatePreview(company, firstName, lastName, street, houseNumber, zip, city, List.of()); + } + + public void updatePreview(String company, String firstName, String lastName, String street, String houseNumber, + String zip, String city, List additionalLines) { previewContent.removeAll(); previewContent.setJustifyContentMode(FlexComponent.JustifyContentMode.START); previewContent.setDefaultHorizontalComponentAlignment(FlexComponent.Alignment.START); @@ -128,6 +136,15 @@ public class StationTile extends VerticalLayout { hasData = true; } + if (additionalLines != null) { + for (String line : additionalLines) { + if (line != null && !line.trim().isEmpty()) { + addPreviewLine(line); + hasData = true; + } + } + } + if (!hasData) { updateEmptyPreview(); } @@ -144,8 +161,8 @@ public class StationTile extends VerticalLayout { private void addPreviewLine(String text) { Span span = new Span(text); - span.getStyle().set("font-size", "var(--lumo-font-size-s)").set("word-break", "break-word").set("color", - "var(--lumo-secondary-text-color)"); + span.getStyle().set("font-size", "var(--lumo-font-size-xs)").set("line-height", "1.2").set("word-break", + "break-word").set("color", "var(--lumo-secondary-text-color)"); previewContent.add(span); } @@ -173,6 +190,10 @@ public class StationTile extends VerticalLayout { this.deleteListener = listener; } + public void setInteractive(boolean interactive) { + getStyle().set("cursor", interactive ? "pointer" : "default"); + } + public void setAddressValidated(boolean validated) { if (validated) { getStyle().set("background-color", "rgba(76, 175, 80, 0.15)"); diff --git a/src/main/java/de/assecutor/votianlt/pages/base/ui/view/MainLayout.java b/src/main/java/de/assecutor/votianlt/pages/base/ui/view/MainLayout.java index ec2a2a9..ee4d14b 100644 --- a/src/main/java/de/assecutor/votianlt/pages/base/ui/view/MainLayout.java +++ b/src/main/java/de/assecutor/votianlt/pages/base/ui/view/MainLayout.java @@ -24,9 +24,7 @@ import com.vaadin.flow.router.Layout; import com.vaadin.flow.server.auth.AnonymousAllowed; import com.vaadin.flow.shared.Registration; import de.assecutor.votianlt.model.User; -import de.assecutor.votianlt.model.UserInvoiceData; import de.assecutor.votianlt.pages.service.AppUserService; -import de.assecutor.votianlt.pages.service.UserInvoiceDataService; import de.assecutor.votianlt.pages.view.EditProfileView; import de.assecutor.votianlt.model.Language; import de.assecutor.votianlt.security.SecurityService; @@ -46,7 +44,6 @@ import java.util.Objects; public final class MainLayout extends AppLayout { private final SecurityService securityService; - private final UserInvoiceDataService userInvoiceDataService; private final MessageService messageService; private final MessageBadgeUpdateService messageBadgeUpdateService; private final AppUserService appUserService; @@ -57,11 +54,9 @@ public final class MainLayout extends AppLayout { private MenuTreeItem messagesTreeItem; private Registration badgeUpdateRegistration; - public MainLayout(SecurityService securityService, UserInvoiceDataService userInvoiceDataService, - MessageService messageService, MessageBadgeUpdateService messageBadgeUpdateService, - AppUserService appUserService) { + public MainLayout(SecurityService securityService, MessageService messageService, + MessageBadgeUpdateService messageBadgeUpdateService, AppUserService appUserService) { this.securityService = securityService; - this.userInvoiceDataService = userInvoiceDataService; this.messageService = messageService; this.messageBadgeUpdateService = messageBadgeUpdateService; this.appUserService = appUserService; @@ -141,12 +136,6 @@ public final class MainLayout extends AppLayout { treeData.addItem(verwaltungItem, new MenuTreeItem(getTranslation("nav.statistics"), "statistics", VaadinIcon.BAR_CHART)); - // Add invoices only if billing is enabled - if (isBillingEnabledForCurrentUser()) { - treeData.addItem(verwaltungItem, - new MenuTreeItem(getTranslation("nav.invoices"), "invoices", VaadinIcon.FILE_TEXT)); - } - // Add children to "Benutzer" treeData.addItem(benutzerItem, new MenuTreeItem(getTranslation("nav.profile"), "edit-profile", VaadinIcon.USER)); @@ -338,20 +327,6 @@ public final class MainLayout extends AppLayout { return userMenu; } - private boolean isBillingEnabledForCurrentUser() { - try { - User currentUser = securityService.getCurrentDatabaseUser(); - if (currentUser != null && currentUser.getId() != null) { - UserInvoiceData invoiceData = userInvoiceDataService.findByUserId(currentUser.getId()).orElse(null); - return invoiceData != null && invoiceData.isBillingEnabled(); - } - } catch (Exception e) { - // Log error or handle appropriately - // Return false as safe default if we can't determine billing status - } - return false; - } - @Override protected void onAttach(AttachEvent attachEvent) { super.onAttach(attachEvent); diff --git a/src/main/java/de/assecutor/votianlt/pages/service/AddJobService.java b/src/main/java/de/assecutor/votianlt/pages/service/AddJobService.java index ea1ff5c..8af1f3b 100644 --- a/src/main/java/de/assecutor/votianlt/pages/service/AddJobService.java +++ b/src/main/java/de/assecutor/votianlt/pages/service/AddJobService.java @@ -2,6 +2,7 @@ package de.assecutor.votianlt.pages.service; import de.assecutor.votianlt.dto.JobWithRelatedDataDTO; import de.assecutor.votianlt.model.CargoItem; +import de.assecutor.votianlt.model.DeliveryStation; import de.assecutor.votianlt.model.Job; import de.assecutor.votianlt.model.JobStatus; import de.assecutor.votianlt.model.task.BaseTask; @@ -21,7 +22,11 @@ import org.springframework.stereotype.Service; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.Optional; @Service @@ -89,18 +94,30 @@ public class AddJobService { .filter(task -> task.getTaskType() != null) // Filter nach TaskType statt Text .toList(); - // Setze JobId und stelle sicher, dass taskOrder korrekt ist - for (int i = 0; i < filteredTasks.size(); i++) { - BaseTask task = filteredTasks.get(i); + Map taskOrderByStation = new HashMap<>(); + + // Setze JobId und stelle sicher, dass taskOrder je Lieferstation korrekt ist + for (BaseTask task : filteredTasks) { task.setJobId(jobId); - // Verwende die bereits gesetzte taskOrder oder setze sie auf den Index - if (task.getTaskOrder() == null || task.getTaskOrder() != i) { - task.setTaskOrder(i); // Stelle sicher, dass die Reihenfolge stimmt + int stationOrder = task.getStationOrder() != null ? task.getStationOrder() : 0; + if (task.getTaskOrder() == null) { + int nextTaskOrder = taskOrderByStation.getOrDefault(stationOrder, 0); + task.setTaskOrder(nextTaskOrder); + taskOrderByStation.put(stationOrder, nextTaskOrder + 1); + } else { + int nextTaskOrder = Math.max(taskOrderByStation.getOrDefault(stationOrder, 0), + task.getTaskOrder() + 1); + taskOrderByStation.put(stationOrder, nextTaskOrder); } } taskRepository.saveAll(filteredTasks); + attachTasksToDeliveryStations(savedJob, filteredTasks); + savedJob = jobRepository.save(savedJob); log.info("Saved {} tasks for job {} with ordering", filteredTasks.size(), jobId); + } else if (savedJob.getDeliveryStations() != null && !savedJob.getDeliveryStations().isEmpty()) { + attachTasksToDeliveryStations(savedJob, List.of()); + savedJob = jobRepository.save(savedJob); } if (modified) { @@ -215,4 +232,26 @@ public class AddJobService { log.warn("[JOB] Failed to send job_created notification: {}", e.getMessage()); } } + + private void attachTasksToDeliveryStations(Job job, List tasks) { + if (job.getDeliveryStations() == null || job.getDeliveryStations().isEmpty()) { + return; + } + + Map> tasksByStation = new HashMap<>(); + for (BaseTask task : tasks) { + if (task == null) { + continue; + } + int stationOrder = task.getStationOrder() != null ? task.getStationOrder() : 0; + tasksByStation.computeIfAbsent(stationOrder, ignored -> new ArrayList<>()).add(task); + } + + for (DeliveryStation station : job.getDeliveryStations()) { + int stationOrder = station.getStationOrder(); + List stationTasks = new ArrayList<>(tasksByStation.getOrDefault(stationOrder, List.of())); + stationTasks.sort(Comparator.comparing(task -> task.getTaskOrder() != null ? task.getTaskOrder() : 0)); + station.setTasks(stationTasks); + } + } } diff --git a/src/main/java/de/assecutor/votianlt/pages/service/AddressValidationService.java b/src/main/java/de/assecutor/votianlt/pages/service/AddressValidationService.java index db8ced8..2851585 100644 --- a/src/main/java/de/assecutor/votianlt/pages/service/AddressValidationService.java +++ b/src/main/java/de/assecutor/votianlt/pages/service/AddressValidationService.java @@ -15,6 +15,8 @@ import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.nio.charset.StandardCharsets; import java.time.Duration; +import java.util.List; +import java.util.Locale; /** * Service zur Validierung von Adressen über die Google Geocoding API. @@ -118,11 +120,12 @@ public class AddressValidationService { double lat = location.path("lat").asDouble(); double lng = location.path("lng").asDouble(); - // Prüfen, ob die Adresse als "ROOFTOP" (genaue Adresse) oder - // "RANGE_INTERPOLATED" gefunden wurde + // Google liefert für valide Adressen nicht immer nur ROOFTOP/RANGE_INTERPOLATED. + // Für unseren Flow reicht ein erfolgreicher Geocoding-Treffer mit Koordinaten. String locationType = geometry.path("location_type").asText(); - boolean isPrecise = "ROOFTOP".equals(locationType) || "RANGE_INTERPOLATED".equals(locationType); + boolean hasCoordinates = location.hasNonNull("lat") && location.hasNonNull("lng"); + boolean hasStreetNumber = false; boolean hasPostalCode = false; JsonNode addressComponents = firstResult.path("address_components"); @@ -131,6 +134,7 @@ public class AddressValidationService { for (JsonNode type : types) { String typeStr = type.asText(); if ("street_number".equals(typeStr)) { + hasStreetNumber = true; } else if ("postal_code".equals(typeStr)) { hasPostalCode = true; } @@ -138,19 +142,22 @@ public class AddressValidationService { } // Ergebnis setzen - result.setValid(isPrecise && hasPostalCode); + result.setValid(hasCoordinates); result.setFormattedAddress(formattedAddress); result.setLatitude(lat); result.setLongitude(lng); if (result.isValid()) { result.setValidationMessage("Adresse erfolgreich validiert"); + log.debug( + "Adressvalidierung erfolgreich: {} -> {} (locationType={}, streetNumber={}, postalCode={})", + addressString, formattedAddress, locationType, hasStreetNumber, hasPostalCode); } else { - result.setValidationMessage("Adresse ungenau gefunden (keine Hausnummer oder Postleitzahl)"); + result.setValidationMessage("Adresse gefunden, aber ohne verwertbare Koordinaten"); + log.warn("Adressvalidierung unvollständig: {} -> {} (locationType={}, streetNumber={}, postalCode={})", + addressString, formattedAddress, locationType, hasStreetNumber, hasPostalCode); } - log.debug("Adressvalidierung erfolgreich: {} -> {}", addressString, formattedAddress); - } catch (Exception e) { log.error("Fehler bei der Adressvalidierung", e); result.setValidationMessage("Fehler: " + e.getMessage()); @@ -191,6 +198,17 @@ public class AddressValidationService { */ public RouteCalculationResult calculateRoute(AddressValidationResult pickupResult, AddressValidationResult deliveryResult) { + return calculateRoute(List.of(pickupResult, deliveryResult)); + } + + /** + * Berechnet die schnellste Route über mehrere validierte Stationen. + * + * @param stationResults + * validierte Stationen in Fahrreihenfolge + * @return RouteCalculationResult mit Gesamtentfernung und Gesamtdauer + */ + public RouteCalculationResult calculateRoute(List stationResults) { RouteCalculationResult routeResult = new RouteCalculationResult(); // Prüfen, ob API-Key konfiguriert ist @@ -200,26 +218,41 @@ public class AddressValidationService { return routeResult; } - // Prüfen, ob beide Adressen gültige Koordinaten haben - if (pickupResult == null || !pickupResult.isValid() || deliveryResult == null || !deliveryResult.isValid()) { - routeResult.setRouteMessage("Beide Adressen müssen validiert sein"); + if (stationResults == null || stationResults.size() < 2) { + routeResult.setRouteMessage("Mindestens zwei validierte Stationen werden benötigt"); + return routeResult; + } + + // Prüfen, ob alle Adressen gültige Koordinaten haben + if (stationResults.stream().anyMatch(result -> result == null || !result.isValid())) { + routeResult.setRouteMessage("Alle Stationen müssen validiert sein"); return routeResult; } try { + AddressValidationResult originResult = stationResults.getFirst(); + AddressValidationResult destinationResult = stationResults.getLast(); + // Koordinaten für Start und Ziel - String origin = String.format("%s,%s", pickupResult.getLatitude(), pickupResult.getLongitude()); - String destination = String.format("%s,%s", deliveryResult.getLatitude(), deliveryResult.getLongitude()); + String origin = formatLatLng(originResult); + String destination = formatLatLng(destinationResult); // URL für die Directions API erstellen - String requestUrl = String.format("%s?origin=%s&destination=%s&mode=driving&key=%s&language=de®ion=de", - DIRECTIONS_API_URL, origin, destination, googleMapsApiKey); + StringBuilder requestUrl = new StringBuilder(String.format( + "%s?origin=%s&destination=%s&mode=driving&key=%s&language=de®ion=de", DIRECTIONS_API_URL, + origin, destination, googleMapsApiKey)); - log.debug("Berechne Route von {} nach {}", pickupResult.getFormattedAddress(), - deliveryResult.getFormattedAddress()); + if (stationResults.size() > 2) { + List waypoints = stationResults.subList(1, stationResults.size() - 1).stream() + .map(this::formatLatLng).toList(); + requestUrl.append("&waypoints=").append(String.join("|", waypoints)); + } + + log.debug("Berechne Route über {} Stationen von {} nach {}", stationResults.size(), + originResult.getFormattedAddress(), destinationResult.getFormattedAddress()); // HTTP Request senden - HttpRequest request = HttpRequest.newBuilder().uri(URI.create(requestUrl)).GET() + HttpRequest request = HttpRequest.newBuilder().uri(URI.create(requestUrl.toString())).GET() .timeout(Duration.ofSeconds(10)).build(); HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); @@ -254,27 +287,26 @@ public class AddressValidationService { return routeResult; } - // Ersten Leg (Hauptstrecke) verwenden - JsonNode firstLeg = legs.get(0); + int totalDistanceMeters = 0; + int totalDurationSeconds = 0; - // Distanz extrahieren - JsonNode distanceNode = firstLeg.path("distance"); - int distanceMeters = distanceNode.path("value").asInt(); - String distanceText = distanceNode.path("text").asText(); + for (JsonNode leg : legs) { + totalDistanceMeters += leg.path("distance").path("value").asInt(); + totalDurationSeconds += leg.path("duration").path("value").asInt(); + } - // Dauer extrahieren - JsonNode durationNode = firstLeg.path("duration"); - int durationSeconds = durationNode.path("value").asInt(); - String durationText = durationNode.path("text").asText(); + double totalDistanceKm = totalDistanceMeters / 1000.0; + String distanceText = String.format(Locale.GERMANY, "%.1f km", totalDistanceKm); + String durationText = formatDuration(totalDurationSeconds); // Ergebnis setzen routeResult.setValid(true); - routeResult.setDistanceKm(distanceMeters / 1000.0); - routeResult.setDurationSeconds(durationSeconds); + routeResult.setDistanceKm(totalDistanceKm); + routeResult.setDurationSeconds(totalDurationSeconds); routeResult.setFormattedDistance(distanceText); routeResult.setFormattedDuration(durationText); routeResult.setRouteMessage( - String.format("Route: %s, Dauer: %s", distanceText, routeResult.getFormattedDurationLong())); + String.format("Route: %s, Dauer: %s", distanceText, durationText)); log.debug("Routenberechnung erfolgreich: {} km, {} Min.", routeResult.getDistanceKm(), routeResult.getDurationMinutes()); @@ -286,4 +318,18 @@ public class AddressValidationService { return routeResult; } + + private String formatLatLng(AddressValidationResult result) { + return String.format(Locale.US, "%s,%s", result.getLatitude(), result.getLongitude()); + } + + private String formatDuration(int durationSeconds) { + int hours = durationSeconds / 3600; + int minutes = (durationSeconds % 3600) / 60; + + if (hours > 0) { + return String.format("%d Std. %d Min.", hours, minutes); + } + return String.format("%d Min.", minutes); + } } diff --git a/src/main/java/de/assecutor/votianlt/pages/view/AddJobView.java b/src/main/java/de/assecutor/votianlt/pages/view/AddJobView.java index 6604f77..63a2711 100644 --- a/src/main/java/de/assecutor/votianlt/pages/view/AddJobView.java +++ b/src/main/java/de/assecutor/votianlt/pages/view/AddJobView.java @@ -6,6 +6,7 @@ import com.vaadin.flow.component.checkbox.Checkbox; import com.vaadin.flow.component.combobox.ComboBox; import com.vaadin.flow.component.confirmdialog.ConfirmDialog; import com.vaadin.flow.component.dialog.Dialog; +import com.vaadin.flow.component.UI; import com.vaadin.flow.component.datepicker.DatePicker; import com.vaadin.flow.component.html.Div; @@ -20,6 +21,7 @@ import com.vaadin.flow.component.notification.Notification; import com.vaadin.flow.component.orderedlayout.FlexComponent; import com.vaadin.flow.component.orderedlayout.HorizontalLayout; import com.vaadin.flow.component.orderedlayout.VerticalLayout; +import com.vaadin.flow.component.progressbar.ProgressBar; import com.vaadin.flow.component.textfield.TextField; import com.vaadin.flow.data.binder.Binder; import com.vaadin.flow.component.Component; @@ -30,8 +32,12 @@ import com.vaadin.flow.theme.lumo.LumoUtility; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import de.assecutor.votianlt.model.Job; +import de.assecutor.votianlt.model.task.BarcodeTask; import de.assecutor.votianlt.model.task.BaseTask; +import de.assecutor.votianlt.model.task.CommentTask; import de.assecutor.votianlt.model.task.ConfirmationTask; +import de.assecutor.votianlt.model.task.PhotoTask; +import de.assecutor.votianlt.model.task.SignatureTask; import de.assecutor.votianlt.model.task.TodoListTask; import de.assecutor.votianlt.pages.service.AddJobService; import de.assecutor.votianlt.pages.service.CustomerService; @@ -61,14 +67,19 @@ import de.assecutor.votianlt.pages.base.ui.component.StationTile; import de.assecutor.votianlt.pages.base.ui.component.PickupStationDialog; import de.assecutor.votianlt.pages.base.ui.component.DeliveryStationDialog; import java.time.LocalDate; +import java.time.LocalTime; +import java.time.format.DateTimeFormatter; import java.util.*; import java.util.Objects; import java.util.Optional; +import java.util.concurrent.CompletableFuture; @Route(value = "add_job", layout = de.assecutor.votianlt.pages.base.ui.view.MainLayout.class) @RolesAllowed("USER") @Slf4j public class AddJobView extends Main implements HasDynamicTitle { + private static final DateTimeFormatter PICKUP_PREVIEW_DATE_FORMATTER = DateTimeFormatter.ofPattern("dd.MM.yyyy"); + private static final DateTimeFormatter PICKUP_PREVIEW_TIME_FORMATTER = DateTimeFormatter.ofPattern("HH:mm"); @Override public String getPageTitle() { @@ -160,6 +171,8 @@ public class AddJobView extends Main implements HasDynamicTitle { // Adressvalidierung private final Map addressValidationResults = new HashMap<>(); private RouteCalculationResult routeCalculationResult; + private boolean pickupAddressValidatedByGoogle; + private final List deliveryStationsValidatedByGoogle = new ArrayList<>(); public AddJobView(AddJobService addJobService, AddCustomerService addCustomerService, CustomerService customerService, AppUserService appUserService, TaskTemplateService taskTemplateService, @@ -354,11 +367,7 @@ public class AddJobView extends Main implements HasDynamicTitle { applyStationsButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY); applyStationsButton.setWidthFull(); applyStationsButton.setEnabled(false); - applyStationsButton.addClickListener(e -> { - applyStationsButton.setVisible(false); - priceAndDetailsSection.setVisible(true); - submitButtonLayout.setVisible(true); - }); + applyStationsButton.addClickListener(e -> handleApplyStations()); tabContent.add(applyStationsButton); // Route Info Box @@ -631,6 +640,7 @@ public class AddJobView extends Main implements HasDynamicTitle { // Add empty state for this station deliveryStationsState.add(new DeliveryStation()); deliveryStationsSaveAddress.add(true); + deliveryStationsValidatedByGoogle.add(false); int stationIndex = deliveryStationTilesList.size(); tile.setClickListener(t -> openDeliveryDialog(t, stationIndex)); @@ -647,6 +657,7 @@ public class AddJobView extends Main implements HasDynamicTitle { stationsGridContainer.add(addStationButton); } + resetRouteInformation(); resetStationsAppliedState(); triggerValidation(); updateTabLabels(); @@ -670,6 +681,7 @@ public class AddJobView extends Main implements HasDynamicTitle { deliveryStationTilesList.remove(removeIdx); deliveryStationsState.remove(removeIdx); deliveryStationsSaveAddress.remove(removeIdx); + deliveryStationsValidatedByGoogle.remove(removeIdx); deliveryStationTasksState.remove(removeIdx); // Re-index tasks state for remaining stations Map> reindexed = new HashMap<>(); @@ -737,12 +749,8 @@ public class AddJobView extends Main implements HasDynamicTitle { savePickupAddress.setValue(data.isSaveAddress()); // Sync appointment fields for binder/submit - if (data.getAppointmentDate() != null) { - pickupDate.setValue(data.getAppointmentDate()); - } - if (data.getAppointmentTime() != null) { - pickupTime.setValue(data.getAppointmentTime()); - } + pickupDate.setValue(data.getAppointmentDate()); + pickupTime.setValue(data.getAppointmentTime()); digitalProcessing.setValue(data.isDigitalProcessing()); if (data.getAppUser() != null) { appUser.setValue(data.getAppUser()); @@ -758,8 +766,12 @@ public class AddJobView extends Main implements HasDynamicTitle { // Update tile preview pickupTile.updatePreview(data.getCompany(), data.getFirstName(), data.getLastName(), - data.getStreet(), data.getHouseNumber(), data.getZip(), data.getCity()); - pickupTile.setAddressValidated(data.isAddressValidatedByGoogle()); + data.getStreet(), data.getHouseNumber(), data.getZip(), data.getCity(), + buildPickupPreviewDetails(data.getAppointmentDate(), data.getAppointmentTime(), + data.isDigitalProcessing(), data.getAppUser(), data.getCargoItems())); + pickupAddressValidatedByGoogle = data.isAddressValidatedByGoogle(); + storePickupAddressValidationResult(data.getAddressValidationResult()); + pickupTile.setAddressValidated(pickupAddressValidatedByGoogle); resetRouteInformation(); resetStationsAppliedState(); @@ -787,11 +799,191 @@ public class AddJobView extends Main implements HasDynamicTitle { currentData.setDigitalProcessing(digitalProcessing.getValue()); currentData.setAppUser(appUser.getValue()); currentData.setCargoItems(new ArrayList<>(cargoItemsState)); + currentData.setAddressValidatedByGoogle(pickupAddressValidatedByGoogle); dialog.setData(currentData); dialog.open(); } + private List buildPickupPreviewDetails(LocalDate appointmentDate, LocalTime appointmentTime, + boolean digitalProcessingEnabled, AppUser assignedAppUser, List cargoItems) { + List previewDetails = new ArrayList<>(); + + previewDetails.add(getTranslation("addjob.tab.cargo") + ": " + summarizeCargoItems(cargoItems)); + + String appointmentPreview = formatPickupAppointment(appointmentDate, appointmentTime); + if (appointmentPreview != null) { + previewDetails.add(getTranslation("addjob.appointment.pickup") + ": " + appointmentPreview); + } + + previewDetails.add(buildDigitalProcessingPreview(digitalProcessingEnabled, assignedAppUser)); + return previewDetails; + } + + 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(LocalDate appointmentDate, LocalTime appointmentTime) { + if (appointmentDate == null) { + return null; + } + + String formattedDate = appointmentDate.format(PICKUP_PREVIEW_DATE_FORMATTER); + if (appointmentTime == null) { + return formattedDate; + } + return formattedDate + " " + appointmentTime.format(PICKUP_PREVIEW_TIME_FORMATTER); + } + + private String buildDigitalProcessingPreview(boolean digitalProcessingEnabled, AppUser assignedAppUser) { + StringBuilder preview = new StringBuilder(); + preview.append(getTranslation("profile.settings.digitalprocess")).append(": ") + .append(getTranslation(digitalProcessingEnabled ? "common.yes" : "common.no")); + + String appUserLabel = formatAssignedAppUser(assignedAppUser); + if (digitalProcessingEnabled && appUserLabel != null) { + preview.append(" (").append(appUserLabel).append(")"); + } + return preview.toString(); + } + + private String formatAssignedAppUser(AppUser assignedAppUser) { + if (assignedAppUser == null) { + return null; + } + + String fullName = ((assignedAppUser.getVorname() != null ? assignedAppUser.getVorname() : "") + " " + + (assignedAppUser.getNachname() != null ? assignedAppUser.getNachname() : "")).trim(); + if (!fullName.isEmpty()) { + return fullName; + } + if (assignedAppUser.getBezeichnung() != null && !assignedAppUser.getBezeichnung().isBlank()) { + return assignedAppUser.getBezeichnung().trim(); + } + if (assignedAppUser.getEmail() != null && !assignedAppUser.getEmail().isBlank()) { + return assignedAppUser.getEmail().trim(); + } + return null; + } + + private List buildDeliveryPreviewDetails(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(Objects::nonNull).map(String::trim) + .filter(item -> !item.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 void openDeliveryDialog(StationTile tile, int stationIndex) { // Ensure index is valid (could have changed due to deletions) int actualIndex = deliveryStationTilesList.indexOf(tile); @@ -822,6 +1014,7 @@ public class AddJobView extends Main implements HasDynamicTitle { station.setAddressAddition(data.getAddressAddition()); station.setZip(data.getZip()); station.setCity(data.getCity()); + station.setTasks(data.getTasks() != null ? new ArrayList<>(data.getTasks()) : new ArrayList<>()); deliveryStationsSaveAddress.set(idx, data.isSaveAddress()); // Store tasks for this delivery station @@ -829,7 +1022,10 @@ public class AddJobView extends Main implements HasDynamicTitle { // Update tile preview tile.updatePreview(data.getCompany(), data.getFirstName(), data.getLastName(), data.getStreet(), - data.getHouseNumber(), data.getZip(), data.getCity()); + data.getHouseNumber(), data.getZip(), data.getCity(), + buildDeliveryPreviewDetails(data.getTasks())); + deliveryStationsValidatedByGoogle.set(idx, data.isAddressValidatedByGoogle()); + storeDeliveryAddressValidationResult(idx, data.getAddressValidationResult()); tile.setAddressValidated(data.isAddressValidatedByGoogle()); resetRouteInformation(); @@ -863,6 +1059,9 @@ public class AddJobView extends Main implements HasDynamicTitle { currentData.setZip(station.getZip()); currentData.setCity(station.getCity()); currentData.setSaveAddress(deliveryStationsSaveAddress.get(actualIndex)); + if (actualIndex < deliveryStationsValidatedByGoogle.size()) { + currentData.setAddressValidatedByGoogle(deliveryStationsValidatedByGoogle.get(actualIndex)); + } // Load existing tasks for this station List stationTasks = deliveryStationTasksState.get(actualIndex); if (stationTasks != null) { @@ -1369,6 +1568,7 @@ public class AddJobView extends Main implements HasDynamicTitle { for (int i = 0; i < deliveryStationsState.size(); i++) { DeliveryStation station = deliveryStationsState.get(i); station.setStationOrder(i); + station.setTasks(new ArrayList<>(deliveryStationTasksState.getOrDefault(i, List.of()))); stations.add(station); } job.setDeliveryStations(stations); @@ -1697,6 +1897,212 @@ public class AddJobView extends Main implements HasDynamicTitle { return null; } + private void handleApplyStations() { + revealPriceAndDetailsSection(); + + if (!areAllStationsValidatedByGoogle()) { + return; + } + + Dialog loadingDialog = createRouteLoadingDialog(); + UI ui = UI.getCurrent(); + loadingDialog.open(); + + CompletableFuture.supplyAsync(this::calculateRouteAcrossAllStations).whenComplete((routeResult, throwable) -> { + if (ui == null) { + return; + } + ui.access(() -> { + loadingDialog.close(); + + if (throwable != null) { + log.error("Fehler bei der Berechnung der Gesamtstrecke", throwable); + Notification.show("Die Strecke konnte nicht berechnet werden.", 4000, Notification.Position.MIDDLE); + return; + } + + if (routeResult != null && routeResult.isValid()) { + applyCalculatedRoute(routeResult); + showRouteSummaryDialog(routeResult); + } else { + String message = routeResult != null && routeResult.getRouteMessage() != null + ? routeResult.getRouteMessage() + : "Die Strecke konnte nicht berechnet werden."; + Notification.show(message, 4000, Notification.Position.MIDDLE); + } + }); + }); + } + + private void revealPriceAndDetailsSection() { + applyStationsButton.setVisible(false); + priceAndDetailsSection.setVisible(true); + submitButtonLayout.setVisible(true); + } + + private boolean areAllStationsValidatedByGoogle() { + if (!pickupAddressValidatedByGoogle || deliveryStationsState.isEmpty()) { + return false; + } + if (deliveryStationsValidatedByGoogle.size() != deliveryStationsState.size()) { + return false; + } + return deliveryStationsValidatedByGoogle.stream().allMatch(Boolean.TRUE::equals); + } + + private RouteCalculationResult calculateRouteAcrossAllStations() { + if (!pickupAddressValidatedByGoogle) { + return createInvalidRouteResult("Die Abholstation ist nicht validiert."); + } + + List stationResults = new ArrayList<>(); + AddressValidationResult pickupValidation = getOrValidatePickupAddressResult(); + if (pickupValidation == null || !pickupValidation.isValid()) { + return createInvalidRouteResult("Die Abholstation konnte nicht validiert werden."); + } + stationResults.add(pickupValidation); + + for (int i = 0; i < deliveryStationsState.size(); i++) { + DeliveryStation station = deliveryStationsState.get(i); + if (hasDeliveryStationValidationErrors(station)) { + return createInvalidRouteResult("Nicht alle Lieferstationen sind vollständig ausgefüllt."); + } + if (i >= deliveryStationsValidatedByGoogle.size() || !Boolean.TRUE.equals(deliveryStationsValidatedByGoogle.get(i))) { + return createInvalidRouteResult("Nicht alle Lieferstationen sind validiert."); + } + + AddressValidationResult deliveryValidation = getOrValidateDeliveryAddressResult(i); + if (deliveryValidation == null || !deliveryValidation.isValid()) { + return createInvalidRouteResult( + String.format("Die Strecke konnte für Lieferstation %d nicht berechnet werden.", i + 1)); + } + stationResults.add(deliveryValidation); + } + + return addressValidationService.calculateRoute(stationResults); + } + + private AddressValidationResult getOrValidatePickupAddressResult() { + AddressValidationResult existingResult = addressValidationResults.get("pickup"); + if (existingResult != null && existingResult.matches(pickupStreet.getValue(), pickupHouseNumber.getValue(), + pickupZip.getValue(), pickupCity.getValue())) { + return existingResult; + } + + AddressValidationResult validationResult = addressValidationService.validateAddress("pickup", + pickupStreet.getValue(), pickupHouseNumber.getValue(), pickupZip.getValue(), pickupCity.getValue()); + storePickupAddressValidationResult(validationResult); + return validationResult; + } + + private AddressValidationResult getOrValidateDeliveryAddressResult(int index) { + String resultKey = "delivery_" + index; + DeliveryStation station = deliveryStationsState.get(index); + + AddressValidationResult existingResult = addressValidationResults.get(resultKey); + if (existingResult != null && existingResult.matches(station.getStreet(), station.getHouseNumber(), + station.getZip(), station.getCity())) { + return existingResult; + } + + AddressValidationResult validationResult = addressValidationService.validateAddress(resultKey, + station.getStreet(), station.getHouseNumber(), station.getZip(), station.getCity()); + storeDeliveryAddressValidationResult(index, validationResult); + return validationResult; + } + + private void storePickupAddressValidationResult(AddressValidationResult validationResult) { + if (validationResult == null) { + addressValidationResults.remove("pickup"); + return; + } + addressValidationResults.put("pickup", validationResult); + } + + private void storeDeliveryAddressValidationResult(int index, AddressValidationResult validationResult) { + String resultKey = "delivery_" + index; + if (validationResult == null) { + addressValidationResults.remove(resultKey); + return; + } + addressValidationResults.put(resultKey, validationResult); + } + + private RouteCalculationResult createInvalidRouteResult(String message) { + RouteCalculationResult routeResult = new RouteCalculationResult(); + routeResult.setRouteMessage(message); + return routeResult; + } + + private Dialog createRouteLoadingDialog() { + Dialog dialog = new Dialog(); + dialog.setCloseOnOutsideClick(false); + dialog.setCloseOnEsc(false); + dialog.setHeaderTitle(getTranslation("addjob.route.title")); + + VerticalLayout content = new VerticalLayout(); + content.setAlignItems(FlexComponent.Alignment.CENTER); + content.setPadding(true); + content.setSpacing(true); + + Span loadingText = new Span("Strecke zwischen allen Stationen wird berechnet..."); + ProgressBar progressBar = new ProgressBar(); + progressBar.setIndeterminate(true); + progressBar.setWidthFull(); + + content.add(loadingText, progressBar); + dialog.add(content); + return dialog; + } + + private void applyCalculatedRoute(RouteCalculationResult routeResult) { + routeCalculationResult = routeResult; + + routeDistanceLabel.setText(routeResult.getFormattedDistance()); + routeDurationLabel.setText(routeResult.getFormattedDurationLong()); + routeInfoBox.setVisible(true); + manualRouteInputBox.setVisible(false); + + updatePriceSummary(); + if (servicesGrid != null) { + servicesGrid.getDataProvider().refreshAll(); + } + } + + private void showRouteSummaryDialog(RouteCalculationResult routeResult) { + Dialog dialog = new Dialog(); + dialog.setHeaderTitle(getTranslation("addjob.route.title")); + dialog.setWidth("420px"); + + VerticalLayout content = new VerticalLayout(); + content.setPadding(false); + content.setSpacing(true); + content.add(createRouteSummaryRow(getTranslation("addjob.route.distance"), routeResult.getFormattedDistance())); + content.add(createRouteSummaryRow(getTranslation("addjob.route.duration"), + routeResult.getFormattedDurationLong())); + + Button closeButton = new Button(getTranslation("dialog.confirm"), event -> dialog.close()); + closeButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY); + + dialog.add(content); + dialog.getFooter().add(closeButton); + dialog.open(); + } + + private HorizontalLayout createRouteSummaryRow(String label, String value) { + HorizontalLayout row = new HorizontalLayout(); + row.setWidthFull(); + row.setJustifyContentMode(FlexComponent.JustifyContentMode.BETWEEN); + row.setAlignItems(FlexComponent.Alignment.CENTER); + + Span labelSpan = new Span(label + ":"); + Span valueSpan = new Span(value); + valueSpan.getStyle().set("font-weight", "bold"); + + row.add(labelSpan, valueSpan); + return row; + } + /** * Registriert ValueChangeListener für alle Adressfelder, um bei Änderungen die * Streckeninformationen zurückzusetzen. @@ -1781,6 +2187,12 @@ public class AddJobView extends Main implements HasDynamicTitle { if (routeInfoBox != null) { routeInfoBox.setVisible(false); } + if (routeDistanceLabel != null) { + routeDistanceLabel.setText("-"); + } + if (routeDurationLabel != null) { + routeDurationLabel.setText("-"); + } if (manualRouteInputBox != null) { manualRouteInputBox.setVisible(true); } 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 f330e50..fb8966a 100644 --- a/src/main/java/de/assecutor/votianlt/pages/view/JobSummaryView.java +++ b/src/main/java/de/assecutor/votianlt/pages/view/JobSummaryView.java @@ -31,6 +31,7 @@ import de.assecutor.votianlt.model.task.ConfirmationTask; import de.assecutor.votianlt.model.task.BarcodeTask; import de.assecutor.votianlt.model.task.CommentTask; import de.assecutor.votianlt.model.AppUser; +import de.assecutor.votianlt.pages.base.ui.component.StationTile; import de.assecutor.votianlt.pages.base.ui.component.ViewToolbar; import lombok.extern.slf4j.Slf4j; import de.assecutor.votianlt.repository.CargoItemRepository; @@ -185,94 +186,7 @@ public class JobSummaryView extends Main implements HasUrlParameter, Has private void render(Job job, List cargoItems, List tasks) { content.removeAll(); - // Kopfzeile: Abholung/Lieferung - HorizontalLayout topRow = new HorizontalLayout(); - topRow.setWidthFull(); - topRow.setSpacing(true); - - VerticalLayout pickupBox = borderedBox(); - pickupBox.add(new H3(getTranslation("jobsummary.section.pickup") + " " - + formatDateWithTime(job.getPickupDate(), job.getPickupTime()))); - pickupBox.add(new Span(valueOrEmpty(job.getPickupCompany()))); - pickupBox.add(new Span(valueOrEmpty(job.getPickupSalutation()) + (job.getPickupSalutation() != null ? " " : "") - + valueOrEmpty(job.getPickupFirstName()) + (job.getPickupFirstName() != null ? " " : "") - + valueOrEmpty(job.getPickupLastName()))); - pickupBox.add(new Span(concatAddress(job.getPickupStreet(), job.getPickupHouseNumber()))); - pickupBox.add(new Span(concatZipCity(job.getPickupZip(), job.getPickupCity()))); - - pickupBox.setWidth("50%"); - - List stations = job.getDeliveryStations(); - if (stations != null && !stations.isEmpty()) { - // Multiple delivery stations layout - VerticalLayout deliveryStationsContainer = new VerticalLayout(); - deliveryStationsContainer.setPadding(false); - deliveryStationsContainer.setSpacing(true); - deliveryStationsContainer.setWidth("50%"); - - for (int i = 0; i < stations.size(); i++) { - DeliveryStation station = stations.get(i); - VerticalLayout stationBox = borderedBox(); - String stationLabel = getTranslation("jobsummary.section.delivery") + " " - + (stations.size() > 1 ? (i + 1) + " " : "") - + formatDateWithTime(station.getDeliveryDate(), station.getDeliveryTime()); - stationBox.add(new H3(stationLabel)); - stationBox.add(new Span(valueOrEmpty(station.getCompany()))); - stationBox.add(new Span(valueOrEmpty(station.getSalutation()) - + (station.getSalutation() != null ? " " : "") + valueOrEmpty(station.getFirstName()) - + (station.getFirstName() != null ? " " : "") + valueOrEmpty(station.getLastName()))); - stationBox.add(new Span(concatAddress(station.getStreet(), station.getHouseNumber()))); - stationBox.add(new Span(concatZipCity(station.getZip(), station.getCity()))); - if (station.getPhone() != null && !station.getPhone().isBlank()) { - stationBox.add(new Span(getTranslation("jobsummary.station.phone") + ": " + station.getPhone())); - } - deliveryStationsContainer.add(stationBox); - } - - topRow.add(pickupBox, deliveryStationsContainer); - } else { - // Fallback: flat delivery fields for old jobs - VerticalLayout deliveryBox = borderedBox(); - deliveryBox.add(new H3(getTranslation("jobsummary.section.delivery") + " " - + formatDateWithTime(job.getDeliveryDate(), job.getDeliveryTime()))); - deliveryBox.add(new Span(valueOrEmpty(job.getDeliveryCompany()))); - deliveryBox.add(new Span(valueOrEmpty(job.getDeliverySalutation()) - + (job.getDeliverySalutation() != null ? " " : "") + valueOrEmpty(job.getDeliveryFirstName()) - + (job.getDeliveryFirstName() != null ? " " : "") + valueOrEmpty(job.getDeliveryLastName()))); - deliveryBox.add(new Span(concatAddress(job.getDeliveryStreet(), job.getDeliveryHouseNumber()))); - deliveryBox.add(new Span(concatZipCity(job.getDeliveryZip(), job.getDeliveryCity()))); - deliveryBox.setWidth("50%"); - topRow.add(pickupBox, deliveryBox); - } - content.add(topRow); - - // Aufgaben - VerticalLayout tasksBox = borderedBox(); - tasksBox.add(new H3(getTranslation("jobsummary.section.tasks"))); - - // Ensure consistent spacing and width for task cards - tasksBox.setSpacing(false); - tasksBox.getStyle().set("gap", "var(--lumo-space-xs)"); - - // Clear previous task cards - taskCards.clear(); - - if (tasks == null || tasks.isEmpty()) { - tasksBox.add(new Span(getTranslation("jobsummary.tasks.none"))); - } else { - for (BaseTask task : tasks) { - if (task != null) { - // Use getDisplayName() instead of getText() for task display - String displayName = task.getDisplayName(); - if (displayName != null && !displayName.isBlank()) { - Div taskCard = createTaskCard(task, displayName); - taskCards.add(taskCard); // Keep reference for hover reset - tasksBox.add(taskCard); - } - } - } - } - content.add(tasksBox); + content.add(createStationTilesSection(job, tasks)); // Fracht und weitere Infos HorizontalLayout midRow = new HorizontalLayout(); @@ -392,6 +306,179 @@ public class JobSummaryView extends Main implements HasUrlParameter, Has return box; } + private Div createStationTilesSection(Job job, 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)); + + List stations = job.getDeliveryStations(); + if (stations != null && !stations.isEmpty()) { + for (int i = 0; i < stations.size(); i++) { + stationGrid.add(createDeliverySummaryTile(stations.get(i), i, stations.size(), tasks)); + } + } else { + stationGrid.add(createLegacyDeliverySummaryTile(job, tasks)); + } + + return stationGrid; + } + + private StationTile createPickupSummaryTile(Job job) { + String title = getTranslation("jobsummary.section.pickup") + " " + + formatDateWithTime(job.getPickupDate(), job.getPickupTime()); + List additionalLines = new ArrayList<>(); + if (job.getPickupPhone() != null && !job.getPickupPhone().isBlank()) { + additionalLines.add(getTranslation("jobsummary.station.phone") + ": " + job.getPickupPhone()); + } + + return createSummaryTile(StationTile.StationType.PICKUP, 0, title, job.getPickupCompany(), + buildDisplayName(job.getPickupSalutation(), job.getPickupFirstName(), job.getPickupLastName()), + job.getPickupStreet(), job.getPickupHouseNumber(), job.getPickupZip(), job.getPickupCity(), + additionalLines); + } + + private StationTile createDeliverySummaryTile(DeliveryStation station, int index, int stationCount, + List tasks) { + String title = getTranslation("jobsummary.section.delivery") + " " + + (stationCount > 1 ? (index + 1) + " " : "") + + formatDateWithTime(station.getDeliveryDate(), station.getDeliveryTime()); + List additionalLines = new ArrayList<>(); + if (station.getPhone() != null && !station.getPhone().isBlank()) { + additionalLines.add(getTranslation("jobsummary.station.phone") + ": " + station.getPhone()); + } + + 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.setInteractive(true); + tile.setClickListener(clickedTile -> showStationTasksDialog(title, stationTasks)); + return tile; + } + + private StationTile createLegacyDeliverySummaryTile(Job job, List tasks) { + String title = getTranslation("jobsummary.section.delivery") + " " + + formatDateWithTime(job.getDeliveryDate(), job.getDeliveryTime()); + List additionalLines = new ArrayList<>(); + if (job.getDeliveryPhone() != null && !job.getDeliveryPhone().isBlank()) { + additionalLines.add(getTranslation("jobsummary.station.phone") + ": " + job.getDeliveryPhone()); + } + + StationTile tile = createSummaryTile(StationTile.StationType.DELIVERY, 1, title, job.getDeliveryCompany(), + buildDisplayName(job.getDeliverySalutation(), job.getDeliveryFirstName(), job.getDeliveryLastName()), + job.getDeliveryStreet(), job.getDeliveryHouseNumber(), job.getDeliveryZip(), job.getDeliveryCity(), + additionalLines); + List stationTasks = getTasksForStation(null, tasks, true); + tile.setInteractive(true); + tile.setClickListener(clickedTile -> showStationTasksDialog(title, stationTasks)); + return tile; + } + + private StationTile createSummaryTile(StationTile.StationType type, int stationNumber, String title, + String company, String displayName, String street, String houseNumber, String zip, String city, + List additionalLines) { + StationTile tile = new StationTile(type, stationNumber, title.trim(), false); + tile.setInteractive(false); + tile.updatePreview(company, displayName, null, street, houseNumber, zip, city, additionalLines); + return tile; + } + + private String buildDisplayName(String salutation, String firstName, String lastName) { + List parts = new ArrayList<>(); + if (salutation != null && !salutation.isBlank()) { + parts.add(salutation.trim()); + } + if (firstName != null && !firstName.isBlank()) { + parts.add(firstName.trim()); + } + if (lastName != null && !lastName.isBlank()) { + parts.add(lastName.trim()); + } + return String.join(" ", parts); + } + + private List getTasksForStation(DeliveryStation station, List tasks, boolean legacyMode) { + if (!legacyMode && station != null && station.getTasks() != null && !station.getTasks().isEmpty()) { + return station.getTasks().stream() + .filter(task -> task != null && task.getDisplayName() != null && !task.getDisplayName().isBlank()) + .sorted((left, right) -> Integer.compare(left.getTaskOrder() != null ? left.getTaskOrder() : 0, + right.getTaskOrder() != null ? right.getTaskOrder() : 0)) + .toList(); + } + + if (tasks == null || tasks.isEmpty()) { + return List.of(); + } + + List stationTasks = new ArrayList<>(); + int stationOrder = station != null ? station.getStationOrder() : 0; + for (BaseTask task : tasks) { + if (task == null) { + continue; + } + + Integer taskStationOrder = task.getStationOrder(); + if (legacyMode) { + if (taskStationOrder == null || taskStationOrder == stationOrder) { + stationTasks.add(task); + } + } else if (taskStationOrder != null && taskStationOrder == stationOrder) { + stationTasks.add(task); + } + } + return stationTasks; + } + + private void showStationTasksDialog(String stationTitle, List tasks) { + Dialog dialog = new Dialog(); + dialog.setWidth("720px"); + dialog.setMaxWidth("95vw"); + dialog.setMaxHeight("85vh"); + dialog.setResizable(true); + dialog.setHeaderTitle(stationTitle); + dialog.addDialogCloseActionListener(e -> resetAllTaskCardHoverStates()); + + taskCards.clear(); + + VerticalLayout dialogContent = new VerticalLayout(); + dialogContent.setPadding(true); + dialogContent.setSpacing(true); + dialogContent.setWidthFull(); + dialogContent.getStyle().set("min-width", "0"); + + H4 header = new H4(getTranslation("jobsummary.section.tasks")); + header.getStyle().set("margin", "0"); + dialogContent.add(header); + + if (tasks == null || tasks.isEmpty()) { + dialogContent.add(new Span(getTranslation("jobsummary.tasks.none"))); + } else { + for (BaseTask task : tasks) { + String displayName = task.getDisplayName(); + if (displayName == null || displayName.isBlank()) { + continue; + } + Div taskCard = createTaskCard(task, displayName); + taskCards.add(taskCard); + dialogContent.add(taskCard); + } + } + + Button closeButton = new Button("Schließen", e -> { + dialog.close(); + resetAllTaskCardHoverStates(); + }); + closeButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY); + dialog.getFooter().add(closeButton); + + dialog.add(dialogContent); + dialog.open(); + } + private String formatLocalDate(java.time.LocalDate date) { try { return DateTimeFormatUtil.formatDate(date); @@ -1054,12 +1141,28 @@ public class JobSummaryView extends Main implements HasUrlParameter, Has taskName.getStyle().set("font-weight", "500").set("font-size", "var(--lumo-font-size-m)").set("color", task.isCompleted() ? "var(--lumo-success-text-color)" : "var(--lumo-body-text-color)"); + Span statusBadge = new Span(getTaskStatusLabel(task)); + statusBadge.getStyle().set("font-size", "var(--lumo-font-size-xs)") + .set("font-weight", "600") + .set("padding", "0.2rem 0.55rem") + .set("border-radius", "999px") + .set("background-color", + task.isCompleted() ? "rgba(76, 175, 80, 0.15)" : "rgba(244, 67, 54, 0.12)") + .set("color", task.isCompleted() ? "var(--lumo-success-text-color)" : "var(--lumo-error-text-color)"); + + HorizontalLayout headerRow = new HorizontalLayout(taskName, statusBadge); + headerRow.setWidthFull(); + headerRow.setPadding(false); + headerRow.setSpacing(true); + headerRow.setAlignItems(HorizontalLayout.Alignment.CENTER); + headerRow.setJustifyContentMode(HorizontalLayout.JustifyContentMode.BETWEEN); + // Task status/description Span taskDescription = new Span(getTaskDescription(task)); taskDescription.getStyle().set("font-size", "var(--lumo-font-size-s)") .set("color", "var(--lumo-secondary-text-color)").set("margin-top", "var(--lumo-space-xs)"); - taskContent.add(taskName, taskDescription); + taskContent.add(headerRow, taskDescription); // Status indicator Div statusIndicator = new Div(); @@ -1097,9 +1200,13 @@ public class JobSummaryView extends Main implements HasUrlParameter, Has } } + private String getTaskStatusLabel(BaseTask task) { + return task.isCompleted() ? "Abgeschlossen" : "Offen"; + } + private String getTaskDescription(BaseTask task) { if (task.isCompleted()) { - return "Abgeschlossen" + return "Erledigt" + (task.getCompletedAt() != null ? " am " + formatLocalDate(task.getCompletedAt().toLocalDate()) : ""); }