feat: adapt job flow for delivery stations

This commit is contained in:
2026-03-09 15:13:06 +01:00
parent 09798efcf1
commit e7423259f3
10 changed files with 916 additions and 405 deletions

View File

@@ -1,23 +1,10 @@
package de.assecutor.votianlt.messaging; package de.assecutor.votianlt.messaging;
import com.fasterxml.jackson.databind.ObjectMapper; 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 lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import java.nio.charset.StandardCharsets; 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. * Publishing helper to send JSON payloads to clients via WebSocket.
@@ -32,13 +19,10 @@ class MessagingPublisherImpl implements MessagingPublisher {
private final WebSocketService webSocketService; private final WebSocketService webSocketService;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final TranslationService translationService;
public MessagingPublisherImpl(WebSocketService webSocketService, ObjectMapper objectMapper, public MessagingPublisherImpl(WebSocketService webSocketService, ObjectMapper objectMapper) {
TranslationService translationService) {
this.webSocketService = webSocketService; this.webSocketService = webSocketService;
this.objectMapper = objectMapper; this.objectMapper = objectMapper;
this.translationService = translationService;
} }
@Override @Override
@@ -53,10 +37,7 @@ class MessagingPublisherImpl implements MessagingPublisher {
return; return;
} }
// Verarbeite Payload und füge Übersetzungen hinzu wenn nötig String json = objectMapper.writeValueAsString(payload);
Object processedPayload = processPayloadWithTranslations(payload);
String json = objectMapper.writeValueAsString(processedPayload);
byte[] data = json.getBytes(StandardCharsets.UTF_8); byte[] data = json.getBytes(StandardCharsets.UTF_8);
webSocketService.sendToClient(clientId, messageType, data).thenRun(() -> { 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); 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<String> texts = collectTexts(dto);
Map<String, List<TranslationService.Translation>> translations = translationService
.translateBatch(texts);
return convertToTranslatedJson(dto, translations);
}
if (payload instanceof List<?> list && !list.isEmpty() && list.get(0) instanceof JobWithRelatedDataDTO) {
@SuppressWarnings("unchecked")
List<JobWithRelatedDataDTO> dtoList = (List<JobWithRelatedDataDTO>) list;
// Collect all texts from all DTOs and translate in one batch
List<String> allTexts = dtoList.stream().flatMap(d -> collectTexts(d).stream()).distinct().toList();
Map<String, List<TranslationService.Translation>> 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<String> collectTexts(JobWithRelatedDataDTO dto) {
List<String> 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<String, List<TranslationService.Translation>> translations) {
ObjectNode root = objectMapper.valueToTree(dto);
// Job remark
if (dto.getJob() != null && isNonBlank(dto.getJob().getRemark())) {
List<TranslationService.Translation> 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<TranslationService.Translation> t = translations.get(task.getDescription());
if (t != null) {
taskNode.set("description", createTranslationArray(t));
}
}
if (isNonBlank(task.getDisplayName())) {
List<TranslationService.Translation> t = translations.get(task.getDisplayName());
if (t != null) {
taskNode.set("displayName", createTranslationArray(t));
}
}
if (task instanceof ConfirmationTask ct && isNonBlank(ct.getButtonText())) {
List<TranslationService.Translation> 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<TranslationService.Translation> 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<TranslationService.Translation> 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<TranslationService.Translation> 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();
}
} }

View File

@@ -1,5 +1,6 @@
package de.assecutor.votianlt.model; package de.assecutor.votianlt.model;
import de.assecutor.votianlt.model.task.BaseTask;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Data; import lombok.Data;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
@@ -7,6 +8,8 @@ import org.springframework.data.mongodb.core.mapping.Field;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.LocalTime; 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 * Embedded delivery station within a Job. Each job can have up to 25 delivery
@@ -56,4 +59,7 @@ public class DeliveryStation {
@Field("delivery_time") @Field("delivery_time")
private LocalTime deliveryTime; private LocalTime deliveryTime;
@Field("tasks")
private List<BaseTask> tasks = new ArrayList<>();
} }

View File

