diff --git a/src/main/java/de/assecutor/votianlt/messaging/MessagingPublisher.java b/src/main/java/de/assecutor/votianlt/messaging/MessagingPublisher.java index 111efe9..3e49d70 100644 --- a/src/main/java/de/assecutor/votianlt/messaging/MessagingPublisher.java +++ b/src/main/java/de/assecutor/votianlt/messaging/MessagingPublisher.java @@ -84,16 +84,12 @@ class MessagingPublisherImpl implements MessagingPublisher { return convertToTranslatedJson(dto, translations); } - if (payload instanceof List list && !list.isEmpty() - && list.get(0) instanceof JobWithRelatedDataDTO) { + 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(); + List allTexts = dtoList.stream().flatMap(d -> collectTexts(d).stream()).distinct().toList(); Map> translations = translationService .translateBatch(allTexts); diff --git a/src/main/java/de/assecutor/votianlt/model/DeliveryStation.java b/src/main/java/de/assecutor/votianlt/model/DeliveryStation.java new file mode 100644 index 0000000..938a65d --- /dev/null +++ b/src/main/java/de/assecutor/votianlt/model/DeliveryStation.java @@ -0,0 +1,59 @@ +package de.assecutor.votianlt.model; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.data.mongodb.core.mapping.Field; + +import java.time.LocalDate; +import java.time.LocalTime; + +/** + * Embedded delivery station within a Job. Each job can have up to 25 delivery + * stations. This is NOT a standalone MongoDB document - it is stored as part of + * the Job document. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class DeliveryStation { + + @Field("station_order") + private int stationOrder; + + @Field("company") + private String company; + + @Field("salutation") + private String salutation; + + @Field("first_name") + private String firstName; + + @Field("last_name") + private String lastName; + + @Field("phone") + private String phone; + + @Field("street") + private String street; + + @Field("house_number") + private String houseNumber; + + @Field("address_addition") + private String addressAddition; + + @Field("zip") + private String zip; + + @Field("city") + private String city; + + @Field("delivery_date") + private LocalDate deliveryDate; + + @Field("delivery_time") + private LocalTime deliveryTime; +} diff --git a/src/main/java/de/assecutor/votianlt/model/Job.java b/src/main/java/de/assecutor/votianlt/model/Job.java index 5165547..e772c61 100644 --- a/src/main/java/de/assecutor/votianlt/model/Job.java +++ b/src/main/java/de/assecutor/votianlt/model/Job.java @@ -12,7 +12,9 @@ import java.time.LocalDate; import java.time.LocalDateTime; import java.time.LocalTime; import java.math.BigDecimal; +import java.util.ArrayList; import java.util.List; +import java.util.stream.Collectors; @Data @Document(collection = "jobs") @@ -158,6 +160,10 @@ public class Job { @Field("invoice_id") private String invoiceId; + // Lieferstationen (bis zu 25) + @Field("delivery_stations") + private List deliveryStations = new ArrayList<>(); + /** * Returns the ObjectId as string for JSON serialization. This ensures that the * job id is returned as a string when jobs are retrieved via API. @@ -166,4 +172,59 @@ public class Job { public String getIdAsString() { return id != null ? id.toString() : null; } + + /** + * Returns the first delivery station's city. Falls back to the flat + * deliveryCity field for backward compatibility with old jobs. + */ + public String getFirstDeliveryCity() { + if (deliveryStations != null && !deliveryStations.isEmpty()) { + return deliveryStations.get(0).getCity(); + } + return deliveryCity; + } + + /** + * Returns the last delivery station's city for route display. + */ + public String getLastDeliveryCity() { + if (deliveryStations != null && !deliveryStations.isEmpty()) { + return deliveryStations.get(deliveryStations.size() - 1).getCity(); + } + return deliveryCity; + } + + /** + * Returns all delivery cities joined with arrows for display (e.g. "Berlin → + * Dresden → München"). + */ + public String getDeliveryCitiesDisplay() { + if (deliveryStations != null && !deliveryStations.isEmpty()) { + return deliveryStations.stream().map(DeliveryStation::getCity).filter(c -> c != null && !c.isBlank()) + .collect(Collectors.joining(" \u2192 ")); + } + return deliveryCity; + } + + /** + * Populates the flat delivery fields from the first delivery station for + * backward compatibility. Call this before saving when using delivery stations. + */ + public void syncFlatDeliveryFieldsFromStations() { + if (deliveryStations != null && !deliveryStations.isEmpty()) { + DeliveryStation first = deliveryStations.get(0); + this.deliveryCompany = first.getCompany(); + this.deliverySalutation = first.getSalutation(); + this.deliveryFirstName = first.getFirstName(); + this.deliveryLastName = first.getLastName(); + this.deliveryPhone = first.getPhone(); + this.deliveryStreet = first.getStreet(); + this.deliveryHouseNumber = first.getHouseNumber(); + this.deliveryAddressAddition = first.getAddressAddition(); + this.deliveryZip = first.getZip(); + this.deliveryCity = first.getCity(); + this.deliveryDate = first.getDeliveryDate(); + this.deliveryTime = first.getDeliveryTime(); + } + } } \ No newline at end of file diff --git a/src/main/java/de/assecutor/votianlt/model/TranslationCacheEntry.java b/src/main/java/de/assecutor/votianlt/model/TranslationCacheEntry.java index 8ccb09b..a53dcbc 100644 --- a/src/main/java/de/assecutor/votianlt/model/TranslationCacheEntry.java +++ b/src/main/java/de/assecutor/votianlt/model/TranslationCacheEntry.java @@ -12,8 +12,8 @@ import java.time.LocalDateTime; import java.util.Map; /** - * MongoDB document for caching LLM translations. Stores the original text, - * all translations keyed by language code, and the insertion timestamp. + * MongoDB document for caching LLM translations. Stores the original text, all + * translations keyed by language code, and the insertion timestamp. */ @Data @NoArgsConstructor diff --git a/src/main/java/de/assecutor/votianlt/pages/base/ui/component/DeliveryStationTile.java b/src/main/java/de/assecutor/votianlt/pages/base/ui/component/DeliveryStationTile.java new file mode 100644 index 0000000..69744fd --- /dev/null +++ b/src/main/java/de/assecutor/votianlt/pages/base/ui/component/DeliveryStationTile.java @@ -0,0 +1,413 @@ +package de.assecutor.votianlt.pages.base.ui.component; + +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.button.ButtonVariant; +import com.vaadin.flow.component.checkbox.Checkbox; +import com.vaadin.flow.component.combobox.ComboBox; +import com.vaadin.flow.component.html.H3; +import com.vaadin.flow.component.icon.Icon; +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 com.vaadin.flow.component.textfield.TextField; +import de.assecutor.votianlt.model.Customer; +import de.assecutor.votianlt.model.DeliveryStation; + +import java.util.List; +import java.util.Optional; + +/** + * A self-contained tile for one delivery station in the AddJob form. Contains + * all address fields, delivery date/time, and a save-address checkbox. + */ +public class DeliveryStationTile extends VerticalLayout { + + public interface ChangeListener { + void onChanged(); + } + + public interface DeleteListener { + void onDelete(DeliveryStationTile tile); + } + + private final int stationNumber; + + private final ComboBox company; + private final ComboBox salutation; + private final TextField firstName; + private final TextField lastName; + private final TextField phone; + private final TextField street; + private final TextField houseNumber; + private final TextField addressAddition; + private final TextField zip; + private final TextField city; + private final Checkbox saveAddress; + private final H3 title; + + private ChangeListener changeListener; + private DeleteListener deleteListener; + + public DeliveryStationTile(int stationNumber, boolean removable, List customers, + TranslationHelper translationHelper) { + this.stationNumber = stationNumber; + + setSpacing(true); + setPadding(true); + setWidth("40%"); + getStyle().set("min-width", "300px"); + getStyle().set("flex-shrink", "0"); + getStyle().set("border", "1px solid var(--lumo-contrast-20pct)"); + getStyle().set("border-radius", "var(--lumo-border-radius-m)"); + getStyle().set("background-color", "var(--lumo-base-color)"); + + // Header + title = new H3(translationHelper.getTranslation("addjob.station.delivery", stationNumber)); + title.getStyle().set("margin", "0"); + + HorizontalLayout titleLayout = new HorizontalLayout(); + titleLayout.setWidthFull(); + titleLayout.setJustifyContentMode(FlexComponent.JustifyContentMode.BETWEEN); + titleLayout.setDefaultVerticalComponentAlignment(FlexComponent.Alignment.CENTER); + titleLayout.add(title); + + Button deleteButton = new Button(new Icon(VaadinIcon.CLOSE_SMALL)); + deleteButton.addThemeVariants(ButtonVariant.LUMO_ERROR, ButtonVariant.LUMO_TERTIARY); + if (removable) { + deleteButton.addClickListener(e -> { + if (deleteListener != null) { + deleteListener.onDelete(this); + } + }); + } else { + deleteButton.getStyle().set("visibility", "hidden"); + } + titleLayout.add(deleteButton); + + add(titleLayout); + + // Company with autocomplete + company = new ComboBox<>(translationHelper.getTranslation("profile.company")); + company.setPlaceholder(translationHelper.getTranslation("addjob.address.company.placeholder")); + company.setAllowCustomValue(true); + company.setWidthFull(); + setupCompanyAutocomplete(company, customers, translationHelper); + add(company); + + // Salutation + salutation = new ComboBox<>(translationHelper.getTranslation("addjob.address.salutation")); + salutation.setItems(translationHelper.getTranslation("addjob.salutation.mr"), + translationHelper.getTranslation("addjob.salutation.ms"), + translationHelper.getTranslation("addjob.salutation.other")); + salutation.setPlaceholder(translationHelper.getTranslation("addjob.address.salutation.placeholder")); + salutation.setWidthFull(); + add(salutation); + + // Name fields + firstName = new TextField(translationHelper.getTranslation("profile.firstname")); + firstName.setPlaceholder(translationHelper.getTranslation("profile.firstname")); + firstName.setRequiredIndicatorVisible(true); + firstName.setWidthFull(); + add(firstName); + + lastName = new TextField(translationHelper.getTranslation("profile.lastname")); + lastName.setPlaceholder(translationHelper.getTranslation("profile.lastname")); + lastName.setRequiredIndicatorVisible(true); + lastName.setWidthFull(); + add(lastName); + + // Phone + phone = new TextField(translationHelper.getTranslation("profile.phone")); + phone.setPlaceholder(translationHelper.getTranslation("profile.phone")); + phone.setWidthFull(); + add(phone); + + // Street + house number + street = new TextField(translationHelper.getTranslation("profile.street")); + street.setPlaceholder(translationHelper.getTranslation("addjob.address.delivery.street.placeholder")); + street.setRequiredIndicatorVisible(true); + + houseNumber = new TextField(translationHelper.getTranslation("profile.housenr")); + houseNumber.setPlaceholder(translationHelper.getTranslation("addjob.address.housenumber")); + houseNumber.setRequiredIndicatorVisible(true); + + HorizontalLayout streetLayout = new HorizontalLayout(); + streetLayout.setWidthFull(); + streetLayout.setSpacing(true); + street.setWidth("70%"); + houseNumber.setWidth("30%"); + streetLayout.add(street, houseNumber); + add(streetLayout); + + // Address addition + addressAddition = new TextField(translationHelper.getTranslation("profile.addressadd")); + addressAddition + .setPlaceholder(translationHelper.getTranslation("addjob.address.delivery.addition.placeholder")); + addressAddition.setWidthFull(); + add(addressAddition); + + // Zip + city + zip = new TextField(translationHelper.getTranslation("profile.zip")); + zip.setPlaceholder(translationHelper.getTranslation("profile.zip")); + zip.setRequiredIndicatorVisible(true); + + city = new TextField(translationHelper.getTranslation("addjob.address.city")); + city.setPlaceholder(translationHelper.getTranslation("addjob.address.city.placeholder.delivery")); + city.setRequiredIndicatorVisible(true); + + HorizontalLayout zipCityLayout = new HorizontalLayout(); + zipCityLayout.setWidthFull(); + zipCityLayout.setSpacing(true); + zip.setWidth("30%"); + city.setWidth("70%"); + zipCityLayout.add(zip, city); + add(zipCityLayout); + + // Save address checkbox + saveAddress = new Checkbox(translationHelper.getTranslation("addjob.address.save")); + saveAddress.setValue(true); + saveAddress.setWidthFull(); + add(saveAddress); + + // Register change listeners on all fields + setupChangeListeners(); + } + + private void setupChangeListeners() { + TextField[] textFields = { firstName, lastName, street, houseNumber, zip, city, phone, addressAddition }; + for (TextField field : textFields) { + field.addValueChangeListener(e -> { + updateFieldStyling(field); + fireChanged(); + }); + } + + company.addValueChangeListener(e -> fireChanged()); + salutation.addValueChangeListener(e -> fireChanged()); + } + + private void fireChanged() { + if (changeListener != null) { + changeListener.onChanged(); + } + } + + private void setupCompanyAutocomplete(ComboBox companyField, List customers, + TranslationHelper translationHelper) { + List companyNames = customers.stream().map(Customer::getCompanyName) + .filter(name -> name != null && !name.trim().isEmpty()).distinct().sorted().toList(); + + companyField.setItems(companyNames); + + companyField.addValueChangeListener(event -> { + String selectedCompany = event.getValue(); + if (selectedCompany == null || selectedCompany.trim().isEmpty()) { + 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()); + + } + }); + + companyField.addCustomValueSetListener(event -> { + companyField.setValue(event.getDetail()); + }); + } + + /** + * Updates the station number displayed in the title. + */ + public void updateStationNumber(int newNumber) { + title.setText(getTranslation("addjob.station.delivery", newNumber)); + } + + /** + * Collects all field values into a DeliveryStation object. + */ + public DeliveryStation getDeliveryStation() { + DeliveryStation station = new DeliveryStation(); + station.setCompany(company.getValue()); + station.setSalutation(salutation.getValue()); + station.setFirstName(firstName.getValue()); + station.setLastName(lastName.getValue()); + station.setPhone(phone.getValue()); + station.setStreet(street.getValue()); + station.setHouseNumber(houseNumber.getValue()); + station.setAddressAddition(addressAddition.getValue()); + station.setZip(zip.getValue()); + station.setCity(city.getValue()); + return station; + } + + /** + * Populates the tile fields from an existing DeliveryStation. + */ + public void setDeliveryStation(DeliveryStation station) { + if (station == null) + return; + if (station.getCompany() != null) + company.setValue(station.getCompany()); + if (station.getSalutation() != null) + salutation.setValue(station.getSalutation()); + if (station.getFirstName() != null) + firstName.setValue(station.getFirstName()); + if (station.getLastName() != null) + lastName.setValue(station.getLastName()); + if (station.getPhone() != null) + phone.setValue(station.getPhone()); + if (station.getStreet() != null) + street.setValue(station.getStreet()); + if (station.getHouseNumber() != null) + houseNumber.setValue(station.getHouseNumber()); + if (station.getAddressAddition() != null) + addressAddition.setValue(station.getAddressAddition()); + if (station.getZip() != null) + zip.setValue(station.getZip()); + if (station.getCity() != null) + city.setValue(station.getCity()); + } + + /** + * Checks if all required address fields are filled. + */ + public boolean hasValidationErrors() { + return isFieldEmpty(firstName) || isFieldEmpty(lastName) || isFieldEmpty(street) || isFieldEmpty(houseNumber) + || isFieldEmpty(zip) || isFieldEmpty(city); + } + + /** + * Applies error styling to empty required fields. + */ + public void highlightErrors() { + TextField[] required = { firstName, lastName, street, houseNumber, zip, city }; + for (TextField field : required) { + updateFieldStyling(field); + } + } + + /** + * Clears all fields in this tile. + */ + public void clearFields() { + company.clear(); + salutation.clear(); + firstName.clear(); + lastName.clear(); + phone.clear(); + street.clear(); + houseNumber.clear(); + addressAddition.clear(); + zip.clear(); + city.clear(); + saveAddress.setValue(true); + } + + /** + * Returns the street value for address validation. + */ + public String getStreetValue() { + return getValueOrEmpty(street); + } + + /** + * Returns the house number value for address validation. + */ + public String getHouseNumberValue() { + return getValueOrEmpty(houseNumber); + } + + /** + * Returns the zip value for address validation. + */ + public String getZipValue() { + return getValueOrEmpty(zip); + } + + /** + * Returns the city value for address validation. + */ + public String getCityValue() { + return getValueOrEmpty(city); + } + + /** + * Checks if the delivery address has enough data for validation. + */ + public boolean hasAddressForValidation() { + return !getStreetValue().isEmpty() && !getZipValue().isEmpty() && !getCityValue().isEmpty(); + } + + public void setChangeListener(ChangeListener listener) { + this.changeListener = listener; + } + + public void setDeleteListener(DeleteListener listener) { + this.deleteListener = listener; + } + + /** + * Returns whether the user wants to save this address as a customer. + */ + public boolean isSaveAddressChecked() { + return saveAddress.getValue(); + } + + public int getStationNumber() { + return stationNumber; + } + + private boolean isFieldEmpty(TextField field) { + String value = field.getValue(); + return value == null || value.trim().isEmpty(); + } + + private String getValueOrEmpty(TextField field) { + return field.getValue() != null ? field.getValue().trim() : ""; + } + + private void updateFieldStyling(TextField field) { + boolean isEmpty = isFieldEmpty(field); + if (isEmpty && field.isRequiredIndicatorVisible()) { + field.getStyle().set("--vaadin-input-field-background", "rgba(255, 0, 0, 0.1)"); + field.getStyle().set("--vaadin-input-field-border-color", "rgba(255, 0, 0, 0.3)"); + } else { + field.getStyle().remove("--vaadin-input-field-background"); + field.getStyle().remove("--vaadin-input-field-border-color"); + } + } + + /** + * Functional interface for accessing translations from the parent view. + */ + @FunctionalInterface + public interface TranslationHelper { + String getTranslation(String key, Object... params); + } +} 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 64617f6..d60a7a3 100644 --- a/src/main/java/de/assecutor/votianlt/pages/view/AddJobView.java +++ b/src/main/java/de/assecutor/votianlt/pages/view/AddJobView.java @@ -61,8 +61,10 @@ import java.math.RoundingMode; import jakarta.annotation.security.RolesAllowed; import lombok.extern.slf4j.Slf4j; import de.assecutor.votianlt.model.CargoItem; +import de.assecutor.votianlt.model.DeliveryStation; import de.assecutor.votianlt.model.AddressValidationResult; import de.assecutor.votianlt.model.RouteCalculationResult; +import de.assecutor.votianlt.pages.base.ui.component.DeliveryStationTile; import java.time.LocalDate; import java.util.*; import java.util.Objects; @@ -104,18 +106,11 @@ public class AddJobView extends Main implements HasDynamicTitle { private TextField pickupCity; private Checkbox savePickupAddress; - // Delivery address fields - private ComboBox deliveryCompany; - private ComboBox deliverySalutation; - private TextField deliveryFirstName; - private TextField deliveryLastName; - private TextField deliveryPhone; - private TextField deliveryStreet; - private TextField deliveryHouseNumber; - private TextField deliveryAddressAddition; - private TextField deliveryZip; - private TextField deliveryCity; - private Checkbox saveDeliveryAddress; + // Delivery station tiles (up to 25) + private final List deliveryStationTiles = new ArrayList<>(); + private Div stationsScrollContainer; + private Div addStationButton; + private static final int MAX_DELIVERY_STATIONS = 25; // Digital processing private Checkbox digitalProcessing; @@ -140,11 +135,9 @@ public class AddJobView extends Main implements HasDynamicTitle { // Date picker fields for appointments private DatePicker pickupDate; - private DatePicker deliveryDate; // Time picker fields for appointments private TimePicker pickupTime; - private TimePicker deliveryTime; private com.vaadin.flow.component.tabs.Tab addressesTab; private com.vaadin.flow.component.tabs.Tab appointmentsTab; @@ -169,7 +162,6 @@ public class AddJobView extends Main implements HasDynamicTitle { private ComboBox templateComboBox; private TextArea remarkArea; private VerticalLayout pickupSection; - private VerticalLayout deliverySection; private final Binder binder = new Binder<>(Job.class); @@ -349,39 +341,7 @@ public class AddJobView extends Main implements HasDynamicTitle { savePickupAddress = new Checkbox(getTranslation("addjob.address.save")); savePickupAddress.setValue(true); - // Delivery address - deliveryCompany = new ComboBox<>(getTranslation("profile.company")); - deliveryCompany.setPlaceholder(getTranslation("addjob.address.company.placeholder")); - deliveryCompany.setAllowCustomValue(true); - setupCompanyAutocomplete(deliveryCompany, false); // false für Delivery - deliverySalutation = new ComboBox<>(getTranslation("addjob.address.salutation")); - deliverySalutation.setItems(getTranslation("addjob.salutation.mr"), getTranslation("addjob.salutation.ms"), - getTranslation("addjob.salutation.other")); - deliverySalutation.setPlaceholder(getTranslation("addjob.address.salutation.placeholder")); - deliveryFirstName = new TextField(getTranslation("profile.firstname")); - deliveryFirstName.setPlaceholder(getTranslation("profile.firstname")); - deliveryFirstName.setRequiredIndicatorVisible(true); - deliveryLastName = new TextField(getTranslation("profile.lastname")); - deliveryLastName.setPlaceholder(getTranslation("profile.lastname")); - deliveryLastName.setRequiredIndicatorVisible(true); - deliveryPhone = new TextField(getTranslation("profile.phone")); - deliveryPhone.setPlaceholder(getTranslation("profile.phone")); - deliveryStreet = new TextField(getTranslation("profile.street")); - deliveryStreet.setPlaceholder(getTranslation("addjob.address.delivery.street.placeholder")); - deliveryStreet.setRequiredIndicatorVisible(true); - deliveryHouseNumber = new TextField(getTranslation("profile.housenr")); - deliveryHouseNumber.setPlaceholder(getTranslation("addjob.address.housenumber")); - deliveryHouseNumber.setRequiredIndicatorVisible(true); - deliveryAddressAddition = new TextField(getTranslation("profile.addressadd")); - deliveryAddressAddition.setPlaceholder(getTranslation("addjob.address.delivery.addition.placeholder")); - deliveryZip = new TextField(getTranslation("profile.zip")); - deliveryZip.setPlaceholder(getTranslation("profile.zip")); - deliveryZip.setRequiredIndicatorVisible(true); - deliveryCity = new TextField(getTranslation("addjob.address.city")); - deliveryCity.setPlaceholder(getTranslation("addjob.address.city.placeholder.delivery")); - deliveryCity.setRequiredIndicatorVisible(true); - saveDeliveryAddress = new Checkbox(getTranslation("addjob.address.save")); - saveDeliveryAddress.setValue(true); + // Delivery station tiles will be created in createCustomerAndAddressesTab() // Digital processing - set value based on user's profile setting digitalProcessing = new Checkbox(getTranslation("profile.settings.digitalprocess")); @@ -414,17 +374,6 @@ public class AddJobView extends Main implements HasDynamicTitle { "Freitag", "Samstag")) .setWeekdaysShort(java.util.Arrays.asList("So", "Mo", "Di", "Mi", "Do", "Fr", "Sa"))); - deliveryDate = new DatePicker(getTranslation("addjob.appointment.date")); - deliveryDate.setRequiredIndicatorVisible(true); - deliveryDate.setMin(LocalDate.now()); - deliveryDate.setLocale(java.util.Locale.GERMANY); // Monday as first day of week - deliveryDate.setI18n(new DatePicker.DatePickerI18n().setFirstDayOfWeek(1) // 1 = Monday - .setMonthNames(java.util.Arrays.asList("Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", - "August", "September", "Oktober", "November", "Dezember")) - .setWeekdays(java.util.Arrays.asList("Sonntag", "Montag", "Dienstag", "Mittwoch", "Donnerstag", - "Freitag", "Samstag")) - .setWeekdaysShort(java.util.Arrays.asList("So", "Mo", "Di", "Mi", "Do", "Fr", "Sa"))); - // Submit button - initially disabled until all required fields are valid submitButton = new Button(getTranslation("addjob.button.submit"), event -> submit()); submitButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY); @@ -493,6 +442,7 @@ public class AddJobView extends Main implements HasDynamicTitle { tabContent.setSizeFull(); tabContent.setPadding(true); tabContent.setSpacing(true); + tabContent.getStyle().set("overflow-y", "auto"); // Customer selection section HorizontalLayout customerLayout = new HorizontalLayout(); @@ -504,29 +454,129 @@ public class AddJobView extends Main implements HasDynamicTitle { tabContent.add(customerLayout); - // Main content layout with two equal columns (50% each) - HorizontalLayout mainLayout = new HorizontalLayout(); - mainLayout.setWidthFull(); - mainLayout.setSpacing(true); - mainLayout.setDefaultVerticalComponentAlignment(FlexComponent.Alignment.START); + // Horizontal scrolling container for pickup + delivery station tiles + stationsScrollContainer = new Div(); + stationsScrollContainer.getStyle().set("display", "flex"); + stationsScrollContainer.getStyle().set("overflow-x", "auto"); + stationsScrollContainer.getStyle().set("gap", "var(--lumo-space-m)"); + stationsScrollContainer.getStyle().set("padding", "var(--lumo-space-s)"); + stationsScrollContainer.getStyle().set("padding-bottom", "20px"); + stationsScrollContainer.getStyle().set("align-items", "flex-start"); + stationsScrollContainer.getStyle().set("flex-shrink", "0"); + stationsScrollContainer.setWidthFull(); - // Left column (50%) - Pickup address section + // Pickup section tile (always present) pickupSection = createPickupSection(); - pickupSection.setWidth("50%"); + pickupSection.setWidth("40%"); + pickupSection.getStyle().set("min-width", "300px"); + pickupSection.getStyle().set("flex-shrink", "0"); + stationsScrollContainer.add(pickupSection); - // Right column (50%) - Delivery address section - deliverySection = createDeliverySection(); - deliverySection.setWidth("50%"); + // "+" add station button tile + addStationButton = createAddStationButton(); + stationsScrollContainer.add(addStationButton); - // Setup focus listeners for input fields + // Add first delivery station tile + addDeliveryStationTile(); + + // Setup focus listeners for pickup input fields setupInputFieldFocusListeners(); - mainLayout.add(pickupSection, deliverySection); - tabContent.add(mainLayout); + tabContent.add(stationsScrollContainer); return tabContent; } + private Div createAddStationButton() { + Div button = new Div(); + button.getStyle().set("min-width", "300px"); + button.getStyle().set("width", "40%"); + button.getStyle().set("min-height", "200px"); + button.getStyle().set("flex-shrink", "0"); + button.getStyle().set("border", "2px dashed var(--lumo-contrast-30pct)"); + button.getStyle().set("border-radius", "var(--lumo-border-radius-m)"); + button.getStyle().set("display", "flex"); + button.getStyle().set("align-items", "center"); + button.getStyle().set("justify-content", "center"); + button.getStyle().set("cursor", "pointer"); + button.getStyle().set("background-color", "var(--lumo-contrast-5pct)"); + button.getStyle().set("transition", "background-color 0.2s"); + + Icon plusIcon = new Icon(VaadinIcon.PLUS); + plusIcon.setSize("64px"); + plusIcon.getStyle().set("color", "var(--lumo-contrast-40pct)"); + button.add(plusIcon); + + button.addClickListener(e -> { + if (deliveryStationTiles.size() >= MAX_DELIVERY_STATIONS) { + Notification.show(getTranslation("addjob.station.max.reached"), 3000, + Notification.Position.BOTTOM_CENTER); + return; + } + addDeliveryStationTile(); + }); + + return button; + } + + private void addDeliveryStationTile() { + int stationNumber = deliveryStationTiles.size() + 1; + boolean removable = deliveryStationTiles.size() > 0; // First station is not removable + + List customers = customerService.findAllForCurrentOwner(); + DeliveryStationTile.TranslationHelper translationHelper = this::getTranslation; + + DeliveryStationTile tile = new DeliveryStationTile(stationNumber, removable, customers, translationHelper); + tile.setChangeListener(() -> { + resetRouteInformation(); + triggerValidation(); + updateTabLabels(); + }); + tile.setDeleteListener(this::removeDeliveryStationTile); + + deliveryStationTiles.add(tile); + + // Insert tile before the "+" button + stationsScrollContainer.remove(addStationButton); + stationsScrollContainer.add(tile); + + // Hide "+" button if max reached + if (deliveryStationTiles.size() < MAX_DELIVERY_STATIONS) { + stationsScrollContainer.add(addStationButton); + } + + triggerValidation(); + updateTabLabels(); + } + + private void removeDeliveryStationTile(DeliveryStationTile tile) { + ConfirmDialog dialog = new ConfirmDialog(); + int idx = deliveryStationTiles.indexOf(tile) + 1; + dialog.setHeader(getTranslation("addjob.station.remove.confirm", idx)); + dialog.setCancelable(true); + dialog.setCancelText(getTranslation("dialog.cancel")); + dialog.setConfirmText(getTranslation("dialog.confirm")); + dialog.addConfirmListener(e -> { + deliveryStationTiles.remove(tile); + stationsScrollContainer.remove(tile); + + // Renumber remaining tiles + for (int i = 0; i < deliveryStationTiles.size(); i++) { + deliveryStationTiles.get(i).updateStationNumber(i + 1); + } + + // Ensure "+" button is visible if under max + if (deliveryStationTiles.size() < MAX_DELIVERY_STATIONS && addStationButton.getParent().isEmpty()) { + stationsScrollContainer.add(addStationButton); + } + + resetRouteInformation(); + triggerValidation(); + updateTabLabels(); + }); + dialog.open(); + } + private Component createAppointmentsAndProcessingTab() { VerticalLayout tabContent = new VerticalLayout(); tabContent.setSizeFull(); @@ -565,17 +615,12 @@ public class AddJobView extends Main implements HasDynamicTitle { pickupTime.setWidth("50%"); content.add(pickupApptTitle, pickupApptRow); - // Appointment (Delivery) - H3 deliveryApptTitle = new H3(getTranslation("addjob.appointment.delivery")); - deliveryApptTitle.getStyle().set("margin", "0"); - deliveryTime = new TimePicker(getTranslation("addjob.appointment.time")); - deliveryTime.setLocale(java.util.Locale.GERMANY); - HorizontalLayout deliveryApptRow = new HorizontalLayout(deliveryDate, deliveryTime); - deliveryApptRow.setWidthFull(); - deliveryApptRow.setSpacing(true); - deliveryDate.setWidth("50%"); - deliveryTime.setWidth("50%"); - content.add(deliveryApptTitle, deliveryApptRow); + // Info: Delivery dates are set per-station in the tiles + Span deliveryInfoLabel = new Span(getTranslation("addjob.appointment.delivery.info")); + deliveryInfoLabel.getStyle().set("color", "var(--lumo-secondary-text-color)"); + deliveryInfoLabel.getStyle().set("font-style", "italic"); + deliveryInfoLabel.getStyle().set("margin-top", "var(--lumo-space-m)"); + content.add(deliveryInfoLabel); tabContent.add(content); return tabContent; @@ -974,10 +1019,16 @@ public class AddJobView extends Main implements HasDynamicTitle { HorizontalLayout titleLayout = new HorizontalLayout(); titleLayout.setWidthFull(); - titleLayout.setJustifyContentMode(FlexComponent.JustifyContentMode.START); + titleLayout.setJustifyContentMode(FlexComponent.JustifyContentMode.BETWEEN); titleLayout.setDefaultVerticalComponentAlignment(FlexComponent.Alignment.CENTER); titleLayout.add(title); + // Invisible placeholder button to match DeliveryStationTile header height + Button placeholder = new Button(new Icon(VaadinIcon.CLOSE_SMALL)); + placeholder.addThemeVariants(ButtonVariant.LUMO_TERTIARY); + placeholder.getStyle().set("visibility", "hidden"); + titleLayout.add(placeholder); + // Alle einzelnen Controls auf volle Breite setzen pickupCompany.setWidthFull(); pickupSalutation.setWidthFull(); @@ -1018,64 +1069,8 @@ public class AddJobView extends Main implements HasDynamicTitle { return section; } - private VerticalLayout createDeliverySection() { - VerticalLayout section = new VerticalLayout(); - section.setSpacing(true); - section.setPadding(true); - section.setWidthFull(); - - // Hellgrauer Rahmen hinzufügen - section.getStyle().set("border", "1px solid var(--lumo-contrast-20pct)"); - section.getStyle().set("border-radius", "var(--lumo-border-radius-m)"); - section.getStyle().set("background-color", "var(--lumo-base-color)"); - - H3 title = new H3(getTranslation("addjob.section.delivery")); - title.getStyle().set("margin", "0"); - - HorizontalLayout titleLayout = new HorizontalLayout(); - titleLayout.setWidthFull(); - titleLayout.setJustifyContentMode(FlexComponent.JustifyContentMode.START); - titleLayout.setDefaultVerticalComponentAlignment(FlexComponent.Alignment.CENTER); - titleLayout.add(title); - - // Alle einzelnen Controls auf volle Breite setzen - deliveryCompany.setWidthFull(); - deliverySalutation.setWidthFull(); - deliveryFirstName.setWidthFull(); - deliveryLastName.setWidthFull(); - deliveryPhone.setWidthFull(); - deliveryAddressAddition.setWidthFull(); - saveDeliveryAddress.setWidthFull(); - - section.add(titleLayout); - section.add(deliveryCompany); - section.add(deliverySalutation); - section.add(deliveryFirstName); - section.add(deliveryLastName); - section.add(deliveryPhone); - - HorizontalLayout streetLayout = new HorizontalLayout(); - streetLayout.setWidthFull(); - streetLayout.setSpacing(true); - streetLayout.add(deliveryStreet, deliveryHouseNumber); - deliveryStreet.setWidth("70%"); - deliveryHouseNumber.setWidth("30%"); - section.add(streetLayout); - - section.add(deliveryAddressAddition); - - HorizontalLayout zipCityLayout = new HorizontalLayout(); - zipCityLayout.setWidthFull(); - zipCityLayout.setSpacing(true); - zipCityLayout.add(deliveryZip, deliveryCity); - deliveryZip.setWidth("30%"); - deliveryCity.setWidth("70%"); - section.add(zipCityLayout); - - section.add(saveDeliveryAddress); - - return section; - } + // createDeliverySection() removed - delivery stations are now handled by + // DeliveryStationTile private void setupCompanyAutocomplete(ComboBox companyField, boolean isPickup) { // Get all customers for the current owner @@ -1088,7 +1083,8 @@ public class AddJobView extends Main implements HasDynamicTitle { // Set items for autocomplete companyField.setItems(companyNames); - // Add selection listener to auto-fill address fields when company is selected + // Add selection listener to auto-fill pickup address fields when company is + // selected companyField.addValueChangeListener(event -> { String selectedCompany = event.getValue(); if (selectedCompany == null || selectedCompany.trim().isEmpty()) { @@ -1105,59 +1101,31 @@ public class AddJobView extends Main implements HasDynamicTitle { if (matchingCustomer.isPresent()) { Customer customer = matchingCustomer.get(); - if (isPickup) { - // Fill pickup address fields - if (customer.getTitle() != null && ("Herr".equalsIgnoreCase(customer.getTitle()) - || "Frau".equalsIgnoreCase(customer.getTitle()) - || "Divers".equalsIgnoreCase(customer.getTitle()))) { - pickupSalutation.setValue(customer.getTitle()); - } - if (customer.getFirstname() != null) - pickupFirstName.setValue(customer.getFirstname()); - if (customer.getLastName() != null) - pickupLastName.setValue(customer.getLastName()); - if (customer.getTelephone() != null) - pickupPhone.setValue(customer.getTelephone()); - if (customer.getStreet() != null) - pickupStreet.setValue(customer.getStreet()); - if (customer.getHouseNumber() != null) - pickupHouseNumber.setValue(customer.getHouseNumber()); - if (customer.getAddressAddition() != null) - pickupAddressAddition.setValue(customer.getAddressAddition()); - if (customer.getZip() != null) - pickupZip.setValue(customer.getZip()); - if (customer.getCity() != null) - pickupCity.setValue(customer.getCity()); - - // Deactivate save checkbox since customer already exists - savePickupAddress.setValue(false); - } else { - // Fill delivery address fields - if (customer.getTitle() != null && ("Herr".equalsIgnoreCase(customer.getTitle()) - || "Frau".equalsIgnoreCase(customer.getTitle()) - || "Divers".equalsIgnoreCase(customer.getTitle()))) { - deliverySalutation.setValue(customer.getTitle()); - } - if (customer.getFirstname() != null) - deliveryFirstName.setValue(customer.getFirstname()); - if (customer.getLastName() != null) - deliveryLastName.setValue(customer.getLastName()); - if (customer.getTelephone() != null) - deliveryPhone.setValue(customer.getTelephone()); - if (customer.getStreet() != null) - deliveryStreet.setValue(customer.getStreet()); - if (customer.getHouseNumber() != null) - deliveryHouseNumber.setValue(customer.getHouseNumber()); - if (customer.getAddressAddition() != null) - deliveryAddressAddition.setValue(customer.getAddressAddition()); - if (customer.getZip() != null) - deliveryZip.setValue(customer.getZip()); - if (customer.getCity() != null) - deliveryCity.setValue(customer.getCity()); - - // Deactivate save checkbox since customer already exists - saveDeliveryAddress.setValue(false); + // Fill pickup address fields + if (customer.getTitle() != null + && ("Herr".equalsIgnoreCase(customer.getTitle()) || "Frau".equalsIgnoreCase(customer.getTitle()) + || "Divers".equalsIgnoreCase(customer.getTitle()))) { + pickupSalutation.setValue(customer.getTitle()); } + if (customer.getFirstname() != null) + pickupFirstName.setValue(customer.getFirstname()); + if (customer.getLastName() != null) + pickupLastName.setValue(customer.getLastName()); + if (customer.getTelephone() != null) + pickupPhone.setValue(customer.getTelephone()); + if (customer.getStreet() != null) + pickupStreet.setValue(customer.getStreet()); + if (customer.getHouseNumber() != null) + pickupHouseNumber.setValue(customer.getHouseNumber()); + if (customer.getAddressAddition() != null) + pickupAddressAddition.setValue(customer.getAddressAddition()); + if (customer.getZip() != null) + pickupZip.setValue(customer.getZip()); + if (customer.getCity() != null) + pickupCity.setValue(customer.getCity()); + + // Deactivate save checkbox since customer already exists + savePickupAddress.setValue(false); } }); @@ -1170,11 +1138,7 @@ public class AddJobView extends Main implements HasDynamicTitle { resetRouteInformation(); // Reactivate save checkbox for custom values - if (isPickup) { - savePickupAddress.setValue(true); - } else { - saveDeliveryAddress.setValue(true); - } + savePickupAddress.setValue(true); }); } @@ -1192,51 +1156,30 @@ public class AddJobView extends Main implements HasDynamicTitle { binder.forField(pickupCity).asRequired("").bind(Job::getPickupCity, Job::setPickupCity); - // Bind delivery address fields with validation - binder.forField(deliveryFirstName).asRequired("").bind(Job::getDeliveryFirstName, Job::setDeliveryFirstName); - - binder.forField(deliveryLastName).asRequired("").bind(Job::getDeliveryLastName, Job::setDeliveryLastName); - - binder.forField(deliveryStreet).asRequired("").bind(Job::getDeliveryStreet, Job::setDeliveryStreet); - - binder.forField(deliveryHouseNumber).asRequired("").bind(Job::getDeliveryHouseNumber, - Job::setDeliveryHouseNumber); - - binder.forField(deliveryZip).asRequired("").bind(Job::getDeliveryZip, Job::setDeliveryZip); - - binder.forField(deliveryCity).asRequired("").bind(Job::getDeliveryCity, Job::setDeliveryCity); + // Delivery address fields are managed by DeliveryStationTile (not binder) // Price wird manuell in submit() berechnet und gesetzt - kein Binder notwendig - // Bind date picker fields with validation + // Bind pickup date field with validation binder.forField(pickupDate).asRequired("") .withValidator(date -> date == null || !date.isBefore(LocalDate.now()), getTranslation("addjob.validation.pickupdate.future")) .bind(Job::getPickupDate, Job::setPickupDate); - binder.forField(deliveryDate).asRequired("") - .withValidator(date -> date == null || !date.isBefore(LocalDate.now()), - getTranslation("addjob.validation.deliverydate.future")) - .bind(Job::getDeliveryDate, Job::setDeliveryDate); + // Delivery dates are now per-station (in DeliveryStationTile) // Bind time picker fields (optional) binder.bind(pickupTime, Job::getPickupTime, Job::setPickupTime); - binder.bind(deliveryTime, Job::getDeliveryTime, Job::setDeliveryTime); // Bind customerSelection field with validation binder.forField(customerSelection).asRequired("").bind(Job::getCustomerSelection, Job::setCustomerSelection); - // Bind optional fields without validation + // Bind optional pickup fields without validation binder.bind(pickupCompany, Job::getPickupCompany, Job::setPickupCompany); binder.bind(pickupSalutation, Job::getPickupSalutation, Job::setPickupSalutation); binder.bind(pickupPhone, Job::getPickupPhone, Job::setPickupPhone); binder.bind(pickupAddressAddition, Job::getPickupAddressAddition, Job::setPickupAddressAddition); - binder.bind(deliveryCompany, Job::getDeliveryCompany, Job::setDeliveryCompany); - binder.bind(deliverySalutation, Job::getDeliverySalutation, Job::setDeliverySalutation); - binder.bind(deliveryPhone, Job::getDeliveryPhone, Job::setDeliveryPhone); - binder.bind(deliveryAddressAddition, Job::getDeliveryAddressAddition, Job::setDeliveryAddressAddition); - binder.forField(digitalProcessing).bind(Job::isDigitalProcessing, (job, value) -> job.setDigitalProcessing(Boolean.TRUE.equals(value))); @@ -1309,11 +1252,10 @@ public class AddJobView extends Main implements HasDynamicTitle { private void setupValidationTriggers() { // List of all required fields TextField[] requiredTextFields = { pickupFirstName, pickupLastName, pickupStreet, pickupHouseNumber, pickupZip, - pickupCity, deliveryFirstName, deliveryLastName, deliveryStreet, deliveryHouseNumber, deliveryZip, - deliveryCity }; + pickupCity }; - // List of required date fields - DatePicker[] requiredDateFields = { pickupDate, deliveryDate }; + // List of required date fields (delivery dates are per-station in tiles) + DatePicker[] requiredDateFields = { pickupDate }; // Add validation listener for customerSelection ComboBox customerSelection.addValueChangeListener(event -> { @@ -1426,18 +1368,20 @@ public class AddJobView extends Main implements HasDynamicTitle { || isFieldEmpty(pickupStreet) || isFieldEmpty(pickupHouseNumber) || isFieldEmpty(pickupZip) || isFieldEmpty(pickupCity); - // Check delivery address fields - boolean deliveryErrors = isFieldEmpty(deliveryFirstName) || isFieldEmpty(deliveryLastName) - || isFieldEmpty(deliveryStreet) || isFieldEmpty(deliveryHouseNumber) || isFieldEmpty(deliveryZip) - || isFieldEmpty(deliveryCity); + // Check all delivery station tiles for errors + boolean deliveryErrors = deliveryStationTiles.isEmpty() + || deliveryStationTiles.stream().anyMatch(DeliveryStationTile::hasValidationErrors); return customerSelectionEmpty || pickupErrors || deliveryErrors; } private boolean hasAppointmentValidationErrors() { LocalDate today = LocalDate.now(); - return pickupDate.getValue() == null || deliveryDate.getValue() == null || pickupDate.getValue().isBefore(today) - || deliveryDate.getValue().isBefore(today); + // Check pickup date + if (pickupDate.getValue() == null || pickupDate.getValue().isBefore(today)) { + return true; + } + return false; } private boolean hasCargoValidationErrors() { @@ -1508,11 +1452,22 @@ public class AddJobView extends Main implements HasDynamicTitle { // Zusätzliche Felder, die nicht über den Binder gebunden sind, manuell setzen job.setPickupDate(pickupDate.getValue()); job.setPickupTime(pickupTime.getValue()); - job.setDeliveryDate(deliveryDate.getValue()); - job.setDeliveryTime(deliveryTime.getValue()); if (remarkArea != null) job.setRemark(remarkArea.getValue()); + // Collect delivery stations from tiles + List stations = new ArrayList<>(); + for (int i = 0; i < deliveryStationTiles.size(); i++) { + DeliveryStationTile tile = deliveryStationTiles.get(i); + DeliveryStation station = tile.getDeliveryStation(); + station.setStationOrder(i); + stations.add(station); + } + job.setDeliveryStations(stations); + + // Populate flat delivery fields from first station for backward compatibility + job.syncFlatDeliveryFieldsFromStations(); + // Store selected service IDs in job for invoice creation job.setServiceIds(selectedServices.stream().map(Service::getId).toList()); @@ -1584,19 +1539,23 @@ public class AddJobView extends Main implements HasDynamicTitle { pickupCustomer.setCity(pickupCity.getValue()); addCustomerService.addCustomer(pickupCustomer); } - if (saveDeliveryAddress.getValue()) { - Customer deliveryCustomer = new Customer(); - deliveryCustomer.setCompanyName(deliveryCompany.getValue()); - deliveryCustomer.setTitle(deliverySalutation.getValue()); - deliveryCustomer.setFirstname(deliveryFirstName.getValue()); - deliveryCustomer.setLastName(deliveryLastName.getValue()); - deliveryCustomer.setTelephone(deliveryPhone.getValue()); - deliveryCustomer.setStreet(deliveryStreet.getValue()); - deliveryCustomer.setHouseNumber(deliveryHouseNumber.getValue()); - deliveryCustomer.setAddressAddition(deliveryAddressAddition.getValue()); - deliveryCustomer.setZip(deliveryZip.getValue()); - deliveryCustomer.setCity(deliveryCity.getValue()); - addCustomerService.addCustomer(deliveryCustomer); + // Save delivery station addresses as customers if checkbox is checked + for (DeliveryStationTile tile : deliveryStationTiles) { + if (tile.isSaveAddressChecked()) { + DeliveryStation ds = tile.getDeliveryStation(); + Customer deliveryCustomer = new Customer(); + deliveryCustomer.setCompanyName(ds.getCompany()); + deliveryCustomer.setTitle(ds.getSalutation()); + deliveryCustomer.setFirstname(ds.getFirstName()); + deliveryCustomer.setLastName(ds.getLastName()); + deliveryCustomer.setTelephone(ds.getPhone()); + deliveryCustomer.setStreet(ds.getStreet()); + deliveryCustomer.setHouseNumber(ds.getHouseNumber()); + deliveryCustomer.setAddressAddition(ds.getAddressAddition()); + deliveryCustomer.setZip(ds.getZip()); + deliveryCustomer.setCity(ds.getCity()); + addCustomerService.addCustomer(deliveryCustomer); + } } // All validations passed, save the job with cargo items and tasks @@ -1986,18 +1945,18 @@ public class AddJobView extends Main implements HasDynamicTitle { pickupCity.clear(); savePickupAddress.setValue(false); - // Delivery address - deliveryCompany.clear(); - deliverySalutation.clear(); - deliveryFirstName.clear(); - deliveryLastName.clear(); - deliveryPhone.clear(); - deliveryStreet.clear(); - deliveryHouseNumber.clear(); - deliveryAddressAddition.clear(); - deliveryZip.clear(); - deliveryCity.clear(); - saveDeliveryAddress.setValue(false); + // Delivery stations - remove all but the first, clear the first + while (deliveryStationTiles.size() > 1) { + DeliveryStationTile tile = deliveryStationTiles.remove(deliveryStationTiles.size() - 1); + stationsScrollContainer.remove(tile); + } + if (!deliveryStationTiles.isEmpty()) { + deliveryStationTiles.get(0).clearFields(); + } + // Ensure "+" button is visible + if (addStationButton.getParent().isEmpty()) { + stationsScrollContainer.add(addStationButton); + } // Digital processing digitalProcessing.setValue(true); @@ -2045,27 +2004,7 @@ public class AddJobView extends Main implements HasDynamicTitle { pickupCity.addFocusListener(e -> disableDragSources()); pickupCity.addBlurListener(e -> enableDragSources()); - // Delivery fields - deliveryCompany.addFocusListener(e -> disableDragSources()); - deliveryCompany.addBlurListener(e -> enableDragSources()); - deliverySalutation.addFocusListener(e -> disableDragSources()); - deliverySalutation.addBlurListener(e -> enableDragSources()); - deliveryFirstName.addFocusListener(e -> disableDragSources()); - deliveryFirstName.addBlurListener(e -> enableDragSources()); - deliveryLastName.addFocusListener(e -> disableDragSources()); - deliveryLastName.addBlurListener(e -> enableDragSources()); - deliveryPhone.addFocusListener(e -> disableDragSources()); - deliveryPhone.addBlurListener(e -> enableDragSources()); - deliveryStreet.addFocusListener(e -> disableDragSources()); - deliveryStreet.addBlurListener(e -> enableDragSources()); - deliveryHouseNumber.addFocusListener(e -> disableDragSources()); - deliveryHouseNumber.addBlurListener(e -> enableDragSources()); - deliveryAddressAddition.addFocusListener(e -> disableDragSources()); - deliveryAddressAddition.addBlurListener(e -> enableDragSources()); - deliveryZip.addFocusListener(e -> disableDragSources()); - deliveryZip.addBlurListener(e -> enableDragSources()); - deliveryCity.addFocusListener(e -> disableDragSources()); - deliveryCity.addBlurListener(e -> enableDragSources()); + // Delivery fields are handled inside DeliveryStationTile // Digital processing appUser.addFocusListener(e -> disableDragSources()); @@ -2080,10 +2019,6 @@ public class AddJobView extends Main implements HasDynamicTitle { pickupSection.getStyle().set("pointer-events", "none"); pickupSection.getElement().setAttribute("draggable", "false"); } - if (deliverySection != null) { - deliverySection.getStyle().set("pointer-events", "none"); - deliverySection.getElement().setAttribute("draggable", "false"); - } } /** @@ -2094,10 +2029,6 @@ public class AddJobView extends Main implements HasDynamicTitle { pickupSection.getStyle().remove("pointer-events"); pickupSection.getElement().setAttribute("draggable", "true"); } - if (deliverySection != null) { - deliverySection.getStyle().remove("pointer-events"); - deliverySection.getElement().setAttribute("draggable", "true"); - } } private void createTaskRow() { @@ -2924,16 +2855,13 @@ public class AddJobView extends Main implements HasDynamicTitle { } /** - * Prüft, ob die Lieferadresse gültig ist (Pflichtfelder ausgefüllt). + * Prüft, ob mindestens eine Lieferstation gültige Adressdaten hat. */ private boolean hasDeliveryAddressChanged() { - String currentStreet = getValueOrEmpty(deliveryStreet); - String currentZip = getValueOrEmpty(deliveryZip); - String currentCity = getValueOrEmpty(deliveryCity); - - // Nur true zurückgeben, wenn alle Pflichtfelder ausgefüllt sind und Validierung - // nötig ist - return addressesDirty && !currentStreet.isEmpty() && !currentZip.isEmpty() && !currentCity.isEmpty(); + if (!addressesDirty) { + return false; + } + return deliveryStationTiles.stream().anyMatch(DeliveryStationTile::hasAddressForValidation); } private String getValueOrEmpty(TextField field) { @@ -3023,16 +2951,19 @@ public class AddJobView extends Main implements HasDynamicTitle { final String pickupZipValue = getValueOrEmpty(pickupZip); final String pickupCityValue = getValueOrEmpty(pickupCity); final boolean pickupChanged = hasPickupAddressChanged(); - - final String deliveryStreetValue = getValueOrEmpty(deliveryStreet); - final String deliveryHouseNumberValue = getValueOrEmpty(deliveryHouseNumber); - final String deliveryZipValue = getValueOrEmpty(deliveryZip); - final String deliveryCityValue = getValueOrEmpty(deliveryCity); final boolean deliveryChanged = hasDeliveryAddressChanged(); + // Capture delivery station data for async validation + final List stationData = new ArrayList<>(); + for (DeliveryStationTile tile : deliveryStationTiles) { + if (tile.hasAddressForValidation()) { + stationData.add(new String[] { tile.getStreetValue(), tile.getHouseNumberValue(), tile.getZipValue(), + tile.getCityValue() }); + } + } + // Asynchrone Validierung im Hintergrund durchführen getUI().ifPresent(ui -> { - // CompletableFuture für Hintergrund-Verarbeitung java.util.concurrent.CompletableFuture.runAsync(() -> { // Abholadresse validieren final AddressValidationResult[] pickupResultHolder = new AddressValidationResult[1]; @@ -3041,45 +2972,67 @@ public class AddJobView extends Main implements HasDynamicTitle { pickupHouseNumberValue, pickupZipValue, pickupCityValue); } - // Lieferadresse validieren - final AddressValidationResult[] deliveryResultHolder = new AddressValidationResult[1]; + // Alle Lieferstationen validieren + final List deliveryResults = new ArrayList<>(); if (deliveryChanged) { - deliveryResultHolder[0] = addressValidationService.validateAddress("delivery", deliveryStreetValue, - deliveryHouseNumberValue, deliveryZipValue, deliveryCityValue); + for (int i = 0; i < stationData.size(); i++) { + String[] data = stationData.get(i); + AddressValidationResult result = addressValidationService.validateAddress("delivery_" + i, + data[0], data[1], data[2], data[3]); + deliveryResults.add(result); + } } // UI aktualisieren mit den Ergebnissen ui.access(() -> { - // Ergebnisse speichern if (pickupResultHolder[0] != null) { addressValidationResults.put("pickup", pickupResultHolder[0]); } - if (deliveryResultHolder[0] != null) { - addressValidationResults.put("delivery", deliveryResultHolder[0]); + for (int i = 0; i < deliveryResults.size(); i++) { + addressValidationResults.put("delivery_" + i, deliveryResults.get(i)); } - // Ergebnisse ermitteln AddressValidationResult pickupResult = pickupResultHolder[0] != null ? pickupResultHolder[0] : addressValidationResults.get("pickup"); - AddressValidationResult deliveryResult = deliveryResultHolder[0] != null ? deliveryResultHolder[0] - : addressValidationResults.get("delivery"); + + // Use first delivery station result for route calculation (backward compat) + AddressValidationResult firstDeliveryResult = !deliveryResults.isEmpty() ? deliveryResults.get(0) + : addressValidationResults.get("delivery_0"); // Lade-Anzeige ausblenden loadingMessage.setVisible(false); progressBar.setVisible(false); - // Prüfen ob beide Adressen gültig sind + // Prüfen ob Abholadresse und erste Lieferadresse gültig sind boolean bothValid = (pickupResult != null && pickupResult.isValid()) - && (deliveryResult != null && deliveryResult.isValid()); + && (firstDeliveryResult != null && firstDeliveryResult.isValid()); // Route berechnen wenn beide gültig if (bothValid) { - routeCalculationResult = addressValidationService.calculateRoute(pickupResult, deliveryResult); + routeCalculationResult = addressValidationService.calculateRoute(pickupResult, + firstDeliveryResult); } - // Ergebnisse anzeigen - updateValidationDialogResults(pickupResult, deliveryResult, pickupResultLabel, deliveryResultLabel, - routeResultLabel, resultLayout, buttonLayout, continueButton, targetTab); + // Ergebnisse anzeigen - show first delivery station result in existing UI + updateValidationDialogResults(pickupResult, firstDeliveryResult, pickupResultLabel, + deliveryResultLabel, routeResultLabel, resultLayout, buttonLayout, continueButton, + targetTab); + + // Show additional station results + for (int i = 1; i < deliveryResults.size(); i++) { + AddressValidationResult stationResult = deliveryResults.get(i); + Span stationLabel = new Span(); + if (stationResult.isValid()) { + stationLabel.setText("\u2713 " + getTranslation("addjob.station.delivery", i + 1) + ": " + + stationResult.getFormattedAddress()); + stationLabel.getStyle().set("color", "var(--lumo-success-text-color)"); + } else { + stationLabel.setText("\u26A0 " + getTranslation("addjob.station.delivery", i + 1) + ": " + + stationResult.getValidationMessage()); + stationLabel.getStyle().set("color", "var(--lumo-error-text-color)"); + } + resultLayout.addComponentAtIndex(resultLayout.getComponentCount() - 1, stationLabel); + } }); }); }); @@ -3209,16 +3162,7 @@ public class AddJobView extends Main implements HasDynamicTitle { pickupZip.getStyle().set("--vaadin-input-field-background", pickupColor); pickupCity.getStyle().set("--vaadin-input-field-background", pickupColor); - // Lieferadresse - hellgrün für validiert, hellgelb für nicht validiert - String deliveryColor = (deliveryResult != null && deliveryResult.isValid()) ? "rgba(144, 238, 144, 0.5)" // Hellgrün - // mit - // Transparenz - : "rgba(255, 250, 205, 0.5)"; // Hellgelb mit Transparenz - - deliveryStreet.getStyle().set("--vaadin-input-field-background", deliveryColor); - deliveryHouseNumber.getStyle().set("--vaadin-input-field-background", deliveryColor); - deliveryZip.getStyle().set("--vaadin-input-field-background", deliveryColor); - deliveryCity.getStyle().set("--vaadin-input-field-background", deliveryColor); + // Delivery station field styling is handled inside the tiles } /** @@ -3238,11 +3182,11 @@ public class AddJobView extends Main implements HasDynamicTitle { } /** - * Gibt das Validierungsergebnis für die Lieferadresse zurück. Kann null sein, - * wenn noch keine Validierung durchgeführt wurde. + * Gibt das Validierungsergebnis für die erste Lieferadresse zurück. Kann null + * sein, wenn noch keine Validierung durchgeführt wurde. */ public AddressValidationResult getDeliveryAddressValidationResult() { - return addressValidationResults.get("delivery"); + return addressValidationResults.get("delivery_0"); } /** @@ -3343,32 +3287,7 @@ public class AddJobView extends Main implements HasDynamicTitle { } }); - // Lieferadress-Felder - deliveryCompany.addValueChangeListener(e -> { - if (e.isFromClient()) { - resetRouteInformation(); - } - }); - deliveryStreet.addValueChangeListener(e -> { - if (e.isFromClient()) { - resetRouteInformation(); - } - }); - deliveryHouseNumber.addValueChangeListener(e -> { - if (e.isFromClient()) { - resetRouteInformation(); - } - }); - deliveryZip.addValueChangeListener(e -> { - if (e.isFromClient()) { - resetRouteInformation(); - } - }); - deliveryCity.addValueChangeListener(e -> { - if (e.isFromClient()) { - resetRouteInformation(); - } - }); + // Delivery station field change listeners are handled via tile callbacks } /** diff --git a/src/main/java/de/assecutor/votianlt/pages/view/CreateInvoiceView.java b/src/main/java/de/assecutor/votianlt/pages/view/CreateInvoiceView.java index 6b84bd2..90d2b23 100644 --- a/src/main/java/de/assecutor/votianlt/pages/view/CreateInvoiceView.java +++ b/src/main/java/de/assecutor/votianlt/pages/view/CreateInvoiceView.java @@ -681,10 +681,10 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter pdfFrame.getStyle().set("border", "none"); Button downloadButton = new Button("Herunterladen", e -> { - parent.getElement().executeJs("const link = document.createElement('a');" - + "link.href = 'data:application/pdf;base64," + base64Pdf + "';" - + "link.download = '" + title.replaceAll("[^a-zA-Z0-9\\-]", "_") + ".pdf';" - + "link.click();"); + parent.getElement() + .executeJs("const link = document.createElement('a');" + "link.href = 'data:application/pdf;base64," + + base64Pdf + "';" + "link.download = '" + title.replaceAll("[^a-zA-Z0-9\\-]", "_") + + ".pdf';" + "link.click();"); }); downloadButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY); diff --git a/src/main/java/de/assecutor/votianlt/pages/view/EditProfileView.java b/src/main/java/de/assecutor/votianlt/pages/view/EditProfileView.java index 0bc4e57..1f72351 100644 --- a/src/main/java/de/assecutor/votianlt/pages/view/EditProfileView.java +++ b/src/main/java/de/assecutor/votianlt/pages/view/EditProfileView.java @@ -877,8 +877,8 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle currentUser, prefixField.getValue()); showPdfInDialog(pdfBytes); } catch (Exception ex) { - Notification.show(getTranslation("profile.invoice.pdf.preview.error", ex.getMessage()), 3000, - Notification.Position.BOTTOM_CENTER); + Notification.show(getTranslation("profile.invoice.pdf.preview.error", ex.getMessage()), + 3000, Notification.Position.BOTTOM_CENTER); } }); } catch (Exception ex) { @@ -1027,10 +1027,10 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle "image"); panel.add(invoiceHeader, senderCompany, senderName, senderAddress, senderCity, senderEmail, senderPhone, - invoiceNumber, servicesHeader, servicesListBlock, servicesNetBlock, servicesVatBlock, servicesGrossBlock, - customerHeader, customerCompany, customerName, customerAddress, customerCity, customerEmail, - customerPhone, freeHeader, textBlock, headerBlock, dateBlock, customerBlock, companyBlock, amountBlock, - lineBlock, imageBlock); + invoiceNumber, servicesHeader, servicesListBlock, servicesNetBlock, servicesVatBlock, + servicesGrossBlock, customerHeader, customerCompany, customerName, customerAddress, customerCity, + customerEmail, customerPhone, freeHeader, textBlock, headerBlock, dateBlock, customerBlock, + companyBlock, amountBlock, lineBlock, imageBlock); return panel; } 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 a92bbcc..f330e50 100644 --- a/src/main/java/de/assecutor/votianlt/pages/view/JobSummaryView.java +++ b/src/main/java/de/assecutor/votianlt/pages/view/JobSummaryView.java @@ -20,6 +20,7 @@ import com.vaadin.flow.router.HasDynamicTitle; import com.vaadin.flow.router.Route; import com.vaadin.flow.theme.lumo.LumoUtility; import de.assecutor.votianlt.model.CargoItem; +import de.assecutor.votianlt.model.DeliveryStation; import de.assecutor.votianlt.model.Job; import de.assecutor.votianlt.model.LocationPosition; import de.assecutor.votianlt.model.task.BaseTask; @@ -199,19 +200,50 @@ public class JobSummaryView extends Main implements HasUrlParameter, Has pickupBox.add(new Span(concatAddress(job.getPickupStreet(), job.getPickupHouseNumber()))); pickupBox.add(new Span(concatZipCity(job.getPickupZip(), job.getPickupCity()))); - 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()))); - pickupBox.setWidth("50%"); - deliveryBox.setWidth("50%"); - topRow.add(pickupBox, deliveryBox); + + 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 @@ -271,13 +303,16 @@ public class JobSummaryView extends Main implements HasUrlParameter, Has // Preis basierend auf den hinterlegten Leistungen berechnen PriceCalculationResult priceResult = calculatePriceFromServices(job); - + Div priceTable = new Div(); priceTable.getStyle().set("width", "100%"); - priceTable.add(createPriceRow(getTranslation("jobsummary.info.netto") + ":", formatPrice(priceResult.netAmount()), false)); - priceTable.add(createPriceRow(getTranslation("jobsummary.info.ust") + ":", formatPrice(priceResult.vatAmount()), false)); - priceTable.add(createPriceRow(getTranslation("jobsummary.info.gesamt") + ":", formatPrice(priceResult.totalAmount()), true)); + priceTable.add(createPriceRow(getTranslation("jobsummary.info.netto") + ":", + formatPrice(priceResult.netAmount()), false)); + priceTable.add(createPriceRow(getTranslation("jobsummary.info.ust") + ":", formatPrice(priceResult.vatAmount()), + false)); + priceTable.add(createPriceRow(getTranslation("jobsummary.info.gesamt") + ":", + formatPrice(priceResult.totalAmount()), true)); infoBox.add(priceTable); @@ -461,8 +496,32 @@ public class JobSummaryView extends Main implements HasUrlParameter, Has // Baue Adress-Strings String origin = (concatAddress(job.getPickupStreet(), job.getPickupHouseNumber()) + ", " + concatZipCity(job.getPickupZip(), job.getPickupCity())).trim(); - String destination = (concatAddress(job.getDeliveryStreet(), job.getDeliveryHouseNumber()) + ", " - + concatZipCity(job.getDeliveryZip(), job.getDeliveryCity())).trim(); + + // Build destination and waypoints from delivery stations + List stations = job.getDeliveryStations(); + String destination; + List waypoints = new ArrayList<>(); + + if (stations != null && !stations.isEmpty()) { + // Last station is the final destination + DeliveryStation lastStation = stations.get(stations.size() - 1); + destination = (concatAddress(lastStation.getStreet(), lastStation.getHouseNumber()) + ", " + + concatZipCity(lastStation.getZip(), lastStation.getCity())).trim(); + + // Intermediate stations are waypoints + for (int i = 0; i < stations.size() - 1; i++) { + DeliveryStation station = stations.get(i); + String wp = (concatAddress(station.getStreet(), station.getHouseNumber()) + ", " + + concatZipCity(station.getZip(), station.getCity())).trim(); + if (!wp.isBlank()) { + waypoints.add(wp); + } + } + } else { + // Fallback to flat delivery fields + destination = (concatAddress(job.getDeliveryStreet(), job.getDeliveryHouseNumber()) + ", " + + concatZipCity(job.getDeliveryZip(), job.getDeliveryCity())).trim(); + } if (origin.isBlank() || destination.isBlank()) { return; @@ -504,15 +563,16 @@ public class JobSummaryView extends Main implements HasUrlParameter, Has Integer savedDuration = job.getRouteDurationSeconds(); boolean hasSavedRouteData = savedDistance != null && savedDuration != null; - String js = buildMapJs(origin, destination, hasPosition, position, appUserId, shouldUpdate, hasSavedRouteData, - savedDistance != null ? savedDistance : 0.0, savedDuration != null ? savedDuration : 0); + String js = buildMapJs(origin, destination, waypoints, hasPosition, position, appUserId, shouldUpdate, + hasSavedRouteData, savedDistance != null ? savedDistance : 0.0, + savedDuration != null ? savedDuration : 0); map.getElement().executeJs(js, map.getElement(), routeInfo.getElement()); } - private String buildMapJs(String origin, String destination, boolean hasPosition, LocationPosition position, - String appUserId, boolean shouldUpdate, boolean hasSavedRouteData, double savedDistance, - int savedDuration) { + private String buildMapJs(String origin, String destination, List waypoints, boolean hasPosition, + LocationPosition position, String appUserId, boolean shouldUpdate, boolean hasSavedRouteData, + double savedDistance, int savedDuration) { String apiKey = getGoogleMapsApiKey(); // Explizit mit Punkt als Dezimaltrennzeichen formatieren String lat = hasPosition ? String.format(java.util.Locale.US, "%.6f", position.getLatitude()) : "0"; @@ -526,6 +586,17 @@ public class JobSummaryView extends Main implements HasUrlParameter, Has String savedDurationText = hours > 0 ? String.format("%d Std. %d Min.", hours, minutes) : String.format("%d Min.", minutes); + // Build waypoints JS array + StringBuilder waypointsJs = new StringBuilder("["); + if (waypoints != null && !waypoints.isEmpty()) { + for (int i = 0; i < waypoints.size(); i++) { + if (i > 0) + waypointsJs.append(","); + waypointsJs.append("{location:'").append(escapeJs(waypoints.get(i))).append("',stopover:true}"); + } + } + waypointsJs.append("]"); + return """ (function(){ var host = $0; @@ -541,6 +612,7 @@ public class JobSummaryView extends Main implements HasUrlParameter, Has var hasSavedRouteData = %s; var savedDistance = %s; var savedDurationText = '%s'; + var waypoints = %s; var appUserMarker = null; var updateInterval = null; @@ -557,7 +629,7 @@ public class JobSummaryView extends Main implements HasUrlParameter, Has trafficLayer.setMap(map); var ds = new google.maps.DirectionsService(); - ds.route({ + var routeRequest = { origin: origin, destination: destination, travelMode: google.maps.TravelMode.DRIVING, @@ -566,7 +638,12 @@ public class JobSummaryView extends Main implements HasUrlParameter, Has departureTime: new Date(), trafficModel: google.maps.TrafficModel.BEST_GUESS } - }, function(res, status){ + }; + if(waypoints.length > 0){ + routeRequest.waypoints = waypoints; + routeRequest.optimizeWaypoints = false; + } + ds.route(routeRequest, function(res, status){ if(status === 'OK'){ infoEl.innerHTML = ''; @@ -684,7 +761,8 @@ public class JobSummaryView extends Main implements HasUrlParameter, Has """ .formatted(escapeJs(origin), escapeJs(destination), escapeJs(apiKey), lat, lng, Boolean.toString(hasPosition), escapeJs(appUserId), Boolean.toString(shouldUpdate), - Boolean.toString(hasSavedRouteData), savedDistanceStr, escapeJs(savedDurationText)); + Boolean.toString(hasSavedRouteData), savedDistanceStr, escapeJs(savedDurationText), + waypointsJs.toString()); } // Hilfsfunktion zum einfachen Escapen von JS-Zeichen in Strings diff --git a/src/main/java/de/assecutor/votianlt/pages/view/ShowJobsView.java b/src/main/java/de/assecutor/votianlt/pages/view/ShowJobsView.java index 9b38703..a9aadbe 100644 --- a/src/main/java/de/assecutor/votianlt/pages/view/ShowJobsView.java +++ b/src/main/java/de/assecutor/votianlt/pages/view/ShowJobsView.java @@ -116,8 +116,8 @@ public class ShowJobsView extends VerticalLayout implements HasDynamicTitle { .setSortable(true); grid.addColumn(job -> DateTimeFormatUtil.formatDateTime(job.getCreatedAt())) .setHeader(getTranslation("jobs.column.jobdate")).setAutoWidth(true).setSortable(true); - grid.addColumn(Job::getDeliveryCity).setHeader(getTranslation("jobs.column.destination")).setAutoWidth(true) - .setFlexGrow(1).setSortable(true); + grid.addColumn(Job::getFirstDeliveryCity).setHeader(getTranslation("jobs.column.destination")) + .setAutoWidth(true).setFlexGrow(1).setSortable(true); // Action column: manual completion for jobs without digital processing grid.addComponentColumn(job -> { @@ -144,16 +144,14 @@ public class ShowJobsView extends VerticalLayout implements HasDynamicTitle { invoiceBtn.setTooltipText(getTranslation("jobs.tooltip.showinvoice")); invoiceBtn.addClickListener(e -> { e.getSource().getElement().getNode(); - customerInvoiceRepository.findById(job.getInvoiceId()).ifPresentOrElse( - invoice -> { - if (invoice.getPdfData() != null) { - CreateInvoiceView.showSavedInvoiceDialog(invoice.getPdfData(), - invoice.getInvoiceNumber(), this); - } else { - getUI().ifPresent(ui -> ui.navigate("create_invoice/" + job.getId().toHexString())); - } - }, - () -> getUI().ifPresent(ui -> ui.navigate("create_invoice/" + job.getId().toHexString()))); + customerInvoiceRepository.findById(job.getInvoiceId()).ifPresentOrElse(invoice -> { + if (invoice.getPdfData() != null) { + CreateInvoiceView.showSavedInvoiceDialog(invoice.getPdfData(), + invoice.getInvoiceNumber(), this); + } else { + getUI().ifPresent(ui -> ui.navigate("create_invoice/" + job.getId().toHexString())); + } + }, () -> getUI().ifPresent(ui -> ui.navigate("create_invoice/" + job.getId().toHexString()))); }); } else { invoiceBtn.setTooltipText(getTranslation("jobs.tooltip.createinvoice")); @@ -344,7 +342,7 @@ public class ShowJobsView extends VerticalLayout implements HasDynamicTitle { csv.append(escapeCsv(extractCompanyName(job.getCustomerSelection()))).append(","); csv.append(escapeCsv(job.getJobNumber())).append(","); csv.append(DateTimeFormatUtil.formatDateTime(job.getCreatedAt())).append(","); - csv.append(escapeCsv(job.getDeliveryCity())).append("\n"); + csv.append(escapeCsv(job.getDeliveryCitiesDisplay())).append("\n"); } return csv.toString(); diff --git a/src/main/java/de/assecutor/votianlt/service/CustomerInvoiceService.java b/src/main/java/de/assecutor/votianlt/service/CustomerInvoiceService.java index 8387f9a..09e0c7c 100644 --- a/src/main/java/de/assecutor/votianlt/service/CustomerInvoiceService.java +++ b/src/main/java/de/assecutor/votianlt/service/CustomerInvoiceService.java @@ -257,8 +257,7 @@ public class CustomerInvoiceService { } public byte[] generatePdfFromCanvasTemplate(String jsonTemplateData, de.assecutor.votianlt.model.User user, - String invoicePrefix) - throws Exception { + String invoicePrefix) throws Exception { // Parse the JSON template data com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper(); com.fasterxml.jackson.databind.JsonNode rootNode = mapper.readTree(jsonTemplateData); @@ -371,18 +370,21 @@ public class CustomerInvoiceService { htmlBuilder.append("left:").append(String.format(java.util.Locale.US, "%.2f", mmX)).append("mm;"); htmlBuilder.append("top:").append(String.format(java.util.Locale.US, "%.2f", mmY)).append("mm;"); if ("line".equals(type)) { - htmlBuilder.append("width:").append(String.format(java.util.Locale.US, "%.2f", mmWidth)).append("mm;"); + htmlBuilder.append("width:").append(String.format(java.util.Locale.US, "%.2f", mmWidth)) + .append("mm;"); htmlBuilder.append("height:0;border-top:1px solid #333;"); } else if ("vline".equals(type)) { htmlBuilder.append("width:0;border-left:1px solid #333;"); - htmlBuilder.append("height:").append(String.format(java.util.Locale.US, "%.2f", mmHeight)).append("mm;"); + htmlBuilder.append("height:").append(String.format(java.util.Locale.US, "%.2f", mmHeight)) + .append("mm;"); } else { - htmlBuilder.append("width:").append(String.format(java.util.Locale.US, "%.2f", mmWidth)).append("mm;"); + htmlBuilder.append("width:").append(String.format(java.util.Locale.US, "%.2f", mmWidth)) + .append("mm;"); htmlBuilder.append("height:").append(String.format(java.util.Locale.US, "%.2f", mmHeight)) .append("mm;"); htmlBuilder.append("font-size:").append(fontSize).append("pt;"); - htmlBuilder.append("line-height:").append(String.format(java.util.Locale.US, "%.2f", fontSize * 1.2)) - .append("pt;"); + htmlBuilder.append("line-height:") + .append(String.format(java.util.Locale.US, "%.2f", fontSize * 1.2)).append("pt;"); htmlBuilder.append("color:").append(color).append(";"); // For services.list use block display to allow table to fill width if ("services.list".equals(variable)) { @@ -668,18 +670,21 @@ public class CustomerInvoiceService { htmlBuilder.append("left:").append(String.format(java.util.Locale.US, "%.2f", mmX)).append("mm;"); htmlBuilder.append("top:").append(String.format(java.util.Locale.US, "%.2f", mmY)).append("mm;"); if ("line".equals(type)) { - htmlBuilder.append("width:").append(String.format(java.util.Locale.US, "%.2f", mmWidth)).append("mm;"); + htmlBuilder.append("width:").append(String.format(java.util.Locale.US, "%.2f", mmWidth)) + .append("mm;"); htmlBuilder.append("height:0;border-top:1px solid #333;"); } else if ("vline".equals(type)) { htmlBuilder.append("width:0;border-left:1px solid #333;"); - htmlBuilder.append("height:").append(String.format(java.util.Locale.US, "%.2f", mmHeight)).append("mm;"); + htmlBuilder.append("height:").append(String.format(java.util.Locale.US, "%.2f", mmHeight)) + .append("mm;"); } else { - htmlBuilder.append("width:").append(String.format(java.util.Locale.US, "%.2f", mmWidth)).append("mm;"); + htmlBuilder.append("width:").append(String.format(java.util.Locale.US, "%.2f", mmWidth)) + .append("mm;"); htmlBuilder.append("height:").append(String.format(java.util.Locale.US, "%.2f", mmHeight)) .append("mm;"); htmlBuilder.append("font-size:").append(fontSize).append("pt;"); - htmlBuilder.append("line-height:").append(String.format(java.util.Locale.US, "%.2f", fontSize * 1.2)) - .append("pt;"); + htmlBuilder.append("line-height:") + .append(String.format(java.util.Locale.US, "%.2f", fontSize * 1.2)).append("pt;"); htmlBuilder.append("color:").append(color).append(";"); // For services.list use block display if ("services.list".equals(variable)) { @@ -706,7 +711,8 @@ public class CustomerInvoiceService { .replace("job.", "") + "]"; System.out.println("DEBUG: No value for variable " + variable + ", using placeholder"); } else if ("customer".equals(type)) { - // Customer-type element without variable: compose address from customer variables + // Customer-type element without variable: compose address from customer + // variables String line1 = variables.getOrDefault("customer.company_name", variables.getOrDefault("customer.contact_name", "")); String line2 = variables.getOrDefault("customer.street", ""); diff --git a/src/main/java/de/assecutor/votianlt/service/EmailService.java b/src/main/java/de/assecutor/votianlt/service/EmailService.java index 8ebfdd7..475afdd 100644 --- a/src/main/java/de/assecutor/votianlt/service/EmailService.java +++ b/src/main/java/de/assecutor/votianlt/service/EmailService.java @@ -85,16 +85,17 @@ public class EmailService { body.append("Aufgabe: ").append(taskTypeName).append("\n"); body.append("Abgeschlossen von: ").append(appUserName).append("\n\n"); - if (job.getPickupCity() != null || job.getDeliveryCity() != null) { + String deliveryCities = job.getDeliveryCitiesDisplay(); + if (job.getPickupCity() != null || deliveryCities != null) { body.append("Route: "); if (job.getPickupCity() != null) { body.append(job.getPickupCity()); } - if (job.getPickupCity() != null && job.getDeliveryCity() != null) { + if (job.getPickupCity() != null && deliveryCities != null) { body.append(" → "); } - if (job.getDeliveryCity() != null) { - body.append(job.getDeliveryCity()); + if (deliveryCities != null) { + body.append(deliveryCities); } body.append("\n\n"); } @@ -204,16 +205,17 @@ public class EmailService { body.append("Kunde: ").append(job.getDeliveryCompany()).append("\n"); } - if (job.getPickupCity() != null || job.getDeliveryCity() != null) { + String deliveryCities = job.getDeliveryCitiesDisplay(); + if (job.getPickupCity() != null || deliveryCities != null) { body.append("Route: "); if (job.getPickupCity() != null) { body.append(job.getPickupCity()); } - if (job.getPickupCity() != null && job.getDeliveryCity() != null) { + if (job.getPickupCity() != null && deliveryCities != null) { body.append(" → "); } - if (job.getDeliveryCity() != null) { - body.append(job.getDeliveryCity()); + if (deliveryCities != null) { + body.append(deliveryCities); } body.append("\n"); } @@ -293,16 +295,17 @@ public class EmailService { body.append("Auftraggeber: ").append(job.getCustomerSelection()).append("\n"); } - if (job.getPickupCity() != null || job.getDeliveryCity() != null) { + String deliveryCities = job.getDeliveryCitiesDisplay(); + if (job.getPickupCity() != null || deliveryCities != null) { body.append("Route: "); if (job.getPickupCity() != null) { body.append(job.getPickupCity()); } - if (job.getPickupCity() != null && job.getDeliveryCity() != null) { + if (job.getPickupCity() != null && deliveryCities != null) { body.append(" → "); } - if (job.getDeliveryCity() != null) { - body.append(job.getDeliveryCity()); + if (deliveryCities != null) { + body.append(deliveryCities); } body.append("\n"); } diff --git a/src/main/java/de/assecutor/votianlt/service/TranslationService.java b/src/main/java/de/assecutor/votianlt/service/TranslationService.java index 6ba7bb9..7d16af7 100644 --- a/src/main/java/de/assecutor/votianlt/service/TranslationService.java +++ b/src/main/java/de/assecutor/votianlt/service/TranslationService.java @@ -152,8 +152,8 @@ public class TranslationService { log.info("[TranslationCache] Cache size {} exceeds threshold {}, deleting {} oldest entries", count, CACHE_CLEANUP_THRESHOLD, toDelete); - List oldest = cacheRepository.findOldestEntries( - PageRequest.of(0, (int) toDelete, Sort.by(Sort.Direction.ASC, "inserted_at"))); + List oldest = cacheRepository + .findOldestEntries(PageRequest.of(0, (int) toDelete, Sort.by(Sort.Direction.ASC, "inserted_at"))); cacheRepository.deleteAll(oldest); log.info("[TranslationCache] Deleted {} entries, new size: {}", oldest.size(), cacheRepository.count()); diff --git a/src/main/resources/messages.properties b/src/main/resources/messages.properties index 7f50371..6ea1844 100644 --- a/src/main/resources/messages.properties +++ b/src/main/resources/messages.properties @@ -446,6 +446,11 @@ addjob.address.delivery.addition.placeholder=Adresszusatz (Lieferung) addjob.address.save=Adresse speichern addjob.section.pickup=Abholung addjob.section.delivery=Lieferung +addjob.station.delivery=Lieferstation {0} +addjob.station.add=Lieferstation hinzuf\u00fcgen +addjob.station.remove.confirm=Lieferstation {0} wirklich entfernen? +addjob.station.max.reached=Maximale Anzahl von 25 Lieferstationen erreicht +addjob.appointment.delivery.info=Liefertermine werden direkt in den Lieferstationen festgelegt. addjob.tab.addresses=Auftraggeber & Adressen addjob.tab.appointments=Termine & Verarbeitung addjob.tab.cargo=Fracht @@ -568,6 +573,7 @@ jobsummary.notification.complete.error=Fehler beim Abschließen: {0} jobsummary.notification.noappuser=Diesem Auftrag ist kein App-Nutzer zugeordnet jobsummary.section.pickup=Abholung jobsummary.section.delivery=Lieferung +jobsummary.station.phone=Telefon jobsummary.section.tasks=Zu quittierende Aufgaben jobsummary.section.cargo=Zu transportierende Fracht jobsummary.section.info=Weitere Informationen diff --git a/src/main/resources/messages_en.properties b/src/main/resources/messages_en.properties index b36e7ae..22322d9 100644 --- a/src/main/resources/messages_en.properties +++ b/src/main/resources/messages_en.properties @@ -446,6 +446,11 @@ addjob.address.delivery.addition.placeholder=Address addition (Delivery) addjob.address.save=Save Address addjob.section.pickup=Pickup addjob.section.delivery=Delivery +addjob.station.delivery=Delivery Station {0} +addjob.station.add=Add delivery station +addjob.station.remove.confirm=Really remove delivery station {0}? +addjob.station.max.reached=Maximum of 25 delivery stations reached +addjob.appointment.delivery.info=Delivery dates are set directly in the delivery stations. addjob.tab.addresses=Customer & Addresses addjob.tab.appointments=Appointments & Processing addjob.tab.cargo=Cargo @@ -568,6 +573,7 @@ jobsummary.notification.complete.error=Error completing job: {0} jobsummary.notification.noappuser=No app user assigned to this job jobsummary.section.pickup=Pickup jobsummary.section.delivery=Delivery +jobsummary.station.phone=Phone jobsummary.section.tasks=Tasks to Confirm jobsummary.section.cargo=Cargo to Transport jobsummary.section.info=Additional Information diff --git a/src/main/resources/messages_es.properties b/src/main/resources/messages_es.properties index a6592fb..3b561b1 100644 --- a/src/main/resources/messages_es.properties +++ b/src/main/resources/messages_es.properties @@ -446,6 +446,11 @@ addjob.address.delivery.addition.placeholder=Complemento de dirección (Entrega) addjob.address.save=Guardar Dirección addjob.section.pickup=Recogida addjob.section.delivery=Entrega +addjob.station.delivery=Estaci\u00f3n de entrega {0} +addjob.station.add=A\u00f1adir estaci\u00f3n de entrega +addjob.station.remove.confirm=\u00bfRealmente eliminar la estaci\u00f3n de entrega {0}? +addjob.station.max.reached=Se alcanz\u00f3 el m\u00e1ximo de 25 estaciones de entrega +addjob.appointment.delivery.info=Las fechas de entrega se establecen directamente en las estaciones de entrega. addjob.tab.addresses=Cliente y Direcciones addjob.tab.appointments=Citas y Procesamiento addjob.tab.cargo=Carga @@ -568,6 +573,7 @@ jobsummary.notification.complete.error=Error al completar trabajo: {0} jobsummary.notification.noappuser=Este trabajo no tiene un usuario de app asignado jobsummary.section.pickup=Recogida jobsummary.section.delivery=Entrega +jobsummary.station.phone=Teléfono jobsummary.section.tasks=Tareas a Confirmar jobsummary.section.cargo=Carga a Transportar jobsummary.section.info=Información Adicional diff --git a/src/main/resources/messages_fr.properties b/src/main/resources/messages_fr.properties index 53e402c..fd297e6 100644 --- a/src/main/resources/messages_fr.properties +++ b/src/main/resources/messages_fr.properties @@ -444,8 +444,13 @@ addjob.address.city.placeholder.delivery=Ville (Livraison) addjob.address.delivery.street.placeholder=Rue (Livraison) addjob.address.delivery.addition.placeholder=Ajout d'adresse (Livraison) addjob.address.save=Enregistrer l'Adresse -addjob.section.pickup=Enlèvement +addjob.section.pickup=Enl\u00e8vement addjob.section.delivery=Livraison +addjob.station.delivery=Station de livraison {0} +addjob.station.add=Ajouter une station de livraison +addjob.station.remove.confirm=Vraiment supprimer la station de livraison {0}? +addjob.station.max.reached=Maximum de 25 stations de livraison atteint +addjob.appointment.delivery.info=Les dates de livraison sont d\u00e9finies directement dans les stations de livraison. addjob.tab.addresses=Client & Adresses addjob.tab.appointments=Rendez-vous & Traitement addjob.tab.cargo=Cargaison @@ -568,6 +573,7 @@ jobsummary.notification.complete.error=Erreur lors de la terminaison : {0} jobsummary.notification.noappuser=Aucun utilisateur d'app assigné à cet emploi jobsummary.section.pickup=Enlèvement jobsummary.section.delivery=Livraison +jobsummary.station.phone=Téléphone jobsummary.section.tasks=Tâches à Confirmer jobsummary.section.cargo=Cargaison à Transporter jobsummary.section.info=Informations Supplémentaires