diff --git a/src/main/java/de/assecutor/aimailassistant/mail/domain/Station.java b/src/main/java/de/assecutor/aimailassistant/mail/domain/Station.java index f3553f1..0864c76 100644 --- a/src/main/java/de/assecutor/aimailassistant/mail/domain/Station.java +++ b/src/main/java/de/assecutor/aimailassistant/mail/domain/Station.java @@ -4,6 +4,9 @@ public class Station { private String name; private String address; private StationAction action; + private Boolean addressValidated; + private Boolean addressValid; + private String validatedAddress; public Station() { } @@ -37,4 +40,40 @@ public class Station { public void setAction(StationAction action) { this.action = action; } + + public Boolean getAddressValidated() { + return addressValidated; + } + + public void setAddressValidated(Boolean addressValidated) { + this.addressValidated = addressValidated; + } + + public Boolean getAddressValid() { + return addressValid; + } + + public void setAddressValid(Boolean addressValid) { + this.addressValid = addressValid; + } + + public String getValidatedAddress() { + return validatedAddress; + } + + public void setValidatedAddress(String validatedAddress) { + this.validatedAddress = validatedAddress; + } + + public boolean isValidationPending() { + return addressValidated == null; + } + + public boolean isAddressValidAndChecked() { + return Boolean.TRUE.equals(addressValidated) && Boolean.TRUE.equals(addressValid); + } + + public boolean isAddressInvalidAndChecked() { + return Boolean.TRUE.equals(addressValidated) && Boolean.FALSE.equals(addressValid); + } } diff --git a/src/main/java/de/assecutor/aimailassistant/mail/service/GoogleGeocodingService.java b/src/main/java/de/assecutor/aimailassistant/mail/service/GoogleGeocodingService.java new file mode 100644 index 0000000..d997d53 --- /dev/null +++ b/src/main/java/de/assecutor/aimailassistant/mail/service/GoogleGeocodingService.java @@ -0,0 +1,166 @@ +package de.assecutor.aimailassistant.mail.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.client.WebClient; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.time.Duration; + +/** + * Service für Google Geocoding API zur Adressvalidierung. + */ +@Service +public class GoogleGeocodingService { + + private static final Logger log = LoggerFactory.getLogger(GoogleGeocodingService.class); + + private final WebClient webClient; + private final ObjectMapper objectMapper; + private final String apiKey; + + public GoogleGeocodingService( + @Value("${google.maps.api.key:}") String apiKey, + ObjectMapper objectMapper) { + this.webClient = WebClient.builder() + .baseUrl("https://maps.googleapis.com") + .build(); + this.apiKey = apiKey; + this.objectMapper = objectMapper; + } + + /** + * Ergebnis einer Adressvalidierung. + */ + public record ValidationResult( + boolean valid, + String formattedAddress, + String streetNumber, + String street, + String postalCode, + String city, + String country, + Double latitude, + Double longitude, + String errorMessage + ) { + public static ValidationResult error(String message) { + return new ValidationResult(false, null, null, null, null, null, null, null, null, message); + } + + public static ValidationResult success(String formattedAddress, String streetNumber, String street, + String postalCode, String city, String country, + Double latitude, Double longitude) { + return new ValidationResult(true, formattedAddress, streetNumber, street, postalCode, city, country, + latitude, longitude, null); + } + } + + /** + * Validiert eine Adresse über die Google Geocoding API. + * + * @param address Die zu validierende Adresse + * @return ValidationResult mit der formatierten Adresse oder Fehlermeldung + */ + public ValidationResult validateAddress(String address) { + if (address == null || address.isBlank()) { + log.warn("validateAddress called with empty address"); + return ValidationResult.error("Keine Adresse angegeben"); + } + + if (apiKey == null || apiKey.isBlank()) { + log.warn("Google Maps API-Key not configured, skipping validation"); + return ValidationResult.error("Google Maps API-Key nicht konfiguriert"); + } + + try { + String encodedAddress = URLEncoder.encode(address, StandardCharsets.UTF_8); + String uri = "/maps/api/geocode/json?address=" + encodedAddress + + "®ion=de&language=de&key=" + apiKey; + + log.info("Validating address: {}", address); + String response = webClient.get() + .uri(uri) + .retrieve() + .bodyToMono(String.class) + .timeout(Duration.ofSeconds(10)) + .block(); + + log.debug("Geocoding response: {}", response); + return parseGeocodingResponse(response); + + } catch (Exception e) { + log.error("Error validating address: {}", address, e); + return ValidationResult.error("Fehler bei der Adressvalidierung: " + e.getMessage()); + } + } + + private ValidationResult parseGeocodingResponse(String response) { + try { + JsonNode root = objectMapper.readTree(response); + String status = root.path("status").asText(); + + if (!"OK".equals(status)) { + String errorMessage = switch (status) { + case "ZERO_RESULTS" -> "Keine Ergebnisse für diese Adresse gefunden"; + case "OVER_DAILY_LIMIT", "OVER_QUERY_LIMIT" -> "API-Limit erreicht"; + case "REQUEST_DENIED" -> "API-Zugriff verweigert"; + case "INVALID_REQUEST" -> "Ungültige Anfrage"; + default -> "Unbekannter Fehler: " + status; + }; + return ValidationResult.error(errorMessage); + } + + JsonNode results = root.path("results"); + if (!results.isArray() || results.isEmpty()) { + return ValidationResult.error("Keine Ergebnisse gefunden"); + } + + JsonNode firstResult = results.get(0); + String formattedAddress = firstResult.path("formatted_address").asText(); + + // Koordinaten extrahieren + JsonNode location = firstResult.path("geometry").path("location"); + Double lat = location.has("lat") ? location.path("lat").asDouble() : null; + Double lng = location.has("lng") ? location.path("lng").asDouble() : null; + + // Adresskomponenten extrahieren + String streetNumber = null; + String street = null; + String postalCode = null; + String city = null; + String country = null; + + JsonNode components = firstResult.path("address_components"); + if (components.isArray()) { + for (JsonNode component : components) { + JsonNode types = component.path("types"); + String longName = component.path("long_name").asText(); + + for (JsonNode type : types) { + String typeStr = type.asText(); + switch (typeStr) { + case "street_number" -> streetNumber = longName; + case "route" -> street = longName; + case "postal_code" -> postalCode = longName; + case "locality" -> city = longName; + case "country" -> country = longName; + } + } + } + } + + log.info("Address validated successfully: {}", formattedAddress); + return ValidationResult.success(formattedAddress, streetNumber, street, postalCode, city, country, lat, lng); + + } catch (Exception e) { + log.error("Error parsing geocoding response", e); + return ValidationResult.error("Fehler beim Parsen der Antwort: " + e.getMessage()); + } + } +} diff --git a/src/main/java/de/assecutor/aimailassistant/mail/service/LlmService.java b/src/main/java/de/assecutor/aimailassistant/mail/service/LlmService.java index 9ad5175..395ed2a 100644 --- a/src/main/java/de/assecutor/aimailassistant/mail/service/LlmService.java +++ b/src/main/java/de/assecutor/aimailassistant/mail/service/LlmService.java @@ -315,4 +315,94 @@ public class LlmService { log.info("Reprocessing email: {}", email.getSubject()); return summarizeEmail(email); } + + /** + * Extrahiert eine Adresse aus markiertem Text mittels AI. + * @param selectedText Der vom Benutzer markierte Text + * @return Eine Station mit extrahierter Adresse, oder null wenn keine Adresse erkannt wurde + */ + public Station extractAddressFromText(String selectedText) { + if (selectedText == null || selectedText.isBlank()) { + return null; + } + + String systemPrompt = """ + Du bist ein Assistent für die Extraktion von Adressen aus Text. + Deine Aufgabe ist es, aus dem gegebenen Text eine Adresse zu erkennen und zu strukturieren. + + Antworte IMMER im folgenden JSON-Format: + { + "found": true oder false, + "name": "Name der Person/Firma (falls erkennbar)", + "address": "Vollständige Adresse (Straße, Hausnummer, PLZ, Ort)" + } + + Regeln: + - Wenn eine Adresse erkennbar ist, setze "found": true + - Extrahiere die Adresse so vollständig wie möglich + - Wenn ein Firmenname oder Personenname zur Adresse gehört, extrahiere diesen als "name" + - Wenn keine Adresse erkennbar ist, setze "found": false und leere Strings für name/address + - Die Adresse sollte im deutschen Format sein (Straße Hausnummer, PLZ Ort) + """; + + String userPrompt = String.format(""" + Analysiere den folgenden markierten Text und extrahiere die Adresse: + + %s + """, selectedText); + + try { + Map request = Map.of( + "model", model, + "messages", List.of( + Map.of("role", "system", "content", systemPrompt), + Map.of("role", "user", "content", userPrompt) + ), + "temperature", 0.1, + "max_tokens", 500 + ); + + log.info("Extracting address from selected text..."); + String response = webClient.post() + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(request) + .retrieve() + .bodyToMono(String.class) + .block(); + + log.debug("Address extraction response: {}", response); + return parseAddressResponse(response); + } catch (Exception e) { + log.error("Error extracting address from text", e); + return null; + } + } + + private Station parseAddressResponse(String response) { + try { + JsonNode root = objectMapper.readTree(response); + String content = root.path("choices").get(0).path("message").path("content").asText(); + String jsonContent = extractJson(content); + JsonNode addressNode = objectMapper.readTree(jsonContent); + + boolean found = addressNode.path("found").asBoolean(false); + if (!found) { + return null; + } + + Station station = new Station(); + station.setName(getTextOrNull(addressNode, "name")); + station.setAddress(getTextOrNull(addressNode, "address")); + station.setAction(StationAction.PICKUP); // Default action + + // Nur zurückgeben wenn mindestens eine Adresse gefunden wurde + if (station.getAddress() != null && !station.getAddress().isBlank()) { + return station; + } + return null; + } catch (JsonProcessingException e) { + log.error("Error parsing address response", e); + return null; + } + } } diff --git a/src/main/java/de/assecutor/aimailassistant/mail/ui/AddressMapDialog.java b/src/main/java/de/assecutor/aimailassistant/mail/ui/AddressMapDialog.java new file mode 100644 index 0000000..1d41e22 --- /dev/null +++ b/src/main/java/de/assecutor/aimailassistant/mail/ui/AddressMapDialog.java @@ -0,0 +1,188 @@ +package de.assecutor.aimailassistant.mail.ui; + +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.dialog.Dialog; +import com.vaadin.flow.component.html.Div; +import com.vaadin.flow.component.html.Span; +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.theme.lumo.LumoUtility; + +/** + * Dialog zur Anzeige einer einzelnen Adresse auf einer Google Maps Karte. + */ +public class AddressMapDialog extends Dialog { + + private final String address; + private final String name; + private final String apiKey; + + public AddressMapDialog(String name, String address, String apiKey) { + this.name = name; + this.address = address; + this.apiKey = apiKey; + + setHeaderTitle("Adresse auf Karte"); + setWidth("700px"); + setHeight("600px"); + setCloseOnOutsideClick(true); + + add(createContent()); + createFooter(); + } + + private VerticalLayout createContent() { + VerticalLayout layout = new VerticalLayout(); + layout.setSizeFull(); + layout.setPadding(false); + layout.setSpacing(true); + + // Info section + HorizontalLayout infoSection = createInfoSection(); + layout.add(infoSection); + + // Map container + Div mapContainer = createMapContainer(); + layout.add(mapContainer); + layout.setFlexGrow(1, mapContainer); + + return layout; + } + + private HorizontalLayout createInfoSection() { + HorizontalLayout layout = new HorizontalLayout(); + layout.setWidthFull(); + layout.setSpacing(true); + layout.setAlignItems(FlexComponent.Alignment.CENTER); + + // Address info box + Div addressBox = new Div(); + addressBox.addClassNames( + LumoUtility.Background.CONTRAST_5, + LumoUtility.BorderRadius.MEDIUM, + LumoUtility.Padding.MEDIUM + ); + addressBox.setWidthFull(); + + VerticalLayout addressContent = new VerticalLayout(); + addressContent.setPadding(false); + addressContent.setSpacing(false); + + if (name != null && !name.isBlank()) { + Span nameSpan = new Span(name); + nameSpan.addClassNames(LumoUtility.FontWeight.BOLD, LumoUtility.FontSize.MEDIUM); + addressContent.add(nameSpan); + } + + Span addressSpan = new Span(address != null ? address : "Keine Adresse"); + addressSpan.addClassNames(LumoUtility.TextColor.SECONDARY); + addressContent.add(addressSpan); + + addressBox.add(addressContent); + layout.add(addressBox); + + return layout; + } + + private Div createMapContainer() { + Div mapDiv = new Div(); + mapDiv.setId("address-map-" + System.currentTimeMillis()); + mapDiv.setSizeFull(); + mapDiv.getStyle() + .set("min-height", "400px") + .set("border-radius", "var(--lumo-border-radius-m)") + .set("overflow", "hidden"); + + String mapId = mapDiv.getId().orElse("address-map"); + String escapedAddress = escapeJs(address != null ? address : ""); + String escapedName = escapeJs(name != null ? name : ""); + + // JavaScript to initialize Google Maps with a single marker + String initScript = """ + (function() { + const mapDiv = document.getElementById('%s'); + if (!mapDiv) return; + + // Load Google Maps script if not already loaded + if (!window.google || !window.google.maps) { + const script = document.createElement('script'); + script.src = 'https://maps.googleapis.com/maps/api/js?key=%s&libraries=places'; + script.async = true; + script.defer = true; + script.onload = function() { + initMap(); + }; + document.head.appendChild(script); + } else { + initMap(); + } + + function initMap() { + const address = "%s"; + const name = "%s"; + + if (!address) { + mapDiv.innerHTML = '
Keine Adresse angegeben
'; + return; + } + + const map = new google.maps.Map(mapDiv, { + zoom: 15, + center: { lat: 51.1657, lng: 10.4515 }, // Germany center + mapTypeControl: true, + streetViewControl: true, + fullscreenControl: true + }); + + const geocoder = new google.maps.Geocoder(); + geocoder.geocode({ address: address, region: 'de' }, function(results, status) { + if (status === 'OK' && results[0]) { + const location = results[0].geometry.location; + map.setCenter(location); + + const marker = new google.maps.Marker({ + map: map, + position: location, + title: name || address, + animation: google.maps.Animation.DROP + }); + + const infoWindow = new google.maps.InfoWindow({ + content: '
' + (name || 'Adresse') + '
' + results[0].formatted_address + '
' + }); + + marker.addListener('click', function() { + infoWindow.open(map, marker); + }); + + // Auto-open info window + infoWindow.open(map, marker); + } else { + console.error('Geocoding failed:', status); + mapDiv.innerHTML = '
Adresse konnte nicht gefunden werden: ' + status + '
'; + } + }); + } + })(); + """.formatted(mapId, apiKey, escapedAddress, escapedName); + + // Execute script after attach + mapDiv.getElement().executeJs(initScript); + + return mapDiv; + } + + private String escapeJs(String str) { + if (str == null) return ""; + return str.replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\n", "\\n") + .replace("\r", "\\r"); + } + + private void createFooter() { + Button closeButton = new Button("Schließen", e -> close()); + getFooter().add(closeButton); + } +} diff --git a/src/main/java/de/assecutor/aimailassistant/mail/ui/MainView.java b/src/main/java/de/assecutor/aimailassistant/mail/ui/MainView.java index 2bfa656..0f64e04 100644 --- a/src/main/java/de/assecutor/aimailassistant/mail/ui/MainView.java +++ b/src/main/java/de/assecutor/aimailassistant/mail/ui/MainView.java @@ -26,6 +26,7 @@ import com.vaadin.flow.theme.lumo.LumoUtility; import de.assecutor.aimailassistant.mail.domain.OrderEmail; import de.assecutor.aimailassistant.mail.domain.OrderEmailRepository; import de.assecutor.aimailassistant.mail.event.EmailBroadcaster; +import de.assecutor.aimailassistant.mail.service.GoogleGeocodingService; import de.assecutor.aimailassistant.mail.service.ImapEmailService; import de.assecutor.aimailassistant.mail.service.LlmService; import de.assecutor.aimailassistant.mail.service.SmtpEmailService; @@ -44,6 +45,7 @@ public class MainView extends VerticalLayout { private final OrderEmailRepository orderEmailRepository; private final LlmService llmService; private final SmtpEmailService smtpEmailService; + private final GoogleGeocodingService geocodingService; private final ImapEmailService imapEmailService; private final String googleMapsApiKey; @@ -54,11 +56,13 @@ public class MainView extends VerticalLayout { OrderEmailRepository orderEmailRepository, LlmService llmService, SmtpEmailService smtpEmailService, + GoogleGeocodingService geocodingService, ImapEmailService imapEmailService, @Value("${google.maps.api.key}") String googleMapsApiKey) { this.orderEmailRepository = orderEmailRepository; this.llmService = llmService; this.smtpEmailService = smtpEmailService; + this.geocodingService = geocodingService; this.imapEmailService = imapEmailService; this.googleMapsApiKey = googleMapsApiKey; @@ -248,6 +252,7 @@ public class MainView extends VerticalLayout { email, llmService, smtpEmailService, + geocodingService, googleMapsApiKey, this::onEmailProcessed, this::onEmailDeleted diff --git a/src/main/java/de/assecutor/aimailassistant/mail/ui/OrderDetailDialog.java b/src/main/java/de/assecutor/aimailassistant/mail/ui/OrderDetailDialog.java index d1fb256..4d17a02 100644 --- a/src/main/java/de/assecutor/aimailassistant/mail/ui/OrderDetailDialog.java +++ b/src/main/java/de/assecutor/aimailassistant/mail/ui/OrderDetailDialog.java @@ -31,20 +31,26 @@ import de.assecutor.aimailassistant.mail.domain.OrderEmail; import de.assecutor.aimailassistant.mail.domain.OrderSummary; import de.assecutor.aimailassistant.mail.domain.Station; import de.assecutor.aimailassistant.mail.domain.StationAction; +import de.assecutor.aimailassistant.mail.service.GoogleGeocodingService; import de.assecutor.aimailassistant.mail.service.LlmService; import de.assecutor.aimailassistant.mail.service.SmtpEmailService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import java.time.format.DateTimeFormatter; +import java.util.ArrayList; import java.util.function.Consumer; public class OrderDetailDialog extends Dialog { + private static final Logger log = LoggerFactory.getLogger(OrderDetailDialog.class); private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm"); private final OrderEmail orderEmail; private OrderSummary summary; private final LlmService llmService; private final SmtpEmailService smtpEmailService; + private final GoogleGeocodingService geocodingService; private final Consumer onProcessed; private final Consumer onDelete; private final String googleMapsApiKey; @@ -54,11 +60,13 @@ public class OrderDetailDialog extends Dialog { private EmailField recipientField; private HorizontalLayout contentLayout; private Binder binder; + private VerticalLayout stationsSection; public OrderDetailDialog( OrderEmail orderEmail, LlmService llmService, SmtpEmailService smtpEmailService, + GoogleGeocodingService geocodingService, String googleMapsApiKey, Consumer onProcessed, Consumer onDelete) { @@ -66,6 +74,7 @@ public class OrderDetailDialog extends Dialog { this.llmService = llmService; this.summary = llmService.deserializeSummary(orderEmail.getSummaryJson()); this.smtpEmailService = smtpEmailService; + this.geocodingService = geocodingService; this.googleMapsApiKey = googleMapsApiKey; this.onProcessed = onProcessed; this.onDelete = onDelete; @@ -144,6 +153,17 @@ public class OrderDetailDialog extends Dialog { title.addClassName(LumoUtility.Margin.Bottom.SMALL); title.addClassName(LumoUtility.Margin.Top.NONE); + // Hinweis für Textauswahl (nur im Bearbeitungsmodus) + Span selectionHint = new Span(); + if (!readOnly) { + selectionHint.setText("Tipp: Markieren Sie Text, um eine Adresse zu extrahieren"); + selectionHint.addClassNames( + LumoUtility.TextColor.SECONDARY, + LumoUtility.FontSize.XSMALL, + LumoUtility.Margin.Bottom.SMALL + ); + } + Div contentBox = new Div(); contentBox.addClassNames( LumoUtility.Background.CONTRAST_5, @@ -165,20 +185,243 @@ public class OrderDetailDialog extends Dialog { .set("margin", "0") .set("max-width", "100%") .set("font-family", "var(--lumo-font-family)") - .set("font-size", "var(--lumo-font-size-s)"); + .set("font-size", "var(--lumo-font-size-s)") + .set("user-select", "text") + .set("-webkit-user-select", "text") + .set("cursor", "text"); contentBox.add(emailContent); + // Textauswahl-Erkennung nur im Bearbeitungsmodus + if (!readOnly) { + setupTextSelectionListener(emailContent); + } + Scroller scroller = new Scroller(contentBox); scroller.setSizeFull(); scroller.setScrollDirection(Scroller.ScrollDirection.VERTICAL); - panel.add(title, scroller); + if (!readOnly) { + panel.add(title, selectionHint, scroller); + } else { + panel.add(title, scroller); + } panel.setFlexGrow(1, scroller); return panel; } + private void setupTextSelectionListener(Pre emailContent) { + emailContent.getElement().executeJs( + "const element = this;" + + "let selectionTimeout = null;" + + "element.addEventListener('mouseup', function(e) {" + + " if (selectionTimeout) clearTimeout(selectionTimeout);" + + " selectionTimeout = setTimeout(function() {" + + " const selection = window.getSelection();" + + " const selectedText = selection.toString().trim();" + + " if (selectedText.length > 10) {" + + " $0.$server.onTextSelected(selectedText);" + + " }" + + " }, 300);" + + "});", + getElement() + ); + } + + @com.vaadin.flow.component.ClientCallable + public void onTextSelected(String selectedText) { + if (selectedText == null || selectedText.isBlank() || readOnly) { + return; + } + + // Zeige Lade-Benachrichtigung + Notification loadingNotification = Notification.show( + "Analysiere markierten Text...", + 0, + Notification.Position.MIDDLE + ); + + // Führe die Adressextraktion asynchron aus + getUI().ifPresent(ui -> { + new Thread(() -> { + try { + Station extractedStation = llmService.extractAddressFromText(selectedText); + + ui.access(() -> { + loadingNotification.close(); + + if (extractedStation != null) { + // Dialog zur Bestätigung und Bearbeitung der extrahierten Adresse + openAddStationDialog(extractedStation); + } else { + Notification.show( + "Keine Adresse im markierten Text erkannt", + 3000, + Notification.Position.BOTTOM_START + ); + } + }); + } catch (Exception e) { + ui.access(() -> { + loadingNotification.close(); + Notification.show( + "Fehler bei der Adresserkennung: " + e.getMessage(), + 5000, + Notification.Position.MIDDLE + ); + }); + } + }).start(); + }); + } + + private void openAddStationDialog(Station extractedStation) { + Dialog addDialog = new Dialog(); + addDialog.setHeaderTitle("Neue Station hinzufügen"); + addDialog.setWidth("500px"); + + VerticalLayout dialogContent = new VerticalLayout(); + dialogContent.setPadding(false); + dialogContent.setSpacing(true); + + FormLayout formLayout = new FormLayout(); + formLayout.setResponsiveSteps(new FormLayout.ResponsiveStep("0", 1)); + + TextField nameField = new TextField("Name"); + nameField.setWidthFull(); + nameField.setValue(extractedStation.getName() != null ? extractedStation.getName() : ""); + + TextArea addressField = new TextArea("Adresse"); + addressField.setWidthFull(); + addressField.setMinHeight("80px"); + addressField.setValue(extractedStation.getAddress() != null ? extractedStation.getAddress() : ""); + + ComboBox actionComboBox = new ComboBox<>("Aktion"); + actionComboBox.setItems(StationAction.values()); + actionComboBox.setItemLabelGenerator(StationAction::getDisplayName); + actionComboBox.setValue(StationAction.PICKUP); + actionComboBox.setWidthFull(); + + formLayout.add(nameField, addressField, actionComboBox); + + // Buttons für Google-Validierung und Kartenansicht + HorizontalLayout addressActions = new HorizontalLayout(); + addressActions.setSpacing(true); + addressActions.setWidthFull(); + + Button validateButton = new Button("Adresse validieren", new Icon(VaadinIcon.CHECK_CIRCLE)); + validateButton.addThemeVariants(ButtonVariant.LUMO_SMALL, ButtonVariant.LUMO_TERTIARY); + validateButton.getElement().setAttribute("title", "Adresse über Google validieren und formatieren"); + validateButton.addClickListener(e -> validateAddressWithGoogle(addressField)); + + Button showMapButton = new Button("Auf Karte anzeigen", new Icon(VaadinIcon.MAP_MARKER)); + showMapButton.addThemeVariants(ButtonVariant.LUMO_SMALL, ButtonVariant.LUMO_TERTIARY); + showMapButton.getElement().setAttribute("title", "Adresse auf Google Maps anzeigen"); + showMapButton.addClickListener(e -> { + String address = addressField.getValue(); + String name = nameField.getValue(); + if (address == null || address.isBlank()) { + Notification.show("Bitte geben Sie eine Adresse ein", 3000, Notification.Position.BOTTOM_START); + return; + } + AddressMapDialog mapDialog = new AddressMapDialog(name, address, googleMapsApiKey); + mapDialog.open(); + }); + + addressActions.add(validateButton, showMapButton); + + dialogContent.add(formLayout, addressActions); + addDialog.add(dialogContent); + + Button cancelButton = new Button("Abbrechen", e -> addDialog.close()); + Button addButton = new Button("Hinzufügen", e -> { + Station newStation = new Station(); + newStation.setName(nameField.getValue()); + newStation.setAddress(addressField.getValue()); + newStation.setAction(actionComboBox.getValue()); + + // Station zur Liste hinzufügen + if (summary.getStations() == null) { + summary.setStations(new ArrayList<>()); + } + summary.getStations().add(newStation); + + // Summary aktualisieren + orderEmail.setSummaryJson(llmService.serializeSummary(summary)); + onProcessed.accept(orderEmail); + + // UI komplett neu aufbauen, da möglicherweise die Stationen-Sektion noch nicht existierte + remove(contentLayout); + getFooter().removeAll(); + contentLayout = createContent(); + add(contentLayout); + createFooter(); + + addDialog.close(); + Notification.show("Station hinzugefügt", 3000, Notification.Position.BOTTOM_START); + }); + addButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY); + + addDialog.getFooter().add(cancelButton, addButton); + addDialog.open(); + } + + private void validateAddressWithGoogle(TextArea addressField) { + String address = addressField.getValue(); + if (address == null || address.isBlank()) { + Notification.show("Bitte geben Sie eine Adresse ein", 3000, Notification.Position.BOTTOM_START); + return; + } + + Notification loadingNotification = Notification.show( + "Validiere Adresse...", + 0, + Notification.Position.MIDDLE + ); + + getUI().ifPresent(ui -> { + new Thread(() -> { + try { + GoogleGeocodingService.ValidationResult result = geocodingService.validateAddress(address); + + ui.access(() -> { + loadingNotification.close(); + + if (result.valid()) { + Notification validNotification = new Notification(); + validNotification.addThemeVariants( + com.vaadin.flow.component.notification.NotificationVariant.LUMO_SUCCESS + ); + validNotification.setText("Adresse gültig: " + result.formattedAddress()); + validNotification.setDuration(5000); + validNotification.setPosition(Notification.Position.BOTTOM_START); + validNotification.open(); + } else { + Notification invalidNotification = new Notification(); + invalidNotification.addThemeVariants( + com.vaadin.flow.component.notification.NotificationVariant.LUMO_ERROR + ); + invalidNotification.setText("Adresse nicht gefunden: " + result.errorMessage()); + invalidNotification.setDuration(5000); + invalidNotification.setPosition(Notification.Position.MIDDLE); + invalidNotification.open(); + } + }); + } catch (Exception e) { + ui.access(() -> { + loadingNotification.close(); + Notification.show( + "Fehler bei der Validierung: " + e.getMessage(), + 5000, + Notification.Position.MIDDLE + ); + }); + } + }).start(); + }); + } + private VerticalLayout createRightPanel() { VerticalLayout panel = new VerticalLayout(); panel.setPadding(false); @@ -411,7 +654,6 @@ public class OrderDetailDialog extends Dialog { Div stationCard = new Div(); stationCard.addClassNames( - LumoUtility.Background.CONTRAST_5, LumoUtility.BorderRadius.MEDIUM, LumoUtility.Padding.SMALL ); @@ -419,6 +661,9 @@ public class OrderDetailDialog extends Dialog { .set("flex", "1") .set("position", "relative"); + // Hintergrundfarbe basierend auf Validierungsstatus + applyValidationBackground(stationCard, station); + HorizontalLayout stationContent = new HorizontalLayout(); stationContent.setAlignItems(FlexComponent.Alignment.CENTER); stationContent.setSpacing(true); @@ -516,6 +761,109 @@ public class OrderDetailDialog extends Dialog { showTourButton.setWidthFull(); section.add(showTourButton); } + + // Automatisch nicht validierte Stationen validieren (nach Attach) + section.addAttachListener(event -> validatePendingStations(section)); + } + + private void applyValidationBackground(Div stationCard, Station station) { + if (station.isValidationPending()) { + // Noch nicht validiert - dezentes Gelb + stationCard.getStyle().set("background-color", "var(--lumo-warning-color-10pct)"); + stationCard.getElement().setAttribute("title", "Adresse wird validiert..."); + } else if (station.isAddressValidAndChecked()) { + // Validiert und gültig - dezentes Grün + stationCard.getStyle().set("background-color", "var(--lumo-success-color-10pct)"); + String tooltip = "Adresse gültig"; + if (station.getValidatedAddress() != null) { + tooltip += ": " + station.getValidatedAddress(); + } + stationCard.getElement().setAttribute("title", tooltip); + } else if (station.isAddressInvalidAndChecked()) { + // Validiert aber ungültig - dezentes Rot + stationCard.getStyle().set("background-color", "var(--lumo-error-color-10pct)"); + stationCard.getElement().setAttribute("title", "Adresse nicht gefunden"); + } else { + // Standard-Hintergrund + stationCard.addClassName(LumoUtility.Background.CONTRAST_5); + } + } + + private void validatePendingStations(VerticalLayout stationsSection) { + var stations = summary.getStations(); + if (stations == null || stations.isEmpty()) { + return; + } + + // Finde Stationen, die noch nicht validiert wurden + var pendingStations = stations.stream() + .filter(Station::isValidationPending) + .filter(s -> s.getAddress() != null && !s.getAddress().isBlank()) + .toList(); + + if (pendingStations.isEmpty()) { + log.debug("No pending stations to validate"); + return; + } + + log.info("Validating {} pending stations", pendingStations.size()); + + var uiOptional = getUI(); + if (uiOptional.isEmpty()) { + log.warn("UI not available, cannot validate stations"); + return; + } + + uiOptional.ifPresent(ui -> { + log.info("Starting validation thread..."); + new Thread(() -> { + log.info("Validation thread started"); + try { + for (Station station : pendingStations) { + try { + log.info("Validating address: {}", station.getAddress()); + GoogleGeocodingService.ValidationResult result = + geocodingService.validateAddress(station.getAddress()); + + station.setAddressValidated(true); + station.setAddressValid(result.valid()); + if (result.valid()) { + station.setValidatedAddress(result.formattedAddress()); + log.info("Address valid: {}", result.formattedAddress()); + } else { + log.info("Address invalid: {}", result.errorMessage()); + } + } catch (Exception e) { + log.error("Error validating address: {}", station.getAddress(), e); + station.setAddressValidated(true); + station.setAddressValid(false); + } + } + + log.info("All stations validated, updating UI"); + + // Update UI - nur wenn noch attached + if (ui.isAttached()) { + ui.access(() -> { + try { + orderEmail.setSummaryJson(llmService.serializeSummary(summary)); + onProcessed.accept(orderEmail); + // Section neu aufbauen - validatePendingStations wird erneut + // aufgerufen, findet aber keine pending stations mehr + refreshStationsSection(stationsSection); + log.debug("UI updated successfully"); + } catch (Exception e) { + log.error("Error updating UI after validation", e); + } + }); + } else { + log.warn("UI not attached, skipping UI update"); + } + } catch (Exception e) { + log.error("Error in validation thread", e); + } + }).start(); + }); } private void reorderStations(int fromIndex, int toIndex, VerticalLayout section) { @@ -541,7 +889,11 @@ public class OrderDetailDialog extends Dialog { private void openStationEditDialog(Station station, VerticalLayout stationsSection) { Dialog editDialog = new Dialog(); editDialog.setHeaderTitle("Station bearbeiten"); - editDialog.setWidth("400px"); + editDialog.setWidth("500px"); + + VerticalLayout dialogContent = new VerticalLayout(); + dialogContent.setPadding(false); + dialogContent.setSpacing(true); FormLayout formLayout = new FormLayout(); formLayout.setResponsiveSteps(new FormLayout.ResponsiveStep("0", 1)); @@ -562,14 +914,52 @@ public class OrderDetailDialog extends Dialog { actionComboBox.setWidthFull(); formLayout.add(nameField, addressField, actionComboBox); - editDialog.add(formLayout); + + // Buttons für Google-Validierung und Kartenansicht + HorizontalLayout addressActions = new HorizontalLayout(); + addressActions.setSpacing(true); + addressActions.setWidthFull(); + + Button validateButton = new Button("Adresse validieren", new Icon(VaadinIcon.CHECK_CIRCLE)); + validateButton.addThemeVariants(ButtonVariant.LUMO_SMALL, ButtonVariant.LUMO_TERTIARY); + validateButton.getElement().setAttribute("title", "Adresse über Google validieren"); + validateButton.addClickListener(e -> validateAddressWithGoogle(addressField)); + + Button showMapButton = new Button("Auf Karte anzeigen", new Icon(VaadinIcon.MAP_MARKER)); + showMapButton.addThemeVariants(ButtonVariant.LUMO_SMALL, ButtonVariant.LUMO_TERTIARY); + showMapButton.getElement().setAttribute("title", "Adresse auf Google Maps anzeigen"); + showMapButton.addClickListener(e -> { + String address = addressField.getValue(); + String name = nameField.getValue(); + if (address == null || address.isBlank()) { + Notification.show("Bitte geben Sie eine Adresse ein", 3000, Notification.Position.BOTTOM_START); + return; + } + AddressMapDialog mapDialog = new AddressMapDialog(name, address, googleMapsApiKey); + mapDialog.open(); + }); + + addressActions.add(validateButton, showMapButton); + + dialogContent.add(formLayout, addressActions); + editDialog.add(dialogContent); Button cancelButton = new Button("Abbrechen", e -> editDialog.close()); Button saveButton = new Button("Speichern", e -> { + // Prüfen ob Adresse geändert wurde + boolean addressChanged = !addressField.getValue().equals(station.getAddress()); + station.setName(nameField.getValue()); station.setAddress(addressField.getValue()); station.setAction(actionComboBox.getValue()); + // Bei Adressänderung Validierung zurücksetzen + if (addressChanged) { + station.setAddressValidated(null); + station.setAddressValid(null); + station.setValidatedAddress(null); + } + // Update summary JSON orderEmail.setSummaryJson(llmService.serializeSummary(summary)); onProcessed.accept(orderEmail);