@@ -54,6 +54,7 @@ public class DeliveryStationDialog extends Dialog {
private boolean saveAddress; private boolean saveAddress;
private List<BaseTask> tasks = new ArrayList<>(); private List<BaseTask> tasks = new ArrayList<>();
private boolean addressValidatedByGoogle; private boolean addressValidatedByGoogle;
private AddressValidationResult addressValidationResult;
public boolean isAddressValidatedByGoogle() { public boolean isAddressValidatedByGoogle() {
return addressValidatedByGoogle; return addressValidatedByGoogle;
@@ -63,6 +64,14 @@ public class DeliveryStationDialog extends Dialog {
this.addressValidatedByGoogle = addressValidatedByGoogle; this.addressValidatedByGoogle = addressValidatedByGoogle;
} }
public AddressValidationResult getAddressValidationResult() {
return addressValidationResult;
}
public void setAddressValidationResult(AddressValidationResult addressValidationResult) {
this.addressValidationResult = addressValidationResult;
}
public String getCompany() { public String getCompany() {
return company; return company;
} }
@@ -191,6 +200,7 @@ public class DeliveryStationDialog extends Dialog {
private final DeliveryStationTile.TranslationHelper translationHelper; private final DeliveryStationTile.TranslationHelper translationHelper;
private final AddressValidationService addressValidationService; private final AddressValidationService addressValidationService;
private final java.util.Map<String, Customer> companyAddressOptions = new java.util.LinkedHashMap<>();
public DeliveryStationDialog(String dialogTitle, List<Customer> customers, public DeliveryStationDialog(String dialogTitle, List<Customer> customers,
DeliveryStationTile.TranslationHelper translationHelper, SaveListener saveListener, DeliveryStationTile.TranslationHelper translationHelper, SaveListener saveListener,
@@ -359,6 +369,7 @@ public class DeliveryStationDialog extends Dialog {
if (validationResult.isValid()) { if (validationResult.isValid()) {
data.setAddressValidatedByGoogle(true); data.setAddressValidatedByGoogle(true);
data.setAddressValidationResult(validationResult);
if (saveListener != null) { if (saveListener != null) {
saveListener.onSave(data); saveListener.onSave(data);
} }
@@ -378,6 +389,7 @@ public class DeliveryStationDialog extends Dialog {
translationHelper.getTranslation("addjob.validation.address.correct")); translationHelper.getTranslation("addjob.validation.address.correct"));
confirmDialog.addConfirmListener(ev -> { confirmDialog.addConfirmListener(ev -> {
data.setAddressValidatedByGoogle(false); data.setAddressValidatedByGoogle(false);
data.setAddressValidationResult(validationResult);
if (saveListener != null) { if (saveListener != null) {
saveListener.onSave(data); saveListener.onSave(data);
} }
@@ -406,8 +418,12 @@ public class DeliveryStationDialog extends Dialog {
public void setData(DeliveryData data) { public void setData(DeliveryData data) {
if (data == null) if (data == null)
return; return;
if (data.getCompany() != null) String companyOption = findCompanyOptionLabel(data);
if (companyOption != null) {
company.setValue(companyOption);
} else if (data.getCompany() != null) {
company.setValue(data.getCompany()); company.setValue(data.getCompany());
}
if (data.getSalutation() != null) if (data.getSalutation() != null)
salutation.setValue(data.getSalutation()); salutation.setValue(data.getSalutation());
if (data.getFirstName() != null) if (data.getFirstName() != null)
@@ -448,7 +464,7 @@ public class DeliveryStationDialog extends Dialog {
private DeliveryData collectData() { private DeliveryData collectData() {
DeliveryData data = new DeliveryData(); DeliveryData data = new DeliveryData();
data.setCompany(company.getValue()); data.setCompany(resolveCompanyValue(company.getValue()));
data.setSalutation(salutation.getValue()); data.setSalutation(salutation.getValue());
data.setFirstName(firstName.getValue()); data.setFirstName(firstName.getValue());
data.setLastName(lastName.getValue()); data.setLastName(lastName.getValue());
@@ -527,22 +543,29 @@ public class DeliveryStationDialog extends Dialog {
} }
private void setupCompanyAutocomplete(ComboBox<String> companyField, List<Customer> customers) { private void setupCompanyAutocomplete(ComboBox<String> companyField, List<Customer> customers) {
List<String> companyNames = customers.stream().map(Customer::getCompanyName) companyAddressOptions.clear();
.filter(name -> name != null && !name.trim().isEmpty()).distinct().sorted().toList(); 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 -> { companyField.addValueChangeListener(event -> {
String selectedCompany = event.getValue(); Customer customer = companyAddressOptions.get(event.getValue());
if (selectedCompany == null || selectedCompany.trim().isEmpty()) { if (customer == null) {
return; return;
} }
Optional<Customer> matchingCustomer = customers.stream()
.filter(c -> selectedCompany.equals(c.getCompanyName())).findFirst();
if (matchingCustomer.isPresent()) {
Customer customer = matchingCustomer.get();
if (customer.getTitle() != null if (customer.getTitle() != null
&& ("Herr".equalsIgnoreCase(customer.getTitle()) || "Frau".equalsIgnoreCase(customer.getTitle()) && ("Herr".equalsIgnoreCase(customer.getTitle()) || "Frau".equalsIgnoreCase(customer.getTitle())
|| "Divers".equalsIgnoreCase(customer.getTitle()))) { || "Divers".equalsIgnoreCase(customer.getTitle()))) {
@@ -564,12 +587,82 @@ public class DeliveryStationDialog extends Dialog {
zip.setValue(customer.getZip()); zip.setValue(customer.getZip());
if (customer.getCity() != null) if (customer.getCity() != null)
city.setValue(customer.getCity()); city.setValue(customer.getCity());
}
}); });
companyField.addCustomValueSetListener(event -> companyField.setValue(event.getDetail())); companyField.addCustomValueSetListener(event -> companyField.setValue(event.getDetail()));
} }
private String buildCompanyAddressLabel(Customer customer) {
if (customer == null) {
return null;
}
List<String> 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<String> 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<String, Customer> 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 // Task Management
// ============================================ // ============================================

View File

@@ -66,6 +66,7 @@ public class PickupStationDialog extends Dialog {
private AppUser appUser; private AppUser appUser;
private List<CargoItem> cargoItems = new ArrayList<>(); private List<CargoItem> cargoItems = new ArrayList<>();
private boolean addressValidatedByGoogle; private boolean addressValidatedByGoogle;
private AddressValidationResult addressValidationResult;
public boolean isAddressValidatedByGoogle() { public boolean isAddressValidatedByGoogle() {
return addressValidatedByGoogle; return addressValidatedByGoogle;
@@ -75,6 +76,14 @@ public class PickupStationDialog extends Dialog {
this.addressValidatedByGoogle = addressValidatedByGoogle; this.addressValidatedByGoogle = addressValidatedByGoogle;
} }
public AddressValidationResult getAddressValidationResult() {
return addressValidationResult;
}
public void setAddressValidationResult(AddressValidationResult addressValidationResult) {
this.addressValidationResult = addressValidationResult;
}
public String getCompany() { public String getCompany() {
return company; return company;
} }
@@ -485,6 +494,7 @@ public class PickupStationDialog extends Dialog {
if (validationResult.isValid()) { if (validationResult.isValid()) {
data.setAddressValidatedByGoogle(true); data.setAddressValidatedByGoogle(true);
data.setAddressValidationResult(validationResult);
if (saveListener != null) { if (saveListener != null) {
saveListener.onSave(data); saveListener.onSave(data);
} }
@@ -504,6 +514,7 @@ public class PickupStationDialog extends Dialog {
translationHelper.getTranslation("addjob.validation.address.correct")); translationHelper.getTranslation("addjob.validation.address.correct"));
confirmDialog.addConfirmListener(ev -> { confirmDialog.addConfirmListener(ev -> {
data.setAddressValidatedByGoogle(false); data.setAddressValidatedByGoogle(false);
data.setAddressValidationResult(validationResult);
if (saveListener != null) { if (saveListener != null) {
saveListener.onSave(data); saveListener.onSave(data);
} }

View File

@@ -9,6 +9,7 @@ import com.vaadin.flow.component.icon.VaadinIcon;
import com.vaadin.flow.component.orderedlayout.FlexComponent; import com.vaadin.flow.component.orderedlayout.FlexComponent;
import com.vaadin.flow.component.orderedlayout.HorizontalLayout; import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
import com.vaadin.flow.component.orderedlayout.VerticalLayout; import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import java.util.List;
/** /**
* A compact tile representing a station (pickup or delivery) in a grid layout. * 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.type = type;
this.stationNumber = stationNumber; this.stationNumber = stationNumber;
setPadding(true); setPadding(false);
setSpacing(false); setSpacing(false);
getStyle().set("border", "1px solid var(--lumo-contrast-20pct)"); getStyle().set("border", "1px solid var(--lumo-contrast-20pct)");
getStyle().set("border-radius", "var(--lumo-border-radius-m)"); getStyle().set("border-radius", "var(--lumo-border-radius-m)");
@@ -48,6 +49,7 @@ public class StationTile extends VerticalLayout {
getStyle().set("cursor", "pointer"); getStyle().set("cursor", "pointer");
getStyle().set("aspect-ratio", "1 / 1"); getStyle().set("aspect-ratio", "1 / 1");
getStyle().set("overflow", "hidden"); getStyle().set("overflow", "hidden");
getStyle().set("padding", "var(--lumo-space-m)");
// Header with title and optional delete button // Header with title and optional delete button
title = new H3(titleText); title = new H3(titleText);
@@ -82,7 +84,8 @@ public class StationTile extends VerticalLayout {
previewContent = new VerticalLayout(); previewContent = new VerticalLayout();
previewContent.setPadding(false); previewContent.setPadding(false);
previewContent.setSpacing(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"); previewContent.getStyle().set("flex-grow", "1");
add(previewContent); add(previewContent);
@@ -99,6 +102,11 @@ public class StationTile extends VerticalLayout {
public void updatePreview(String company, String firstName, String lastName, String street, String houseNumber, public void updatePreview(String company, String firstName, String lastName, String street, String houseNumber,
String zip, String city) { 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<String> additionalLines) {
previewContent.removeAll(); previewContent.removeAll();
previewContent.setJustifyContentMode(FlexComponent.JustifyContentMode.START); previewContent.setJustifyContentMode(FlexComponent.JustifyContentMode.START);
previewContent.setDefaultHorizontalComponentAlignment(FlexComponent.Alignment.START); previewContent.setDefaultHorizontalComponentAlignment(FlexComponent.Alignment.START);
@@ -128,6 +136,15 @@ public class StationTile extends VerticalLayout {
hasData = true; hasData = true;
} }
if (additionalLines != null) {
for (String line : additionalLines) {
if (line != null && !line.trim().isEmpty()) {
addPreviewLine(line);
hasData = true;
}
}
}
if (!hasData) { if (!hasData) {
updateEmptyPreview(); updateEmptyPreview();
} }
@@ -144,8 +161,8 @@ public class StationTile extends VerticalLayout {
private void addPreviewLine(String text) { private void addPreviewLine(String text) {
Span span = new Span(text); Span span = new Span(text);
span.getStyle().set("font-size", "var(--lumo-font-size-s)").set("word-break", "break-word").set("color", span.getStyle().set("font-size", "var(--lumo-font-size-xs)").set("line-height", "1.2").set("word-break",
"var(--lumo-secondary-text-color)"); "break-word").set("color", "var(--lumo-secondary-text-color)");
previewContent.add(span); previewContent.add(span);
} }
@@ -173,6 +190,10 @@ public class StationTile extends VerticalLayout {
this.deleteListener = listener; this.deleteListener = listener;
} }
public void setInteractive(boolean interactive) {
getStyle().set("cursor", interactive ? "pointer" : "default");
}
public void setAddressValidated(boolean validated) { public void setAddressValidated(boolean validated) {
if (validated) { if (validated) {
getStyle().set("background-color", "rgba(76, 175, 80, 0.15)"); getStyle().set("background-color", "rgba(76, 175, 80, 0.15)");

View File

@@ -24,9 +24,7 @@ import com.vaadin.flow.router.Layout;
import com.vaadin.flow.server.auth.AnonymousAllowed; import com.vaadin.flow.server.auth.AnonymousAllowed;
import com.vaadin.flow.shared.Registration; import com.vaadin.flow.shared.Registration;
import de.assecutor.votianlt.model.User; 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.AppUserService;
import de.assecutor.votianlt.pages.service.UserInvoiceDataService;
import de.assecutor.votianlt.pages.view.EditProfileView; import de.assecutor.votianlt.pages.view.EditProfileView;
import de.assecutor.votianlt.model.Language; import de.assecutor.votianlt.model.Language;
import de.assecutor.votianlt.security.SecurityService; import de.assecutor.votianlt.security.SecurityService;
@@ -46,7 +44,6 @@ import java.util.Objects;
public final class MainLayout extends AppLayout { public final class MainLayout extends AppLayout {
private final SecurityService securityService; private final SecurityService securityService;
private final UserInvoiceDataService userInvoiceDataService;
private final MessageService messageService; private final MessageService messageService;
private final MessageBadgeUpdateService messageBadgeUpdateService; private final MessageBadgeUpdateService messageBadgeUpdateService;
private final AppUserService appUserService; private final AppUserService appUserService;
@@ -57,11 +54,9 @@ public final class MainLayout extends AppLayout {
private MenuTreeItem messagesTreeItem; private MenuTreeItem messagesTreeItem;
private Registration badgeUpdateRegistration; private Registration badgeUpdateRegistration;
public MainLayout(SecurityService securityService, UserInvoiceDataService userInvoiceDataService, public MainLayout(SecurityService securityService, MessageService messageService,
MessageService messageService, MessageBadgeUpdateService messageBadgeUpdateService, MessageBadgeUpdateService messageBadgeUpdateService, AppUserService appUserService) {
AppUserService appUserService) {
this.securityService = securityService; this.securityService = securityService;
this.userInvoiceDataService = userInvoiceDataService;
this.messageService = messageService; this.messageService = messageService;
this.messageBadgeUpdateService = messageBadgeUpdateService; this.messageBadgeUpdateService = messageBadgeUpdateService;
this.appUserService = appUserService; this.appUserService = appUserService;
@@ -141,12 +136,6 @@ public final class MainLayout extends AppLayout {
treeData.addItem(verwaltungItem, treeData.addItem(verwaltungItem,
new MenuTreeItem(getTranslation("nav.statistics"), "statistics", VaadinIcon.BAR_CHART)); 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" // Add children to "Benutzer"
treeData.addItem(benutzerItem, treeData.addItem(benutzerItem,
new MenuTreeItem(getTranslation("nav.profile"), "edit-profile", VaadinIcon.USER)); new MenuTreeItem(getTranslation("nav.profile"), "edit-profile", VaadinIcon.USER));
@@ -338,20 +327,6 @@ public final class MainLayout extends AppLayout {
return userMenu; 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 @Override
protected void onAttach(AttachEvent attachEvent) { protected void onAttach(AttachEvent attachEvent) {
super.onAttach(attachEvent); super.onAttach(attachEvent);

View File

@@ -2,6 +2,7 @@ package de.assecutor.votianlt.pages.service;
import de.assecutor.votianlt.dto.JobWithRelatedDataDTO; import de.assecutor.votianlt.dto.JobWithRelatedDataDTO;
import de.assecutor.votianlt.model.CargoItem; import de.assecutor.votianlt.model.CargoItem;
import de.assecutor.votianlt.model.DeliveryStation;
import de.assecutor.votianlt.model.Job; import de.assecutor.votianlt.model.Job;
import de.assecutor.votianlt.model.JobStatus; import de.assecutor.votianlt.model.JobStatus;
import de.assecutor.votianlt.model.task.BaseTask; import de.assecutor.votianlt.model.task.BaseTask;
@@ -21,7 +22,11 @@ import org.springframework.stereotype.Service;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Optional; import java.util.Optional;
@Service @Service
@@ -89,18 +94,30 @@ public class AddJobService {
.filter(task -> task.getTaskType() != null) // Filter nach TaskType statt Text .filter(task -> task.getTaskType() != null) // Filter nach TaskType statt Text
.toList(); .toList();
// Setze JobId und stelle sicher, dass taskOrder korrekt ist Map<Integer, Integer> taskOrderByStation = new HashMap<>();
for (int i = 0; i < filteredTasks.size(); i++) {
BaseTask task = filteredTasks.get(i); // Setze JobId und stelle sicher, dass taskOrder je Lieferstation korrekt ist
for (BaseTask task : filteredTasks) {
task.setJobId(jobId); task.setJobId(jobId);
// Verwende die bereits gesetzte taskOrder oder setze sie auf den Index int stationOrder = task.getStationOrder() != null ? task.getStationOrder() : 0;
if (task.getTaskOrder() == null || task.getTaskOrder() != i) { if (task.getTaskOrder() == null) {
task.setTaskOrder(i); // Stelle sicher, dass die Reihenfolge stimmt 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); taskRepository.saveAll(filteredTasks);
attachTasksToDeliveryStations(savedJob, filteredTasks);
savedJob = jobRepository.save(savedJob);
log.info("Saved {} tasks for job {} with ordering", filteredTasks.size(), jobId); 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) { if (modified) {
@@ -215,4 +232,26 @@ public class AddJobService {
log.warn("[JOB] Failed to send job_created notification: {}", e.getMessage()); log.warn("[JOB] Failed to send job_created notification: {}", e.getMessage());
} }
} }
private void attachTasksToDeliveryStations(Job job, List<BaseTask> tasks) {
if (job.getDeliveryStations() == null || job.getDeliveryStations().isEmpty()) {
return;
}
Map<Integer, List<BaseTask>> 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<BaseTask> stationTasks = new ArrayList<>(tasksByStation.getOrDefault(stationOrder, List.of()));
stationTasks.sort(Comparator.comparing(task -> task.getTaskOrder() != null ? task.getTaskOrder() : 0));
station.setTasks(stationTasks);
}
}
} }

View File

@@ -15,6 +15,8 @@ import java.net.http.HttpRequest;
import java.net.http.HttpResponse; import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.time.Duration; import java.time.Duration;
import java.util.List;
import java.util.Locale;
/** /**
* Service zur Validierung von Adressen über die Google Geocoding API. * Service zur Validierung von Adressen über die Google Geocoding API.
@@ -118,11 +120,12 @@ public class AddressValidationService {
double lat = location.path("lat").asDouble(); double lat = location.path("lat").asDouble();
double lng = location.path("lng").asDouble(); double lng = location.path("lng").asDouble();
// Prüfen, ob die Adresse als "ROOFTOP" (genaue Adresse) oder // Google liefert für valide Adressen nicht immer nur ROOFTOP/RANGE_INTERPOLATED.
// "RANGE_INTERPOLATED" gefunden wurde // Für unseren Flow reicht ein erfolgreicher Geocoding-Treffer mit Koordinaten.
String locationType = geometry.path("location_type").asText(); 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; boolean hasPostalCode = false;
JsonNode addressComponents = firstResult.path("address_components"); JsonNode addressComponents = firstResult.path("address_components");
@@ -131,6 +134,7 @@ public class AddressValidationService {
for (JsonNode type : types) { for (JsonNode type : types) {
String typeStr = type.asText(); String typeStr = type.asText();
if ("street_number".equals(typeStr)) { if ("street_number".equals(typeStr)) {
hasStreetNumber = true;
} else if ("postal_code".equals(typeStr)) { } else if ("postal_code".equals(typeStr)) {
hasPostalCode = true; hasPostalCode = true;
} }
@@ -138,19 +142,22 @@ public class AddressValidationService {
} }
// Ergebnis setzen // Ergebnis setzen
result.setValid(isPrecise && hasPostalCode); result.setValid(hasCoordinates);
result.setFormattedAddress(formattedAddress); result.setFormattedAddress(formattedAddress);
result.setLatitude(lat); result.setLatitude(lat);
result.setLongitude(lng); result.setLongitude(lng);
if (result.isValid()) { if (result.isValid()) {
result.setValidationMessage("Adresse erfolgreich validiert"); result.setValidationMessage("Adresse erfolgreich validiert");
log.debug(
"Adressvalidierung erfolgreich: {} -> {} (locationType={}, streetNumber={}, postalCode={})",
addressString, formattedAddress, locationType, hasStreetNumber, hasPostalCode);
} else { } 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) { } catch (Exception e) {
log.error("Fehler bei der Adressvalidierung", e); log.error("Fehler bei der Adressvalidierung", e);
result.setValidationMessage("Fehler: " + e.getMessage()); result.setValidationMessage("Fehler: " + e.getMessage());
@@ -191,6 +198,17 @@ public class AddressValidationService {
*/ */
public RouteCalculationResult calculateRoute(AddressValidationResult pickupResult, public RouteCalculationResult calculateRoute(AddressValidationResult pickupResult,
AddressValidationResult deliveryResult) { 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<AddressValidationResult> stationResults) {
RouteCalculationResult routeResult = new RouteCalculationResult(); RouteCalculationResult routeResult = new RouteCalculationResult();
// Prüfen, ob API-Key konfiguriert ist // Prüfen, ob API-Key konfiguriert ist
@@ -200,26 +218,41 @@ public class AddressValidationService {
return routeResult; return routeResult;
} }
// Prüfen, ob beide Adressen gültige Koordinaten haben if (stationResults == null || stationResults.size() < 2) {
if (pickupResult == null || !pickupResult.isValid() || deliveryResult == null || !deliveryResult.isValid()) { routeResult.setRouteMessage("Mindestens zwei validierte Stationen werden benötigt");
routeResult.setRouteMessage("Beide Adressen müssen validiert sein"); 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; return routeResult;
} }
try { try {
AddressValidationResult originResult = stationResults.getFirst();
AddressValidationResult destinationResult = stationResults.getLast();
// Koordinaten für Start und Ziel // Koordinaten für Start und Ziel
String origin = String.format("%s,%s", pickupResult.getLatitude(), pickupResult.getLongitude()); String origin = formatLatLng(originResult);
String destination = String.format("%s,%s", deliveryResult.getLatitude(), deliveryResult.getLongitude()); String destination = formatLatLng(destinationResult);
// URL für die Directions API erstellen // URL für die Directions API erstellen
String requestUrl = String.format("%s?origin=%s&destination=%s&mode=driving&key=%s&language=de&region=de", StringBuilder requestUrl = new StringBuilder(String.format(
DIRECTIONS_API_URL, origin, destination, googleMapsApiKey); "%s?origin=%s&destination=%s&mode=driving&key=%s&language=de&region=de", DIRECTIONS_API_URL,
origin, destination, googleMapsApiKey));
log.debug("Berechne Route von {} nach {}", pickupResult.getFormattedAddress(), if (stationResults.size() > 2) {
deliveryResult.getFormattedAddress()); List<String> 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 // 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(); .timeout(Duration.ofSeconds(10)).build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
@@ -254,27 +287,26 @@ public class AddressValidationService {
return routeResult; return routeResult;
} }
// Ersten Leg (Hauptstrecke) verwenden int totalDistanceMeters = 0;
JsonNode firstLeg = legs.get(0); int totalDurationSeconds = 0;
// Distanz extrahieren for (JsonNode leg : legs) {
JsonNode distanceNode = firstLeg.path("distance"); totalDistanceMeters += leg.path("distance").path("value").asInt();
int distanceMeters = distanceNode.path("value").asInt(); totalDurationSeconds += leg.path("duration").path("value").asInt();
String distanceText = distanceNode.path("text").asText(); }
// Dauer extrahieren double totalDistanceKm = totalDistanceMeters / 1000.0;
JsonNode durationNode = firstLeg.path("duration"); String distanceText = String.format(Locale.GERMANY, "%.1f km", totalDistanceKm);
int durationSeconds = durationNode.path("value").asInt(); String durationText = formatDuration(totalDurationSeconds);
String durationText = durationNode.path("text").asText();
// Ergebnis setzen // Ergebnis setzen
routeResult.setValid(true); routeResult.setValid(true);
routeResult.setDistanceKm(distanceMeters / 1000.0); routeResult.setDistanceKm(totalDistanceKm);
routeResult.setDurationSeconds(durationSeconds); routeResult.setDurationSeconds(totalDurationSeconds);
routeResult.setFormattedDistance(distanceText); routeResult.setFormattedDistance(distanceText);
routeResult.setFormattedDuration(durationText); routeResult.setFormattedDuration(durationText);
routeResult.setRouteMessage( 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(), log.debug("Routenberechnung erfolgreich: {} km, {} Min.", routeResult.getDistanceKm(),
routeResult.getDurationMinutes()); routeResult.getDurationMinutes());
@@ -286,4 +318,18 @@ public class AddressValidationService {
return routeResult; 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);
}
} }

View File

@@ -6,6 +6,7 @@ import com.vaadin.flow.component.checkbox.Checkbox;
import com.vaadin.flow.component.combobox.ComboBox; import com.vaadin.flow.component.combobox.ComboBox;
import com.vaadin.flow.component.confirmdialog.ConfirmDialog; import com.vaadin.flow.component.confirmdialog.ConfirmDialog;
import com.vaadin.flow.component.dialog.Dialog; 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.datepicker.DatePicker;
import com.vaadin.flow.component.html.Div; 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.FlexComponent;
import com.vaadin.flow.component.orderedlayout.HorizontalLayout; import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
import com.vaadin.flow.component.orderedlayout.VerticalLayout; 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.component.textfield.TextField;
import com.vaadin.flow.data.binder.Binder; import com.vaadin.flow.data.binder.Binder;
import com.vaadin.flow.component.Component; 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.Authentication;
import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.context.SecurityContextHolder;
import de.assecutor.votianlt.model.Job; 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.BaseTask;
import de.assecutor.votianlt.model.task.CommentTask;
import de.assecutor.votianlt.model.task.ConfirmationTask; 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.model.task.TodoListTask;
import de.assecutor.votianlt.pages.service.AddJobService; import de.assecutor.votianlt.pages.service.AddJobService;
import de.assecutor.votianlt.pages.service.CustomerService; 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.PickupStationDialog;
import de.assecutor.votianlt.pages.base.ui.component.DeliveryStationDialog; import de.assecutor.votianlt.pages.base.ui.component.DeliveryStationDialog;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.LocalTime;
import java.time.format.DateTimeFormatter;
import java.util.*; import java.util.*;
import java.util.Objects; import java.util.Objects;
import java.util.Optional; import java.util.Optional;
import java.util.concurrent.CompletableFuture;
@Route(value = "add_job", layout = de.assecutor.votianlt.pages.base.ui.view.MainLayout.class) @Route(value = "add_job", layout = de.assecutor.votianlt.pages.base.ui.view.MainLayout.class)
@RolesAllowed("USER") @RolesAllowed("USER")
@Slf4j @Slf4j
public class AddJobView extends Main implements HasDynamicTitle { 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 @Override
public String getPageTitle() { public String getPageTitle() {
@@ -160,6 +171,8 @@ public class AddJobView extends Main implements HasDynamicTitle {
// Adressvalidierung // Adressvalidierung
private final Map<String, AddressValidationResult> addressValidationResults = new HashMap<>(); private final Map<String, AddressValidationResult> addressValidationResults = new HashMap<>();
private RouteCalculationResult routeCalculationResult; private RouteCalculationResult routeCalculationResult;
private boolean pickupAddressValidatedByGoogle;
private final List<Boolean> deliveryStationsValidatedByGoogle = new ArrayList<>();
public AddJobView(AddJobService addJobService, AddCustomerService addCustomerService, public AddJobView(AddJobService addJobService, AddCustomerService addCustomerService,
CustomerService customerService, AppUserService appUserService, TaskTemplateService taskTemplateService, CustomerService customerService, AppUserService appUserService, TaskTemplateService taskTemplateService,
@@ -354,11 +367,7 @@ public class AddJobView extends Main implements HasDynamicTitle {
applyStationsButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY); applyStationsButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
applyStationsButton.setWidthFull(); applyStationsButton.setWidthFull();
applyStationsButton.setEnabled(false); applyStationsButton.setEnabled(false);
applyStationsButton.addClickListener(e -> { applyStationsButton.addClickListener(e -> handleApplyStations());
applyStationsButton.setVisible(false);
priceAndDetailsSection.setVisible(true);
submitButtonLayout.setVisible(true);
});
tabContent.add(applyStationsButton); tabContent.add(applyStationsButton);
// Route Info Box // Route Info Box
@@ -631,6 +640,7 @@ public class AddJobView extends Main implements HasDynamicTitle {
// Add empty state for this station // Add empty state for this station
deliveryStationsState.add(new DeliveryStation()); deliveryStationsState.add(new DeliveryStation());
deliveryStationsSaveAddress.add(true); deliveryStationsSaveAddress.add(true);
deliveryStationsValidatedByGoogle.add(false);
int stationIndex = deliveryStationTilesList.size(); int stationIndex = deliveryStationTilesList.size();
tile.setClickListener(t -> openDeliveryDialog(t, stationIndex)); tile.setClickListener(t -> openDeliveryDialog(t, stationIndex));
@@ -647,6 +657,7 @@ public class AddJobView extends Main implements HasDynamicTitle {
stationsGridContainer.add(addStationButton); stationsGridContainer.add(addStationButton);
} }
resetRouteInformation();
resetStationsAppliedState(); resetStationsAppliedState();
triggerValidation(); triggerValidation();
updateTabLabels(); updateTabLabels();
@@ -670,6 +681,7 @@ public class AddJobView extends Main implements HasDynamicTitle {
deliveryStationTilesList.remove(removeIdx); deliveryStationTilesList.remove(removeIdx);
deliveryStationsState.remove(removeIdx); deliveryStationsState.remove(removeIdx);
deliveryStationsSaveAddress.remove(removeIdx); deliveryStationsSaveAddress.remove(removeIdx);
deliveryStationsValidatedByGoogle.remove(removeIdx);
deliveryStationTasksState.remove(removeIdx); deliveryStationTasksState.remove(removeIdx);
// Re-index tasks state for remaining stations // Re-index tasks state for remaining stations
Map<Integer, List<BaseTask>> reindexed = new HashMap<>(); Map<Integer, List<BaseTask>> reindexed = new HashMap<>();
@@ -737,12 +749,8 @@ public class AddJobView extends Main implements HasDynamicTitle {
savePickupAddress.setValue(data.isSaveAddress()); savePickupAddress.setValue(data.isSaveAddress());
// Sync appointment fields for binder/submit // Sync appointment fields for binder/submit
if (data.getAppointmentDate() != null) {
pickupDate.setValue(data.getAppointmentDate()); pickupDate.setValue(data.getAppointmentDate());
}
if (data.getAppointmentTime() != null) {
pickupTime.setValue(data.getAppointmentTime()); pickupTime.setValue(data.getAppointmentTime());
}
digitalProcessing.setValue(data.isDigitalProcessing()); digitalProcessing.setValue(data.isDigitalProcessing());
if (data.getAppUser() != null) { if (data.getAppUser() != null) {
appUser.setValue(data.getAppUser()); appUser.setValue(data.getAppUser());
@@ -758,8 +766,12 @@ public class AddJobView extends Main implements HasDynamicTitle {
// Update tile preview // Update tile preview
pickupTile.updatePreview(data.getCompany(), data.getFirstName(), data.getLastName(), pickupTile.updatePreview(data.getCompany(), data.getFirstName(), data.getLastName(),
data.getStreet(), data.getHouseNumber(), data.getZip(), data.getCity()); data.getStreet(), data.getHouseNumber(), data.getZip(), data.getCity(),
pickupTile.setAddressValidated(data.isAddressValidatedByGoogle()); buildPickupPreviewDetails(data.getAppointmentDate(), data.getAppointmentTime(),
data.isDigitalProcessing(), data.getAppUser(), data.getCargoItems()));
pickupAddressValidatedByGoogle = data.isAddressValidatedByGoogle();
storePickupAddressValidationResult(data.getAddressValidationResult());
pickupTile.setAddressValidated(pickupAddressValidatedByGoogle);
resetRouteInformation(); resetRouteInformation();
resetStationsAppliedState(); resetStationsAppliedState();
@@ -787,11 +799,191 @@ public class AddJobView extends Main implements HasDynamicTitle {
currentData.setDigitalProcessing(digitalProcessing.getValue()); currentData.setDigitalProcessing(digitalProcessing.getValue());
currentData.setAppUser(appUser.getValue()); currentData.setAppUser(appUser.getValue());
currentData.setCargoItems(new ArrayList<>(cargoItemsState)); currentData.setCargoItems(new ArrayList<>(cargoItemsState));
currentData.setAddressValidatedByGoogle(pickupAddressValidatedByGoogle);
dialog.setData(currentData); dialog.setData(currentData);
dialog.open(); dialog.open();
} }
private List<String> buildPickupPreviewDetails(LocalDate appointmentDate, LocalTime appointmentTime,
boolean digitalProcessingEnabled, AppUser assignedAppUser, List<CargoItem> cargoItems) {
List<String> 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<CargoItem> cargoItems) {
if (cargoItems == null || cargoItems.isEmpty()) {
return getTranslation("jobsummary.cargo.none");
}
List<String> 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<String> buildDeliveryPreviewDetails(List<BaseTask> tasks) {
if (tasks == null || tasks.isEmpty()) {
return List.of(getTranslation("addjob.tab.tasks") + ": " + getTranslation("jobsummary.tasks.none"));
}
List<String> 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) { private void openDeliveryDialog(StationTile tile, int stationIndex) {
// Ensure index is valid (could have changed due to deletions) // Ensure index is valid (could have changed due to deletions)
int actualIndex = deliveryStationTilesList.indexOf(tile); int actualIndex = deliveryStationTilesList.indexOf(tile);
@@ -822,6 +1014,7 @@ public class AddJobView extends Main implements HasDynamicTitle {
station.setAddressAddition(data.getAddressAddition()); station.setAddressAddition(data.getAddressAddition());
station.setZip(data.getZip()); station.setZip(data.getZip());
station.setCity(data.getCity()); station.setCity(data.getCity());
station.setTasks(data.getTasks() != null ? new ArrayList<>(data.getTasks()) : new ArrayList<>());
deliveryStationsSaveAddress.set(idx, data.isSaveAddress()); deliveryStationsSaveAddress.set(idx, data.isSaveAddress());
// Store tasks for this delivery station // Store tasks for this delivery station
@@ -829,7 +1022,10 @@ public class AddJobView extends Main implements HasDynamicTitle {
// Update tile preview // Update tile preview
tile.updatePreview(data.getCompany(), data.getFirstName(), data.getLastName(), data.getStreet(), 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()); tile.setAddressValidated(data.isAddressValidatedByGoogle());
resetRouteInformation(); resetRouteInformation();
@@ -863,6 +1059,9 @@ public class AddJobView extends Main implements HasDynamicTitle {
currentData.setZip(station.getZip()); currentData.setZip(station.getZip());
currentData.setCity(station.getCity()); currentData.setCity(station.getCity());
currentData.setSaveAddress(deliveryStationsSaveAddress.get(actualIndex)); currentData.setSaveAddress(deliveryStationsSaveAddress.get(actualIndex));
if (actualIndex < deliveryStationsValidatedByGoogle.size()) {
currentData.setAddressValidatedByGoogle(deliveryStationsValidatedByGoogle.get(actualIndex));
}
// Load existing tasks for this station // Load existing tasks for this station
List<BaseTask> stationTasks = deliveryStationTasksState.get(actualIndex); List<BaseTask> stationTasks = deliveryStationTasksState.get(actualIndex);
if (stationTasks != null) { if (stationTasks != null) {
@@ -1369,6 +1568,7 @@ public class AddJobView extends Main implements HasDynamicTitle {
for (int i = 0; i < deliveryStationsState.size(); i++) { for (int i = 0; i < deliveryStationsState.size(); i++) {
DeliveryStation station = deliveryStationsState.get(i); DeliveryStation station = deliveryStationsState.get(i);
station.setStationOrder(i); station.setStationOrder(i);
station.setTasks(new ArrayList<>(deliveryStationTasksState.getOrDefault(i, List.of())));
stations.add(station); stations.add(station);
} }
job.setDeliveryStations(stations); job.setDeliveryStations(stations);
@@ -1697,6 +1897,212 @@ public class AddJobView extends Main implements HasDynamicTitle {
return null; 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<AddressValidationResult> 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 * Registriert ValueChangeListener für alle Adressfelder, um bei Änderungen die
* Streckeninformationen zurückzusetzen. * Streckeninformationen zurückzusetzen.
@@ -1781,6 +2187,12 @@ public class AddJobView extends Main implements HasDynamicTitle {
if (routeInfoBox != null) { if (routeInfoBox != null) {
routeInfoBox.setVisible(false); routeInfoBox.setVisible(false);
} }
if (routeDistanceLabel != null) {
routeDistanceLabel.setText("-");
}
if (routeDurationLabel != null) {
routeDurationLabel.setText("-");
}
if (manualRouteInputBox != null) { if (manualRouteInputBox != null) {
manualRouteInputBox.setVisible(true); manualRouteInputBox.setVisible(true);
} }

View File

@@ -31,6 +31,7 @@ import de.assecutor.votianlt.model.task.ConfirmationTask;
import de.assecutor.votianlt.model.task.BarcodeTask; import de.assecutor.votianlt.model.task.BarcodeTask;
import de.assecutor.votianlt.model.task.CommentTask; import de.assecutor.votianlt.model.task.CommentTask;
import de.assecutor.votianlt.model.AppUser; 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 de.assecutor.votianlt.pages.base.ui.component.ViewToolbar;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import de.assecutor.votianlt.repository.CargoItemRepository; import de.assecutor.votianlt.repository.CargoItemRepository;
@@ -185,94 +186,7 @@ public class JobSummaryView extends Main implements HasUrlParameter<String>, Has
private void render(Job job, List<CargoItem> cargoItems, List<BaseTask> tasks) { private void render(Job job, List<CargoItem> cargoItems, List<BaseTask> tasks) {
content.removeAll(); content.removeAll();
// Kopfzeile: Abholung/Lieferung content.add(createStationTilesSection(job, tasks));
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<DeliveryStation> 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);
// Fracht und weitere Infos // Fracht und weitere Infos
HorizontalLayout midRow = new HorizontalLayout(); HorizontalLayout midRow = new HorizontalLayout();
@@ -392,6 +306,179 @@ public class JobSummaryView extends Main implements HasUrlParameter<String>, Has
return box; return box;
} }
private Div createStationTilesSection(Job job, List<BaseTask> 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<DeliveryStation> 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<String> 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<BaseTask> tasks) {
String title = getTranslation("jobsummary.section.delivery") + " "
+ (stationCount > 1 ? (index + 1) + " " : "")
+ formatDateWithTime(station.getDeliveryDate(), station.getDeliveryTime());
List<String> 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<BaseTask> stationTasks = getTasksForStation(station, tasks, false);
tile.setInteractive(true);
tile.setClickListener(clickedTile -> showStationTasksDialog(title, stationTasks));
return tile;
}
private StationTile createLegacyDeliverySummaryTile(Job job, List<BaseTask> tasks) {
String title = getTranslation("jobsummary.section.delivery") + " "
+ formatDateWithTime(job.getDeliveryDate(), job.getDeliveryTime());
List<String> 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<BaseTask> 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<String> 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<String> 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<BaseTask> getTasksForStation(DeliveryStation station, List<BaseTask> 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<BaseTask> 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<BaseTask> 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) { private String formatLocalDate(java.time.LocalDate date) {
try { try {
return DateTimeFormatUtil.formatDate(date); return DateTimeFormatUtil.formatDate(date);
@@ -1054,12 +1141,28 @@ public class JobSummaryView extends Main implements HasUrlParameter<String>, Has
taskName.getStyle().set("font-weight", "500").set("font-size", "var(--lumo-font-size-m)").set("color", 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)"); 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 // Task status/description
Span taskDescription = new Span(getTaskDescription(task)); Span taskDescription = new Span(getTaskDescription(task));
taskDescription.getStyle().set("font-size", "var(--lumo-font-size-s)") taskDescription.getStyle().set("font-size", "var(--lumo-font-size-s)")
.set("color", "var(--lumo-secondary-text-color)").set("margin-top", "var(--lumo-space-xs)"); .set("color", "var(--lumo-secondary-text-color)").set("margin-top", "var(--lumo-space-xs)");
taskContent.add(taskName, taskDescription); taskContent.add(headerRow, taskDescription);
// Status indicator // Status indicator
Div statusIndicator = new Div(); Div statusIndicator = new Div();
@@ -1097,9 +1200,13 @@ public class JobSummaryView extends Main implements HasUrlParameter<String>, Has
} }
} }
private String getTaskStatusLabel(BaseTask task) {
return task.isCompleted() ? "Abgeschlossen" : "Offen";
}
private String getTaskDescription(BaseTask task) { private String getTaskDescription(BaseTask task) {
if (task.isCompleted()) { if (task.isCompleted()) {
return "Abgeschlossen" return "Erledigt"
+ (task.getCompletedAt() != null ? " am " + formatLocalDate(task.getCompletedAt().toLocalDate()) + (task.getCompletedAt() != null ? " am " + formatLocalDate(task.getCompletedAt().toLocalDate())
: ""); : "");
} }