Erweiterungen

This commit is contained in:
2026-01-23 11:13:44 +01:00
parent e5e1af70fb
commit 148cf94abf
6 changed files with 883 additions and 5 deletions

View File

@@ -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);
}
}

View File

@@ -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 +
"&region=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());
}
}
}

View File

@@ -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<String, Object> 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;
}
}
}

View File

@@ -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 = '<div style="display:flex;align-items:center;justify-content:center;height:100%%;color:var(--lumo-secondary-text-color)">Keine Adresse angegeben</div>';
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: '<div style="padding:8px;"><strong>' + (name || 'Adresse') + '</strong><br>' + results[0].formatted_address + '</div>'
});
marker.addListener('click', function() {
infoWindow.open(map, marker);
});
// Auto-open info window
infoWindow.open(map, marker);
} else {
console.error('Geocoding failed:', status);
mapDiv.innerHTML = '<div style="display:flex;align-items:center;justify-content:center;height:100%%;color:var(--lumo-error-text-color)">Adresse konnte nicht gefunden werden: ' + status + '</div>';
}
});
}
})();
""".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);
}
}

View File

@@ -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

View File

@@ -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<OrderEmail> onProcessed;
private final Consumer<OrderEmail> onDelete;
private final String googleMapsApiKey;
@@ -54,11 +60,13 @@ public class OrderDetailDialog extends Dialog {
private EmailField recipientField;
private HorizontalLayout contentLayout;
private Binder<OrderSummary> binder;
private VerticalLayout stationsSection;
public OrderDetailDialog(
OrderEmail orderEmail,
LlmService llmService,
SmtpEmailService smtpEmailService,
GoogleGeocodingService geocodingService,
String googleMapsApiKey,
Consumer<OrderEmail> onProcessed,
Consumer<OrderEmail> 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);
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<StationAction> 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);