Erweiterungen

This commit is contained in:
2026-02-17 15:51:23 +01:00
parent ec1299d265
commit b114c115d7
41 changed files with 3666 additions and 1793 deletions

View File

@@ -17,3 +17,6 @@ History currently uses brief German titles; shift to imperative, scoped summarie
## Security & Configuration Tips ## Security & Configuration Tips
External service credentials for MongoDB, SMTP, and MQTT belong in environment variables or a developer-specific `application-local.properties` kept out of version control. Document default ports and topics when touching `MqttConfig` so ops can replicate environments. For two-factor flows, keep shared secrets in secure storage and avoid logging codes during development. External service credentials for MongoDB, SMTP, and MQTT belong in environment variables or a developer-specific `application-local.properties` kept out of version control. Document default ports and topics when touching `MqttConfig` so ops can replicate environments. For two-factor flows, keep shared secrets in secure storage and avoid logging codes during development.
# Misc
Never start the application; leave that to the user.

View File

@@ -27,7 +27,8 @@ public class LocationApiController {
/** /**
* Gibt die aktuelle Position eines App-Nutzers zurück. * Gibt die aktuelle Position eines App-Nutzers zurück.
* *
* @param appUserId die ID des App-Nutzers * @param appUserId
* die ID des App-Nutzers
* @return die aktuelle Position oder 404 wenn keine vorhanden * @return die aktuelle Position oder 404 wenn keine vorhanden
*/ */
@GetMapping("/{appUserId}") @GetMapping("/{appUserId}")

View File

@@ -36,8 +36,8 @@ import java.util.Optional;
import org.bson.types.ObjectId; import org.bson.types.ObjectId;
/** /**
* Message controller for handling real-time communication with apps. * Message controller for handling real-time communication with apps. Provides
* Provides endpoints for sending and receiving messages via WebSocket. * endpoints for sending and receiving messages via WebSocket.
*/ */
@Component @Component
@Slf4j @Slf4j
@@ -409,9 +409,9 @@ public class MessageController {
/** /**
* Handle incoming message from a client via WebSocket. Client sends to * Handle incoming message from a client via WebSocket. Client sends to
* /server/message with payload: { "content": "message payload", * /server/message with payload: { "content": "message payload", "contentType":
* "contentType": "TEXT|IMAGE", "jobId": "optional job id", "jobNumber": * "TEXT|IMAGE", "jobId": "optional job id", "jobNumber": "optional job number"
* "optional job number" } * }
* *
* The appUserId is determined from the authenticated WebSocket session. * The appUserId is determined from the authenticated WebSocket session.
*/ */

View File

@@ -10,6 +10,9 @@ import lombok.NoArgsConstructor;
public class AppLoginResponse { public class AppLoginResponse {
private boolean success; private boolean success;
private String message; private String message;
/** Only populated on success, for internal server-side routing. Not sent to client. */ /**
* Only populated on success, for internal server-side routing. Not sent to
* client.
*/
private String appUserId; private String appUserId;
} }

View File

@@ -113,9 +113,7 @@ public class MessagingConfig {
// Send success response to the now-authenticated session // Send success response to the now-authenticated session
// locationTrackingEnabled: true = client should send position updates // locationTrackingEnabled: true = client should send position updates
Map<String, Object> authResponse = Map.of( Map<String, Object> authResponse = Map.of("success", true, "message", response.getMessage(),
"success", true,
"message", response.getMessage(),
"locationTrackingEnabled", true); "locationTrackingEnabled", true);
byte[] responseBytes = objectMapper.writeValueAsBytes(authResponse); byte[] responseBytes = objectMapper.writeValueAsBytes(authResponse);
webSocketService.sendToClient(appUserId, "auth", responseBytes); webSocketService.sendToClient(appUserId, "auth", responseBytes);

View File

@@ -255,8 +255,8 @@ public class WebSocketService extends TextWebSocketHandler {
} }
/** /**
* Register a pending session as authenticated under the given appUserId. * Register a pending session as authenticated under the given appUserId. Called
* Called by MessagingConfig after successful login. * by MessagingConfig after successful login.
*/ */
public void registerAuthenticatedSession(String wsSessionId, String appUserId) { public void registerAuthenticatedSession(String wsSessionId, String appUserId) {
PendingSession pending = pendingSessions.get(wsSessionId); PendingSession pending = pendingSessions.get(wsSessionId);

View File

@@ -0,0 +1,53 @@
package de.assecutor.votianlt.model;
import lombok.Data;
/**
* Speichert das Ergebnis einer Adressvalidierung. Wird verwendet, um zu merken,
* ob eine Adresse bereits validiert wurde und ob sie gültig ist.
*/
@Data
public class AddressValidationResult {
private final String addressType; // "pickup" oder "delivery"
private final String street;
private final String houseNumber;
private final String zip;
private final String city;
private boolean valid;
private String formattedAddress;
private double latitude;
private double longitude;
private String validationMessage;
public AddressValidationResult(String addressType, String street, String houseNumber, String zip, String city) {
this.addressType = addressType;
this.street = street;
this.houseNumber = houseNumber;
this.zip = zip;
this.city = city;
this.valid = false;
}
/**
* Erstellt einen eindeutigen Schlüssel für diese Adresse
*/
public String getAddressKey() {
return String.format("%s|%s|%s|%s|%s", addressType, normalize(street), normalize(houseNumber), normalize(zip),
normalize(city));
}
private String normalize(String value) {
return value != null ? value.trim().toLowerCase() : "";
}
/**
* Prüft, ob diese Validierung für die angegebenen Adressdaten gilt
*/
public boolean matches(String street, String houseNumber, String zip, String city) {
return normalize(this.street).equals(normalize(street))
&& normalize(this.houseNumber).equals(normalize(houseNumber))
&& normalize(this.zip).equals(normalize(zip)) && normalize(this.city).equals(normalize(city));
}
}

View File

@@ -9,8 +9,8 @@ import org.springframework.data.mongodb.core.mapping.Document;
import java.time.LocalDateTime; import java.time.LocalDateTime;
/** /**
* Stores invoice template data for a user. * Stores invoice template data for a user. Contains the JSON representation of
* Contains the JSON representation of the canvas elements. * the canvas elements.
*/ */
@Document(collection = "invoice_templates") @Document(collection = "invoice_templates")
@Data @Data

View File

@@ -133,6 +133,14 @@ public class Job {
@Field("price") @Field("price")
private BigDecimal price; private BigDecimal price;
// Gefahrene Kilometer für Rechnungsstellung
@Field("kilometers_driven")
private Integer kilometersDriven;
// Arbeitszeit in 15-Minuten-Einheiten für Rechnungsstellung
@Field("time_in_15min_units")
private Integer timeIn15MinUnits;
/** /**
* Returns the ObjectId as string for JSON serialization. This ensures that the * 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. * job id is returned as a string when jobs are retrieved via API.

View File

@@ -77,8 +77,8 @@ public class LocationPosition {
@Indexed(expireAfter = "3600s") // TTL index: auto-delete after 60 minutes @Indexed(expireAfter = "3600s") // TTL index: auto-delete after 60 minutes
private Instant receivedAt; private Instant receivedAt;
public LocationPosition(String appUserId, Double latitude, Double longitude, Double accuracy, public LocationPosition(String appUserId, Double latitude, Double longitude, Double accuracy, Double altitude,
Double altitude, Double speed, Double heading, Instant timestamp) { Double speed, Double heading, Instant timestamp) {
this.appUserId = appUserId; this.appUserId = appUserId;
this.latitude = latitude; this.latitude = latitude;
this.longitude = longitude; this.longitude = longitude;

View File

@@ -1,12 +1,11 @@
package de.assecutor.votianlt.model; package de.assecutor.votianlt.model;
/** /**
* Delivery status for messages sent to clients. * Delivery status for messages sent to clients. Tracks whether a message was
* Tracks whether a message was successfully delivered via WebSocket. * successfully delivered via WebSocket.
*/ */
public enum MessageDeliveryStatus { public enum MessageDeliveryStatus {
NOTSEND("Nicht gesendet"), NOTSEND("Nicht gesendet"), SEND("Gesendet");
SEND("Gesendet");
private final String displayName; private final String displayName;

View File

@@ -0,0 +1,44 @@
package de.assecutor.votianlt.model;
import lombok.Data;
/**
* Speichert das Ergebnis einer Routenberechnung zwischen zwei Adressen.
*/
@Data
public class RouteCalculationResult {
private boolean valid;
private double distanceKm;
private int durationSeconds;
private String formattedDistance;
private String formattedDuration;
private String routeMessage;
public RouteCalculationResult() {
this.valid = false;
this.distanceKm = 0.0;
this.durationSeconds = 0;
}
/**
* Gibt die Dauer in Minuten zurück
*/
public int getDurationMinutes() {
return durationSeconds / 60;
}
/**
* Gibt die Dauer formatiert zurück (z.B. "1 Std. 30 Min." oder "45 Min.")
*/
public String getFormattedDurationLong() {
int hours = durationSeconds / 3600;
int minutes = (durationSeconds % 3600) / 60;
if (hours > 0) {
return String.format("%d Std. %d Min.", hours, minutes);
} else {
return String.format("%d Min.", minutes);
}
}
}

View File

@@ -0,0 +1,138 @@
package de.assecutor.votianlt.model;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.mapping.Document;
import java.math.BigDecimal;
@Document(collection = "services")
public class Service {
@Id
private String id;
private String userId;
private String name;
private CalculationBasis calculationBasis;
private BigDecimal price; // For FLAT_RATE services
private BigDecimal pricePerKilometer; // For DISTANCE services - price per kilometer
private BigDecimal pricePer15Minutes; // For TIME services - price per 15 minutes
private BigDecimal vatRate;
private boolean mandatory;
public enum CalculationBasis {
DISTANCE, TIME, FLAT_RATE
}
public Service() {
}
public Service(String userId, String name, CalculationBasis calculationBasis, BigDecimal price,
BigDecimal vatRate) {
this(userId, name, calculationBasis, price, vatRate, false);
}
public Service(String userId, String name, CalculationBasis calculationBasis, BigDecimal price, BigDecimal vatRate,
boolean mandatory) {
this.userId = userId;
this.name = name;
this.calculationBasis = calculationBasis;
this.vatRate = vatRate;
this.mandatory = mandatory;
// Set the appropriate price field based on calculation basis
switch (calculationBasis) {
case DISTANCE:
this.pricePerKilometer = price;
break;
case TIME:
this.pricePer15Minutes = price;
break;
case FLAT_RATE:
this.price = price;
break;
}
}
public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
public String getUserId() {
return userId;
}
public void setUserId(String userId) {
this.userId = userId;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public CalculationBasis getCalculationBasis() {
return calculationBasis;
}
public void setCalculationBasis(CalculationBasis calculationBasis) {
this.calculationBasis = calculationBasis;
}
public BigDecimal getVatRate() {
return vatRate;
}
public void setVatRate(BigDecimal vatRate) {
this.vatRate = vatRate;
}
public BigDecimal getPrice() {
return price;
}
public void setPrice(BigDecimal price) {
this.price = price;
}
public BigDecimal getPricePerKilometer() {
return pricePerKilometer;
}
public void setPricePerKilometer(BigDecimal pricePerKilometer) {
this.pricePerKilometer = pricePerKilometer;
}
public BigDecimal getPricePer15Minutes() {
return pricePer15Minutes;
}
public void setPricePer15Minutes(BigDecimal pricePer15Minutes) {
this.pricePer15Minutes = pricePer15Minutes;
}
/**
* Get the appropriate price based on calculation basis
*/
public BigDecimal getEffectivePrice() {
return switch (calculationBasis) {
case DISTANCE -> pricePerKilometer;
case TIME -> pricePer15Minutes;
case FLAT_RATE -> price;
};
}
public boolean isMandatory() {
return mandatory;
}
public void setMandatory(boolean mandatory) {
this.mandatory = mandatory;
}
}

View File

@@ -182,8 +182,8 @@ public class AddJobService {
} }
/** /**
* Sendet den neu erstellten Job per WebSocket an den zugewiesenen Client, falls dieser * Sendet den neu erstellten Job per WebSocket an den zugewiesenen Client, falls
* online ist. * dieser online ist.
*/ */
private void notifyClientJobCreated(Job job) { private void notifyClientJobCreated(Job job) {
if (!job.isDigitalProcessing()) { if (!job.isDigitalProcessing()) {

View File

@@ -0,0 +1,292 @@
package de.assecutor.votianlt.pages.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import de.assecutor.votianlt.model.AddressValidationResult;
import de.assecutor.votianlt.model.RouteCalculationResult;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
/**
* Service zur Validierung von Adressen über die Google Geocoding API.
*/
@Service
@Slf4j
public class AddressValidationService {
@Value("${app.google.maps.api-key:}")
private String googleMapsApiKey;
private final HttpClient httpClient;
private final ObjectMapper objectMapper;
private static final String GEOCODING_API_URL = "https://maps.googleapis.com/maps/api/geocode/json";
private static final String DIRECTIONS_API_URL = "https://maps.googleapis.com/maps/api/directions/json";
public AddressValidationService() {
this.httpClient = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build();
this.objectMapper = new ObjectMapper();
}
/**
* Validiert eine Adresse über die Google Geocoding API.
*
* @param addressType
* "pickup" oder "delivery"
* @param street
* Straße
* @param houseNumber
* Hausnummer
* @param zip
* Postleitzahl
* @param city
* Stadt
* @return AddressValidationResult mit dem Validierungsergebnis
*/
public AddressValidationResult validateAddress(String addressType, String street, String houseNumber, String zip,
String city) {
AddressValidationResult result = new AddressValidationResult(addressType, street, houseNumber, zip, city);
// Prüfen, ob API-Key konfiguriert ist
if (googleMapsApiKey == null || googleMapsApiKey.isBlank()) {
log.warn("Google Maps API Key nicht konfiguriert. Adressvalidierung übersprungen.");
result.setValidationMessage("API-Key nicht konfiguriert");
return result;
}
// Prüfen, ob alle erforderlichen Felder vorhanden sind
if (isEmpty(street) || isEmpty(zip) || isEmpty(city)) {
result.setValidationMessage("Unvollständige Adresse");
return result;
}
try {
// Adressstring zusammenbauen
String addressString = buildAddressString(street, houseNumber, zip, city);
String encodedAddress = URLEncoder.encode(addressString, StandardCharsets.UTF_8);
// URL für die Geocoding API erstellen
String requestUrl = String.format("%s?address=%s&key=%s&language=de&region=de", GEOCODING_API_URL,
encodedAddress, googleMapsApiKey);
log.debug("Validiere Adresse: {}", addressString);
// HTTP Request senden
HttpRequest request = HttpRequest.newBuilder().uri(URI.create(requestUrl)).GET()
.timeout(Duration.ofSeconds(10)).build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
log.error("Fehler bei der Geocoding API: HTTP {}", response.statusCode());
result.setValidationMessage("Fehler bei der API-Anfrage");
return result;
}
// JSON Response parsen
JsonNode rootNode = objectMapper.readTree(response.body());
String status = rootNode.path("status").asText();
if (!"OK".equals(status)) {
log.warn("Geocoding API Status: {} für Adresse: {}", status, addressString);
result.setValidationMessage("Adresse nicht gefunden: " + status);
return result;
}
// Ergebnisse extrahieren
JsonNode results = rootNode.path("results");
if (results.isEmpty()) {
result.setValidationMessage("Keine Ergebnisse gefunden");
return result;
}
JsonNode firstResult = results.get(0);
String formattedAddress = firstResult.path("formatted_address").asText();
// Geokoordinaten extrahieren
JsonNode geometry = firstResult.path("geometry");
JsonNode location = geometry.path("location");
double lat = location.path("lat").asDouble();
double lng = location.path("lng").asDouble();
// Prüfen, ob die Adresse als "ROOFTOP" (genaue Adresse) oder
// "RANGE_INTERPOLATED" gefunden wurde
String locationType = geometry.path("location_type").asText();
boolean isPrecise = "ROOFTOP".equals(locationType) || "RANGE_INTERPOLATED".equals(locationType);
// Adresskomponenten prüfen
boolean hasStreetNumber = false;
boolean hasPostalCode = false;
JsonNode addressComponents = firstResult.path("address_components");
for (JsonNode component : addressComponents) {
JsonNode types = component.path("types");
for (JsonNode type : types) {
String typeStr = type.asText();
if ("street_number".equals(typeStr)) {
hasStreetNumber = true;
} else if ("postal_code".equals(typeStr)) {
hasPostalCode = true;
}
}
}
// Ergebnis setzen
result.setValid(isPrecise && hasPostalCode);
result.setFormattedAddress(formattedAddress);
result.setLatitude(lat);
result.setLongitude(lng);
if (result.isValid()) {
result.setValidationMessage("Adresse erfolgreich validiert");
} else {
result.setValidationMessage("Adresse ungenau gefunden (keine Hausnummer oder Postleitzahl)");
}
log.debug("Adressvalidierung erfolgreich: {} -> {}", addressString, formattedAddress);
} catch (Exception e) {
log.error("Fehler bei der Adressvalidierung", e);
result.setValidationMessage("Fehler: " + e.getMessage());
}
return result;
}
/**
* Baut einen zusammenhängenden Adressstring für die API-Anfrage.
*/
private String buildAddressString(String street, String houseNumber, String zip, String city) {
StringBuilder sb = new StringBuilder();
sb.append(street.trim());
if (!isEmpty(houseNumber)) {
sb.append(" ").append(houseNumber.trim());
}
sb.append(", ").append(zip.trim()).append(" ").append(city.trim());
sb.append(", Deutschland");
return sb.toString();
}
private boolean isEmpty(String value) {
return value == null || value.trim().isEmpty();
}
/**
* Berechnet die schnellste Route zwischen zwei Adressen über die Google
* Directions API.
*
* @param pickupResult
* Validierungsergebnis der Abholadresse (muss gültige Koordinaten
* enthalten)
* @param deliveryResult
* Validierungsergebnis der Lieferadresse (muss gültige Koordinaten
* enthalten)
* @return RouteCalculationResult mit Entfernung und Dauer
*/
public RouteCalculationResult calculateRoute(AddressValidationResult pickupResult,
AddressValidationResult deliveryResult) {
RouteCalculationResult routeResult = new RouteCalculationResult();
// Prüfen, ob API-Key konfiguriert ist
if (googleMapsApiKey == null || googleMapsApiKey.isBlank()) {
log.warn("Google Maps API Key nicht konfiguriert. Routenberechnung übersprungen.");
routeResult.setRouteMessage("API-Key nicht konfiguriert");
return routeResult;
}
// Prüfen, ob beide Adressen gültige Koordinaten haben
if (pickupResult == null || !pickupResult.isValid() || deliveryResult == null || !deliveryResult.isValid()) {
routeResult.setRouteMessage("Beide Adressen müssen validiert sein");
return routeResult;
}
try {
// Koordinaten für Start und Ziel
String origin = String.format("%s,%s", pickupResult.getLatitude(), pickupResult.getLongitude());
String destination = String.format("%s,%s", deliveryResult.getLatitude(), deliveryResult.getLongitude());
// URL für die Directions API erstellen
String requestUrl = String.format("%s?origin=%s&destination=%s&mode=driving&key=%s&language=de&region=de",
DIRECTIONS_API_URL, origin, destination, googleMapsApiKey);
log.debug("Berechne Route von {} nach {}", pickupResult.getFormattedAddress(),
deliveryResult.getFormattedAddress());
// HTTP Request senden
HttpRequest request = HttpRequest.newBuilder().uri(URI.create(requestUrl)).GET()
.timeout(Duration.ofSeconds(10)).build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
log.error("Fehler bei der Directions API: HTTP {}", response.statusCode());
routeResult.setRouteMessage("Fehler bei der API-Anfrage");
return routeResult;
}
// JSON Response parsen
JsonNode rootNode = objectMapper.readTree(response.body());
String status = rootNode.path("status").asText();
if (!"OK".equals(status)) {
log.warn("Directions API Status: {}", status);
routeResult.setRouteMessage("Route konnte nicht berechnet werden: " + status);
return routeResult;
}
// Routen extrahieren
JsonNode routes = rootNode.path("routes");
if (routes.isEmpty()) {
routeResult.setRouteMessage("Keine Route gefunden");
return routeResult;
}
JsonNode firstRoute = routes.get(0);
JsonNode legs = firstRoute.path("legs");
if (legs.isEmpty()) {
routeResult.setRouteMessage("Keine Routeninformationen verfügbar");
return routeResult;
}
// Ersten Leg (Hauptstrecke) verwenden
JsonNode firstLeg = legs.get(0);
// Distanz extrahieren
JsonNode distanceNode = firstLeg.path("distance");
int distanceMeters = distanceNode.path("value").asInt();
String distanceText = distanceNode.path("text").asText();
// Dauer extrahieren
JsonNode durationNode = firstLeg.path("duration");
int durationSeconds = durationNode.path("value").asInt();
String durationText = durationNode.path("text").asText();
// Ergebnis setzen
routeResult.setValid(true);
routeResult.setDistanceKm(distanceMeters / 1000.0);
routeResult.setDurationSeconds(durationSeconds);
routeResult.setFormattedDistance(distanceText);
routeResult.setFormattedDuration(durationText);
routeResult.setRouteMessage(
String.format("Route: %s, Dauer: %s", distanceText, routeResult.getFormattedDurationLong()));
log.debug("Routenberechnung erfolgreich: {} km, {} Min.", routeResult.getDistanceKm(),
routeResult.getDurationMinutes());
} catch (Exception e) {
log.error("Fehler bei der Routenberechnung", e);
routeResult.setRouteMessage("Fehler: " + e.getMessage());
}
return routeResult;
}
}

View File

@@ -44,6 +44,7 @@ import de.assecutor.votianlt.model.task.CommentTask;
import de.assecutor.votianlt.pages.service.AddJobService; import de.assecutor.votianlt.pages.service.AddJobService;
import de.assecutor.votianlt.pages.service.CustomerService; import de.assecutor.votianlt.pages.service.CustomerService;
import de.assecutor.votianlt.pages.service.AddCustomerService; import de.assecutor.votianlt.pages.service.AddCustomerService;
import de.assecutor.votianlt.pages.service.AddressValidationService;
import de.assecutor.votianlt.model.Customer; import de.assecutor.votianlt.model.Customer;
import de.assecutor.votianlt.pages.service.AppUserService; import de.assecutor.votianlt.pages.service.AppUserService;
import de.assecutor.votianlt.model.AppUser; import de.assecutor.votianlt.model.AppUser;
@@ -51,10 +52,17 @@ import de.assecutor.votianlt.pages.service.TaskTemplateService;
import de.assecutor.votianlt.model.TaskTemplate; import de.assecutor.votianlt.model.TaskTemplate;
import de.assecutor.votianlt.model.User; import de.assecutor.votianlt.model.User;
import de.assecutor.votianlt.security.SecurityService; import de.assecutor.votianlt.security.SecurityService;
import de.assecutor.votianlt.model.Service;
import de.assecutor.votianlt.repository.ServiceRepository;
import com.vaadin.flow.component.grid.Grid;
import java.math.BigDecimal;
import java.math.RoundingMode;
import jakarta.annotation.security.RolesAllowed; import jakarta.annotation.security.RolesAllowed;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import de.assecutor.votianlt.model.CargoItem; import de.assecutor.votianlt.model.CargoItem;
import de.assecutor.votianlt.model.AddressValidationResult;
import de.assecutor.votianlt.model.RouteCalculationResult;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.*; import java.util.*;
import java.util.Objects; import java.util.Objects;
@@ -73,6 +81,8 @@ public class AddJobView extends Main {
private final AppUserService appUserService; private final AppUserService appUserService;
private final TaskTemplateService taskTemplateService; private final TaskTemplateService taskTemplateService;
private final SecurityService securityService; private final SecurityService securityService;
private final ServiceRepository serviceRepository;
private final AddressValidationService addressValidationService;
// Customer selection // Customer selection
private ComboBox<String> customerSelection; private ComboBox<String> customerSelection;
@@ -108,8 +118,12 @@ public class AddJobView extends Main {
private Checkbox digitalProcessing; private Checkbox digitalProcessing;
private ComboBox<AppUser> appUser; private ComboBox<AppUser> appUser;
// Price field // Services for the job
private TextField price; private Grid<Service> servicesGrid;
private final List<Service> selectedServices = new ArrayList<>();
private Span netTotalLabel;
private Span vatTotalLabel;
private Span grossTotalLabel;
// Date picker fields for appointments // Date picker fields for appointments
private DatePicker pickupDate; private DatePicker pickupDate;
@@ -152,19 +166,36 @@ public class AddJobView extends Main {
// Available app users for the current user // Available app users for the current user
private List<AppUser> availableAppUsers; private List<AppUser> availableAppUsers;
// Adressvalidierung
private final Map<String, AddressValidationResult> addressValidationResults = new HashMap<>();
private RouteCalculationResult routeCalculationResult;
private String lastPickupStreet = "";
private String lastPickupHouseNumber = "";
private String lastPickupZip = "";
private String lastPickupCity = "";
private String lastDeliveryStreet = "";
private String lastDeliveryHouseNumber = "";
private String lastDeliveryZip = "";
private String lastDeliveryCity = "";
private TabSheet tabSheet;
public AddJobView(AddJobService addJobService, AddCustomerService addCustomerService, public AddJobView(AddJobService addJobService, AddCustomerService addCustomerService,
CustomerService customerService, AppUserService appUserService, TaskTemplateService taskTemplateService, CustomerService customerService, AppUserService appUserService, TaskTemplateService taskTemplateService,
SecurityService securityService) { SecurityService securityService, ServiceRepository serviceRepository,
AddressValidationService addressValidationService) {
this.addJobService = addJobService; this.addJobService = addJobService;
this.addCustomerService = addCustomerService; this.addCustomerService = addCustomerService;
this.customerService = customerService; this.customerService = customerService;
this.appUserService = appUserService; this.appUserService = appUserService;
this.taskTemplateService = taskTemplateService; this.taskTemplateService = taskTemplateService;
this.securityService = securityService; this.securityService = securityService;
this.serviceRepository = serviceRepository;
this.addressValidationService = addressValidationService;
initializeComponents(); initializeComponents();
setupLayout(); setupLayout();
setupValidation(); setupValidation();
loadDraftIfExists(); loadDraftIfExists();
loadMandatoryServices();
} }
private void initializeComponents() { private void initializeComponents() {
@@ -357,20 +388,7 @@ public class AddJobView extends Main {
user -> user.getVorname() + " " + user.getNachname() + " (" + user.getEmail() + ")"); user -> user.getVorname() + " " + user.getNachname() + " (" + user.getEmail() + ")");
appUser.setPlaceholder("App-Nutzer auswählen..."); appUser.setPlaceholder("App-Nutzer auswählen...");
// Price field // Services grid will be initialized in createPriceAndSubmitTab()
price = new TextField("Preis");
price.setPlaceholder("Betrag eingeben");
price.setRequiredIndicatorVisible(true);
// Erzwinge Komma als Dezimaltrennzeichen: ersetze Punkt beim Tippen
price.addValueChangeListener(e -> {
String v = e.getValue();
if (v != null && v.contains(".")) {
String replaced = v.replace('.', ',');
if (!replaced.equals(v))
price.setValue(replaced);
}
});
// Date picker fields for appointments // Date picker fields for appointments
pickupDate = new DatePicker("Datum"); pickupDate = new DatePicker("Datum");
pickupDate.setRequiredIndicatorVisible(true); pickupDate.setRequiredIndicatorVisible(true);
@@ -412,7 +430,7 @@ public class AddJobView extends Main {
// Create TabSheet for organizing the form // Create TabSheet for organizing the form
// TabSheet and Tab references for dynamic label updates // TabSheet and Tab references for dynamic label updates
TabSheet tabSheet = new TabSheet(); tabSheet = new TabSheet();
tabSheet.setSizeFull(); tabSheet.setSizeFull();
// Tab 1: Customer & Addresses // Tab 1: Customer & Addresses
@@ -437,7 +455,10 @@ public class AddJobView extends Main {
} }
// Tab 5: Price & Submit // Tab 5: Price & Submit
priceTab = tabSheet.add("Preis & Abschluss", createPriceAndSubmitTab()); priceTab = tabSheet.add("Leistungen und Preis", createPriceAndSubmitTab());
// Tab-Wechsel-Listener für Adressvalidierung
tabSheet.addSelectedChangeListener(this::onTabChange);
add(tabSheet); add(tabSheet);
@@ -573,24 +594,200 @@ public class AddJobView extends Main {
tabContent.setSizeFull(); tabContent.setSizeFull();
tabContent.setPadding(true); tabContent.setPadding(true);
tabContent.setSpacing(true); tabContent.setSpacing(true);
tabContent.setDefaultHorizontalComponentAlignment(FlexComponent.Alignment.CENTER); tabContent.setDefaultHorizontalComponentAlignment(FlexComponent.Alignment.STRETCH);
// Container with fixed width to center content // Container with full width like other tabs
VerticalLayout content = new VerticalLayout(); VerticalLayout content = new VerticalLayout();
content.setPadding(false); content.setPadding(false);
content.setSpacing(true); content.setSpacing(true);
content.setWidth("720px"); content.setWidthFull();
content.setDefaultHorizontalComponentAlignment(FlexComponent.Alignment.STRETCH); content.setDefaultHorizontalComponentAlignment(FlexComponent.Alignment.STRETCH);
// Preis (netto) - moved from createTasksAndNotesSection // Title
H3 priceTitle = new H3("Preis (netto)"); H3 servicesTitle = new H3("Leistungen");
priceTitle.getStyle().set("margin", "0"); servicesTitle.getStyle().set("margin", "0");
content.add(priceTitle, price); content.add(servicesTitle);
// Services Grid
servicesGrid = new Grid<>();
servicesGrid.setWidthFull();
servicesGrid.setHeight("250px");
servicesGrid.setItems(selectedServices);
servicesGrid.addColumn(Service::getName).setHeader("Leistung").setSortable(true);
servicesGrid.addColumn(service -> {
if (service.getCalculationBasis() != null) {
return switch (service.getCalculationBasis()) {
case DISTANCE -> "Gefahrene Kilometer";
case TIME -> "Zeit";
case FLAT_RATE -> "Pauschal";
};
}
return "";
}).setHeader("Berechnung").setSortable(true);
servicesGrid.addColumn(service -> {
if (service.getEffectivePrice() != null) {
return service.getEffectivePrice().setScale(2, RoundingMode.HALF_UP) + "";
}
return "";
}).setHeader("Preis").setSortable(true);
servicesGrid.addColumn(service -> {
if (service.getVatRate() != null) {
return service.getVatRate().multiply(new BigDecimal("100")).setScale(0, RoundingMode.HALF_UP) + " %";
}
return "";
}).setHeader("MwSt").setSortable(true);
servicesGrid.addComponentColumn(service -> {
Button removeButton = new Button(new Icon(VaadinIcon.TRASH));
removeButton.addThemeVariants(ButtonVariant.LUMO_ERROR, ButtonVariant.LUMO_TERTIARY,
ButtonVariant.LUMO_SMALL);
removeButton.addClickListener(e -> {
selectedServices.remove(service);
servicesGrid.getDataProvider().refreshAll();
updatePriceSummary();
triggerValidation();
updateTabLabels();
});
return removeButton;
}).setHeader("Aktion").setAutoWidth(true).setFlexGrow(0);
content.add(servicesGrid);
// Add Service Button
Button addServiceButton = new Button("Leistung hinzufügen", new Icon(VaadinIcon.PLUS));
addServiceButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
addServiceButton.addClickListener(e -> openAddServiceDialog());
content.add(addServiceButton);
// Price Summary
VerticalLayout summaryLayout = new VerticalLayout();
summaryLayout.setPadding(true);
summaryLayout.setSpacing(true);
summaryLayout.getStyle().set("border", "1px solid var(--lumo-contrast-20pct)");
summaryLayout.getStyle().set("border-radius", "var(--lumo-border-radius-m)");
summaryLayout.getStyle().set("background-color", "var(--lumo-contrast-5pct)");
summaryLayout.setWidthFull();
summaryLayout.setDefaultHorizontalComponentAlignment(FlexComponent.Alignment.STRETCH);
H3 summaryTitle = new H3("Zusammenfassung");
summaryTitle.getStyle().set("margin", "0");
summaryLayout.add(summaryTitle);
// Net total
HorizontalLayout netRow = new HorizontalLayout();
netRow.setWidthFull();
netRow.setJustifyContentMode(FlexComponent.JustifyContentMode.BETWEEN);
Span netLabel = new Span("Nettosumme:");
netTotalLabel = new Span("0,00 €");
netTotalLabel.getStyle().set("font-weight", "bold");
netRow.add(netLabel, netTotalLabel);
summaryLayout.add(netRow);
// VAT total
HorizontalLayout vatRow = new HorizontalLayout();
vatRow.setWidthFull();
vatRow.setJustifyContentMode(FlexComponent.JustifyContentMode.BETWEEN);
Span vatLabel = new Span("Umsatzsteuer:");
vatTotalLabel = new Span("0,00 €");
vatTotalLabel.getStyle().set("font-weight", "bold");
vatRow.add(vatLabel, vatTotalLabel);
summaryLayout.add(vatRow);
// Gross total
HorizontalLayout grossRow = new HorizontalLayout();
grossRow.setWidthFull();
grossRow.setJustifyContentMode(FlexComponent.JustifyContentMode.BETWEEN);
Span grossLabel = new Span("Bruttosumme:");
grossLabel.getStyle().set("font-size", "var(--lumo-font-size-l)");
grossTotalLabel = new Span("0,00 €");
grossTotalLabel.getStyle().set("font-size", "var(--lumo-font-size-l)");
grossTotalLabel.getStyle().set("font-weight", "bold");
grossTotalLabel.getStyle().set("color", "var(--lumo-primary-text-color)");
grossRow.add(grossLabel, grossTotalLabel);
summaryLayout.add(grossRow);
content.add(summaryLayout);
tabContent.add(content); tabContent.add(content);
return tabContent; return tabContent;
} }
private void openAddServiceDialog() {
Dialog dialog = new Dialog();
dialog.setHeaderTitle("Leistung auswählen");
dialog.setWidth("500px");
VerticalLayout dialogContent = new VerticalLayout();
dialogContent.setPadding(true);
dialogContent.setSpacing(true);
// Load available services for current user
List<Service> availableServices = serviceRepository
.findByUserId(securityService.getCurrentDatabaseUser().getId().toString());
ComboBox<Service> serviceCombo = new ComboBox<>("Leistung");
serviceCombo.setWidthFull();
serviceCombo.setItems(availableServices);
serviceCombo.setItemLabelGenerator(service -> {
// Only show price for FLAT_RATE services
if (service.getCalculationBasis() == Service.CalculationBasis.FLAT_RATE
&& service.getEffectivePrice() != null) {
return service.getName() + " (" + service.getEffectivePrice().setScale(2, RoundingMode.HALF_UP) + " €)";
}
return service.getName();
});
serviceCombo.setPlaceholder("Leistung auswählen...");
serviceCombo.setRequired(true);
dialogContent.add(serviceCombo);
HorizontalLayout buttonLayout = new HorizontalLayout();
buttonLayout.setWidthFull();
buttonLayout.setJustifyContentMode(FlexComponent.JustifyContentMode.END);
buttonLayout.setSpacing(true);
Button cancelButton = new Button("Abbrechen", e -> dialog.close());
cancelButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
Button addButton = new Button("Hinzufügen", e -> {
if (serviceCombo.getValue() != null) {
selectedServices.add(serviceCombo.getValue());
servicesGrid.getDataProvider().refreshAll();
updatePriceSummary();
triggerValidation();
updateTabLabels();
dialog.close();
}
});
addButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
buttonLayout.add(cancelButton, addButton);
dialogContent.add(buttonLayout);
dialog.add(dialogContent);
dialog.open();
}
private void updatePriceSummary() {
BigDecimal netTotal = BigDecimal.ZERO;
BigDecimal vatTotal = BigDecimal.ZERO;
BigDecimal grossTotal = BigDecimal.ZERO;
for (Service service : selectedServices) {
BigDecimal price = service.getEffectivePrice() != null ? service.getEffectivePrice() : BigDecimal.ZERO;
BigDecimal vatRate = service.getVatRate() != null ? service.getVatRate() : BigDecimal.ZERO;
netTotal = netTotal.add(price);
BigDecimal vatAmount = price.multiply(vatRate);
vatTotal = vatTotal.add(vatAmount);
grossTotal = grossTotal.add(price.add(vatAmount));
}
netTotalLabel.setText(netTotal.setScale(2, RoundingMode.HALF_UP).toString().replace(".", ",") + "");
vatTotalLabel.setText(vatTotal.setScale(2, RoundingMode.HALF_UP).toString().replace(".", ",") + "");
grossTotalLabel.setText(grossTotal.setScale(2, RoundingMode.HALF_UP).toString().replace(".", ",") + "");
}
private VerticalLayout createPickupSection() { private VerticalLayout createPickupSection() {
VerticalLayout section = new VerticalLayout(); VerticalLayout section = new VerticalLayout();
section.setSpacing(true); section.setSpacing(true);
@@ -833,21 +1030,15 @@ public class AddJobView extends Main {
binder.forField(deliveryCity).asRequired("").bind(Job::getDeliveryCity, Job::setDeliveryCity); binder.forField(deliveryCity).asRequired("").bind(Job::getDeliveryCity, Job::setDeliveryCity);
// Bind price field: Komma-Zahlen in Punkt-Zahlen umsetzen, dann nach BigDecimal // Price is now calculated from selected services - bind to job price for
// konvertieren // storage
binder.forField(price).withNullRepresentation("").asRequired("Preis erforderlich").withConverter((String s) -> { binder.forField(new com.vaadin.flow.component.textfield.TextField()).withConverter((String str) -> {
if (s == null || s.trim().isEmpty()) // Calculate total from selected services
return null; BigDecimal total = selectedServices.stream()
String normalized = s.replace(" ", "").replace(".", "").replace(',', '.'); .map(svc -> svc.getEffectivePrice() != null ? svc.getEffectivePrice() : BigDecimal.ZERO)
try { .reduce(BigDecimal.ZERO, BigDecimal::add);
return new java.math.BigDecimal(normalized); return total;
} catch (NumberFormatException ex) { }, (java.math.BigDecimal bd) -> bd == null ? "" : bd.toString()).bind(Job::getPrice, Job::setPrice);
throw new NumberFormatException("Ungültiger Betrag");
}
}, (java.math.BigDecimal bd) -> bd == null ? "" : bd.toString(), "Ungültiger Betrag")
.withValidator(value -> value != null && value.compareTo(java.math.BigDecimal.ZERO) > 0,
"Der Preis muss größer als 0 sein")
.bind(Job::getPrice, Job::setPrice);
// Bind date picker fields with validation // Bind date picker fields with validation
binder.forField(pickupDate).asRequired("") binder.forField(pickupDate).asRequired("")
@@ -952,7 +1143,7 @@ public class AddJobView extends Main {
// List of all required fields // List of all required fields
TextField[] requiredTextFields = { pickupFirstName, pickupLastName, pickupStreet, pickupHouseNumber, pickupZip, TextField[] requiredTextFields = { pickupFirstName, pickupLastName, pickupStreet, pickupHouseNumber, pickupZip,
pickupCity, deliveryFirstName, deliveryLastName, deliveryStreet, deliveryHouseNumber, deliveryZip, pickupCity, deliveryFirstName, deliveryLastName, deliveryStreet, deliveryHouseNumber, deliveryZip,
deliveryCity, price }; deliveryCity };
// List of required date fields // List of required date fields
DatePicker[] requiredDateFields = { pickupDate, deliveryDate }; DatePicker[] requiredDateFields = { pickupDate, deliveryDate };
@@ -1078,8 +1269,8 @@ public class AddJobView extends Main {
private boolean hasAppointmentValidationErrors() { private boolean hasAppointmentValidationErrors() {
LocalDate today = LocalDate.now(); LocalDate today = LocalDate.now();
return pickupDate.getValue() == null || deliveryDate.getValue() == null return pickupDate.getValue() == null || deliveryDate.getValue() == null || pickupDate.getValue().isBefore(today)
|| pickupDate.getValue().isBefore(today) || deliveryDate.getValue().isBefore(today); || deliveryDate.getValue().isBefore(today);
} }
private boolean hasCargoValidationErrors() { private boolean hasCargoValidationErrors() {
@@ -1105,7 +1296,8 @@ public class AddJobView extends Main {
private boolean hasTasksValidationErrors() { private boolean hasTasksValidationErrors() {
for (BaseTask task : tasksState) { for (BaseTask task : tasksState) {
// Check if any ConfirmationTask has an empty description or buttonText (required fields) // Check if any ConfirmationTask has an empty description or buttonText
// (required fields)
if (task instanceof ConfirmationTask confirmationTask) { if (task instanceof ConfirmationTask confirmationTask) {
String description = task.getDescription(); String description = task.getDescription();
if (description == null || description.trim().isEmpty()) { if (description == null || description.trim().isEmpty()) {
@@ -1133,7 +1325,8 @@ public class AddJobView extends Main {
} }
private boolean hasPriceValidationErrors() { private boolean hasPriceValidationErrors() {
return isFieldEmpty(price); // Price tab is valid when at least one service is selected
return selectedServices == null || selectedServices.isEmpty();
} }
private boolean isFieldEmpty(TextField field) { private boolean isFieldEmpty(TextField field) {
@@ -1153,6 +1346,15 @@ public class AddJobView extends Main {
if (remarkArea != null) if (remarkArea != null)
job.setRemark(remarkArea.getValue()); job.setRemark(remarkArea.getValue());
// Calculate price from selected services
BigDecimal totalPrice = selectedServices.stream()
.map(s -> s.getEffectivePrice() != null ? s.getEffectivePrice() : BigDecimal.ZERO)
.reduce(BigDecimal.ZERO, BigDecimal::add);
job.setPrice(totalPrice);
// Store selected service IDs in job (optional - if Job has serviceIds field)
// job.setServiceIds(selectedServices.stream().map(Service::getId).toList());
// Validate all required fields using the binder // Validate all required fields using the binder
if (binder.writeBeanIfValid(job)) { if (binder.writeBeanIfValid(job)) {
// Additional validation: If digital processing is enabled, app user must be // Additional validation: If digital processing is enabled, app user must be
@@ -1248,6 +1450,31 @@ public class AddJobView extends Main {
// Zusammenfassungs-Helfer entfernt (Route übernimmt Darstellung) // Zusammenfassungs-Helfer entfernt (Route übernimmt Darstellung)
/**
* Lädt verpflichtende Leistungen aus dem Leistungskatalog
*/
private void loadMandatoryServices() {
try {
User currentUser = securityService.getCurrentDatabaseUser();
if (currentUser != null) {
List<Service> userServices = serviceRepository.findByUserId(currentUser.getId().toString());
List<Service> mandatoryServices = userServices.stream().filter(Service::isMandatory).toList();
if (!mandatoryServices.isEmpty()) {
selectedServices.addAll(mandatoryServices);
if (servicesGrid != null) {
servicesGrid.getDataProvider().refreshAll();
}
updatePriceSummary();
triggerValidation();
updateTabLabels();
}
}
} catch (Exception e) {
log.warn("Fehler beim Laden der verpflichtenden Leistungen: {}", e.getMessage());
}
}
/** /**
* Lädt einen bestehenden Entwurf, falls vorhanden * Lädt einen bestehenden Entwurf, falls vorhanden
*/ */
@@ -1591,8 +1818,12 @@ public class AddJobView extends Main {
digitalProcessing.setValue(true); digitalProcessing.setValue(true);
appUser.clear(); appUser.clear();
// Price field // Clear services
price.clear(); selectedServices.clear();
if (servicesGrid != null) {
servicesGrid.getDataProvider().refreshAll();
}
updatePriceSummary();
// Benutzer-Feedback // Benutzer-Feedback
Notification.show("Alle Felder wurden geleert", 2000, Notification.Position.BOTTOM_CENTER); Notification.show("Alle Felder wurden geleert", 2000, Notification.Position.BOTTOM_CENTER);
@@ -2435,4 +2666,374 @@ public class AddJobView extends Main {
return TaskType.CONFIRMATION; // fallback return TaskType.CONFIRMATION; // fallback
} }
// ============================================
// Adressvalidierung
// ============================================
/**
* Wird aufgerufen, wenn der Benutzer den Tab wechselt. Prüft, ob vom Tab
* "Auftraggeber & Adressen" gewechselt wird und ob die Adressen geändert
* wurden.
*/
private void onTabChange(com.vaadin.flow.component.tabs.TabSheet.SelectedChangeEvent event) {
com.vaadin.flow.component.tabs.Tab previousTab = event.getPreviousTab();
com.vaadin.flow.component.tabs.Tab selectedTab = event.getSelectedTab();
// Nur prüfen, wenn vom Adress-Tab weg gewechselt wird
if (previousTab != addressesTab) {
return;
}
// Prüfen, ob Adressen geändert wurden
boolean pickupChanged = hasPickupAddressChanged();
boolean deliveryChanged = hasDeliveryAddressChanged();
if (!pickupChanged && !deliveryChanged) {
// Adressen nicht geändert, nichts zu tun
return;
}
// Tab-Wechsel vorübergehend verhindern, indem wir zurück zum Adress-Tab
// wechseln
// Der Dialog wird angezeigt und bei Bestätigung wird der Tab gewechselt
event.unregisterListener();
tabSheet.setSelectedTab(addressesTab);
// Validierungsdialog anzeigen
showAddressValidationDialog(selectedTab);
}
/**
* Prüft, ob sich die Abholadresse geändert hat.
*/
private boolean hasPickupAddressChanged() {
String currentStreet = getValueOrEmpty(pickupStreet);
String currentHouseNumber = getValueOrEmpty(pickupHouseNumber);
String currentZip = getValueOrEmpty(pickupZip);
String currentCity = getValueOrEmpty(pickupCity);
boolean changed = !currentStreet.equals(lastPickupStreet) || !currentHouseNumber.equals(lastPickupHouseNumber)
|| !currentZip.equals(lastPickupZip) || !currentCity.equals(lastPickupCity);
return changed && !currentStreet.isEmpty() && !currentZip.isEmpty() && !currentCity.isEmpty();
}
/**
* Prüft, ob sich die Lieferadresse geändert hat.
*/
private boolean hasDeliveryAddressChanged() {
String currentStreet = getValueOrEmpty(deliveryStreet);
String currentHouseNumber = getValueOrEmpty(deliveryHouseNumber);
String currentZip = getValueOrEmpty(deliveryZip);
String currentCity = getValueOrEmpty(deliveryCity);
boolean changed = !currentStreet.equals(lastDeliveryStreet)
|| !currentHouseNumber.equals(lastDeliveryHouseNumber) || !currentZip.equals(lastDeliveryZip)
|| !currentCity.equals(lastDeliveryCity);
return changed && !currentStreet.isEmpty() && !currentZip.isEmpty() && !currentCity.isEmpty();
}
private String getValueOrEmpty(TextField field) {
return field.getValue() != null ? field.getValue().trim() : "";
}
/**
* Zeigt den Adressvalidierungsdialog an.
*/
private void showAddressValidationDialog(com.vaadin.flow.component.tabs.Tab targetTab) {
final Dialog dialog = new Dialog();
dialog.setHeaderTitle("Adressen werden überprüft");
dialog.setWidth("500px");
dialog.setModal(true);
dialog.setCloseOnOutsideClick(false);
dialog.setCloseOnEsc(false);
final VerticalLayout content = new VerticalLayout();
content.setPadding(true);
content.setSpacing(true);
// Status-Labels für die Validierung
final Span pickupStatusLabel = new Span("Abholadresse wird überprüft...");
final Span deliveryStatusLabel = new Span("Lieferadresse wird überprüft...");
content.add(pickupStatusLabel, deliveryStatusLabel);
// Layout für die Ergebnisanzeige
final VerticalLayout resultLayout = new VerticalLayout();
resultLayout.setVisible(false);
resultLayout.setPadding(false);
resultLayout.setSpacing(true);
final Span pickupResultLabel = new Span();
final Span deliveryResultLabel = new Span();
// Route-Label für die Anzeige der berechneten Strecke
final Span routeResultLabel = new Span();
routeResultLabel.getStyle().set("font-weight", "bold");
routeResultLabel.getStyle().set("margin-top", "var(--lumo-space-s)");
routeResultLabel.setVisible(false);
resultLayout.add(pickupResultLabel, deliveryResultLabel, routeResultLabel);
content.add(resultLayout);
// Button-Layout (initial versteckt)
final HorizontalLayout buttonLayout = new HorizontalLayout();
buttonLayout.setWidthFull();
buttonLayout.setJustifyContentMode(FlexComponent.JustifyContentMode.END);
buttonLayout.setVisible(false);
final Button cancelButton = new Button("Zurück", e -> {
dialog.close();
// Im Adress-Tab bleiben
});
cancelButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
final Button continueButton = new Button("Trotzdem wechseln", e -> {
dialog.close();
// Zum Ziel-Tab wechseln
tabSheet.setSelectedTab(targetTab);
// Gespeicherte Adressen aktualisieren
saveCurrentAddresses();
});
continueButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
buttonLayout.add(cancelButton, continueButton);
content.add(buttonLayout);
dialog.add(content);
dialog.open();
// Asynchrone Validierung durchführen
getUI().ifPresent(ui -> {
// UI-Zugriff für Validierung
ui.access(() -> {
// Abholadresse validieren
final AddressValidationResult[] pickupResultHolder = new AddressValidationResult[1];
if (hasPickupAddressChanged()) {
pickupResultHolder[0] = addressValidationService.validateAddress("pickup",
getValueOrEmpty(pickupStreet), getValueOrEmpty(pickupHouseNumber),
getValueOrEmpty(pickupZip), getValueOrEmpty(pickupCity));
addressValidationResults.put("pickup", pickupResultHolder[0]);
}
// Lieferadresse validieren
final AddressValidationResult[] deliveryResultHolder = new AddressValidationResult[1];
if (hasDeliveryAddressChanged()) {
deliveryResultHolder[0] = addressValidationService.validateAddress("delivery",
getValueOrEmpty(deliveryStreet), getValueOrEmpty(deliveryHouseNumber),
getValueOrEmpty(deliveryZip), getValueOrEmpty(deliveryCity));
addressValidationResults.put("delivery", deliveryResultHolder[0]);
}
// Route berechnen, wenn beide Adressen gültig sind
final RouteCalculationResult[] routeResultHolder = new RouteCalculationResult[1];
AddressValidationResult pickup = pickupResultHolder[0];
AddressValidationResult delivery = deliveryResultHolder[0];
if ((pickup != null && pickup.isValid()) && (delivery != null && delivery.isValid())) {
routeResultHolder[0] = addressValidationService.calculateRoute(pickup, delivery);
routeCalculationResult = routeResultHolder[0];
} else if (pickup == null) {
// Bereits validierte Abholadresse verwenden
AddressValidationResult existingPickup = addressValidationResults.get("pickup");
if (existingPickup != null && existingPickup.isValid() && delivery != null && delivery.isValid()) {
routeResultHolder[0] = addressValidationService.calculateRoute(existingPickup, delivery);
routeCalculationResult = routeResultHolder[0];
}
} else if (delivery == null) {
// Bereits validierte Lieferadresse verwenden
AddressValidationResult existingDelivery = addressValidationResults.get("delivery");
if (existingDelivery != null && existingDelivery.isValid() && pickup != null && pickup.isValid()) {
routeResultHolder[0] = addressValidationService.calculateRoute(pickup, existingDelivery);
routeCalculationResult = routeResultHolder[0];
}
}
// UI aktualisieren
updateValidationDialogUI(pickup, delivery, pickupStatusLabel, deliveryStatusLabel, pickupResultLabel,
deliveryResultLabel, routeResultLabel, resultLayout, buttonLayout, continueButton, targetTab);
});
});
}
/**
* Aktualisiert die UI des Validierungsdialogs mit den Ergebnissen.
*/
private void updateValidationDialogUI(AddressValidationResult pickupResult, AddressValidationResult deliveryResult,
Span pickupStatusLabel, Span deliveryStatusLabel, Span pickupResultLabel, Span deliveryResultLabel,
Span routeResultLabel, VerticalLayout resultLayout, HorizontalLayout buttonLayout, Button continueButton,
com.vaadin.flow.component.tabs.Tab targetTab) {
boolean hasInvalidAddress = false;
boolean bothAddressesValid = true;
// Abholadresse anzeigen
if (pickupResult != null) {
if (pickupResult.isValid()) {
pickupResultLabel.setText("✓ Abholadresse: " + pickupResult.getFormattedAddress());
pickupResultLabel.getStyle().set("color", "var(--lumo-success-text-color)");
} else {
pickupResultLabel.setText("⚠ Abholadresse: " + pickupResult.getValidationMessage());
pickupResultLabel.getStyle().set("color", "var(--lumo-error-text-color)");
hasInvalidAddress = true;
bothAddressesValid = false;
}
} else {
pickupResultLabel.setVisible(false);
}
// Lieferadresse anzeigen
if (deliveryResult != null) {
if (deliveryResult.isValid()) {
deliveryResultLabel.setText("✓ Lieferadresse: " + deliveryResult.getFormattedAddress());
deliveryResultLabel.getStyle().set("color", "var(--lumo-success-text-color)");
} else {
deliveryResultLabel.setText("⚠ Lieferadresse: " + deliveryResult.getValidationMessage());
deliveryResultLabel.getStyle().set("color", "var(--lumo-error-text-color)");
hasInvalidAddress = true;
bothAddressesValid = false;
}
} else {
deliveryResultLabel.setVisible(false);
}
// Prüfen, ob beide Adressen insgesamt gültig sind (auch aus vorherigen
// Validierungen)
AddressValidationResult existingPickup = addressValidationResults.get("pickup");
AddressValidationResult existingDelivery = addressValidationResults.get("delivery");
if (pickupResult != null && !pickupResult.isValid()) {
bothAddressesValid = false;
} else if (pickupResult == null && (existingPickup == null || !existingPickup.isValid())) {
bothAddressesValid = false;
}
if (deliveryResult != null && !deliveryResult.isValid()) {
bothAddressesValid = false;
} else if (deliveryResult == null && (existingDelivery == null || !existingDelivery.isValid())) {
bothAddressesValid = false;
}
// Route anzeigen, wenn beide Adressen gültig sind
if (bothAddressesValid && routeCalculationResult != null && routeCalculationResult.isValid()) {
routeResultLabel.setText("🚛 Route: " + String.format("%.1f km", routeCalculationResult.getDistanceKm())
+ " (Fahrtzeit: " + routeCalculationResult.getFormattedDurationLong() + ")");
routeResultLabel.getStyle().set("color", "var(--lumo-primary-text-color)");
routeResultLabel.setVisible(true);
} else {
routeResultLabel.setVisible(false);
}
// Status-Labels ausblenden, Ergebnisse anzeigen
pickupStatusLabel.setVisible(false);
deliveryStatusLabel.setVisible(false);
resultLayout.setVisible(true);
// Farbliche Markierung der Adressfelder
updateAddressFieldStyles(pickupResult != null ? pickupResult : existingPickup,
deliveryResult != null ? deliveryResult : existingDelivery);
// Buttons anzeigen
buttonLayout.setVisible(true);
// Wenn beide Adressen gültig sind, direkt weiter
if (!hasInvalidAddress) {
continueButton.setText("Weiter");
} else {
continueButton.setText("Trotzdem wechseln");
}
}
/**
* Aktualisiert die Hintergrundfarbe der Adressfelder basierend auf dem
* Validierungsergebnis. Hellgrün für validierte Adressen, hellgelb für nicht
* validierte.
*/
private void updateAddressFieldStyles(AddressValidationResult pickupResult,
AddressValidationResult deliveryResult) {
// Abholadresse - hellgrün (#90EE90) für validiert, hellgelb (#FFFACD) für nicht
// validiert
String pickupColor = (pickupResult != null && pickupResult.isValid()) ? "rgba(144, 238, 144, 0.5)" // Hellgrün
// mit
// Transparenz
: "rgba(255, 250, 205, 0.5)"; // Hellgelb mit Transparenz
pickupStreet.getStyle().set("--vaadin-input-field-background", pickupColor);
pickupHouseNumber.getStyle().set("--vaadin-input-field-background", pickupColor);
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);
}
/**
* Speichert die aktuellen Adressen als "zuletzt geprüft".
*/
private void saveCurrentAddresses() {
lastPickupStreet = getValueOrEmpty(pickupStreet);
lastPickupHouseNumber = getValueOrEmpty(pickupHouseNumber);
lastPickupZip = getValueOrEmpty(pickupZip);
lastPickupCity = getValueOrEmpty(pickupCity);
lastDeliveryStreet = getValueOrEmpty(deliveryStreet);
lastDeliveryHouseNumber = getValueOrEmpty(deliveryHouseNumber);
lastDeliveryZip = getValueOrEmpty(deliveryZip);
lastDeliveryCity = getValueOrEmpty(deliveryCity);
}
/**
* Gibt das Validierungsergebnis für die Abholadresse zurück. Kann null sein,
* wenn noch keine Validierung durchgeführt wurde.
*/
public AddressValidationResult getPickupAddressValidationResult() {
return addressValidationResults.get("pickup");
}
/**
* Gibt das Validierungsergebnis für die Lieferadresse zurück. Kann null sein,
* wenn noch keine Validierung durchgeführt wurde.
*/
public AddressValidationResult getDeliveryAddressValidationResult() {
return addressValidationResults.get("delivery");
}
/**
* Gibt alle Validierungsergebnisse zurück.
*/
public Map<String, AddressValidationResult> getAddressValidationResults() {
return new HashMap<>(addressValidationResults);
}
/**
* Gibt das Ergebnis der Routenberechnung zurück. Enthält die Entfernung in
* Kilometern und die Fahrtzeit, wenn beide Adressen validiert wurden.
*
* @return RouteCalculationResult oder null, wenn keine Berechnung durchgeführt
* wurde
*/
public RouteCalculationResult getRouteCalculationResult() {
return routeCalculationResult;
}
/**
* Gibt die Entfernung zwischen Abhol- und Lieferadresse in Kilometern zurück.
*
* @return Entfernung in km oder 0.0, wenn keine Berechnung durchgeführt wurde
*/
public double getRouteDistanceKm() {
return routeCalculationResult != null && routeCalculationResult.isValid()
? routeCalculationResult.getDistanceKm()
: 0.0;
}
} }

View File

@@ -0,0 +1,456 @@
package de.assecutor.votianlt.pages.view;
import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.button.ButtonVariant;
import com.vaadin.flow.component.combobox.ComboBox;
import com.vaadin.flow.component.grid.Grid;
import com.vaadin.flow.component.html.Div;
import com.vaadin.flow.component.html.H2;
import com.vaadin.flow.component.html.H3;
import com.vaadin.flow.component.html.Span;
import com.vaadin.flow.component.notification.Notification;
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.component.textfield.IntegerField;
import com.vaadin.flow.router.PageTitle;
import com.vaadin.flow.router.Route;
import com.vaadin.flow.router.BeforeEvent;
import com.vaadin.flow.router.HasUrlParameter;
import de.assecutor.votianlt.model.Job;
import de.assecutor.votianlt.model.Service;
import de.assecutor.votianlt.repository.JobRepository;
import de.assecutor.votianlt.repository.ServiceRepository;
import de.assecutor.votianlt.security.SecurityService;
import jakarta.annotation.security.RolesAllowed;
import lombok.extern.slf4j.Slf4j;
import org.bson.types.ObjectId;
import org.springframework.beans.factory.annotation.Autowired;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.ArrayList;
import java.util.List;
@PageTitle("Rechnung erstellen")
@Route(value = "create_invoice", layout = de.assecutor.votianlt.pages.base.ui.view.MainLayout.class)
@RolesAllowed({ "USER" })
@Slf4j
public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter<String> {
private final JobRepository jobRepository;
private final ServiceRepository serviceRepository;
private final SecurityService securityService;
private Job currentJob;
private List<ServiceRow> gridRows = new ArrayList<>();
private List<Service> allUserServices;
private Grid<ServiceRow> servicesGrid;
private IntegerField kilometersField;
private IntegerField timeField;
private Div servicesSection;
/**
* Helper class to represent a row in the services grid
*/
public static class ServiceRow {
private Service service;
public ServiceRow() {
this.service = null;
}
public ServiceRow(Service service) {
this.service = service;
}
public Service getService() {
return service;
}
public void setService(Service service) {
this.service = service;
}
public boolean isEmpty() {
return service == null;
}
}
@Autowired
public CreateInvoiceView(JobRepository jobRepository, ServiceRepository serviceRepository,
SecurityService securityService) {
this.jobRepository = jobRepository;
this.serviceRepository = serviceRepository;
this.securityService = securityService;
setSizeFull();
setPadding(true);
setSpacing(true);
}
@Override
public void setParameter(BeforeEvent event, String jobIdHex) {
try {
ObjectId jobId = new ObjectId(jobIdHex);
loadJob(jobId);
} catch (Exception e) {
log.error("Fehler beim Parsen der Job-ID: " + jobIdHex, e);
add(new Span("Ungültige Auftrags-ID"));
}
}
public void loadJob(ObjectId jobId) {
currentJob = jobRepository.findById(jobId).orElse(null);
if (currentJob == null) {
add(new Span("Auftrag nicht gefunden"));
return;
}
createInvoiceView();
}
private void createInvoiceView() {
removeAll();
// Title
H2 title = new H2("Rechnung erstellen für Auftrag " + currentJob.getJobNumber());
add(title);
// Job Details Section
Div jobDetailsSection = createJobDetailsSection();
add(jobDetailsSection);
// Performance Data Section
Div performanceDataSection = createPerformanceDataSection();
add(performanceDataSection);
// Services Selection Section
Div servicesSection = createServicesSelectionSection();
add(servicesSection);
// Summary Section
Div summarySection = createSummarySection();
add(summarySection);
// Create Invoice Button
Button createInvoiceButton = new Button("Rechnung erstellen");
createInvoiceButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
createInvoiceButton.addClickListener(e -> createInvoice());
createInvoiceButton.getStyle().set("margin-bottom", "15px");
add(createInvoiceButton);
}
private Div createJobDetailsSection() {
Div section = new Div();
section.getStyle().set("border", "1px solid var(--lumo-contrast-20pct)")
.set("border-radius", "var(--lumo-border-radius-m)").set("padding", "var(--lumo-space-m)")
.set("margin-bottom", "var(--lumo-space-m)").set("width", "100%").set("box-sizing", "border-box");
H3 sectionTitle = new H3("Auftragsdetails");
section.add(sectionTitle);
// Job information
VerticalLayout jobInfo = new VerticalLayout();
jobInfo.setSpacing(true);
jobInfo.setWidthFull();
jobInfo.add(new HorizontalLayout(new Span("Auftragsnummer:"), new Span(currentJob.getJobNumber())));
jobInfo.add(new HorizontalLayout(new Span("Kunde:"),
new Span(extractCompanyName(currentJob.getCustomerSelection()))));
jobInfo.add(new HorizontalLayout(new Span("Status:"), new Span(currentJob.getStatus().toString())));
jobInfo.add(new HorizontalLayout(new Span("Preis:"), new Span(currentJob.getPrice() + "")));
section.add(jobInfo);
return section;
}
private Div createPerformanceDataSection() {
Div section = new Div();
section.getStyle().set("border", "1px solid var(--lumo-contrast-20pct)")
.set("border-radius", "var(--lumo-border-radius-m)").set("padding", "var(--lumo-space-m)")
.set("margin-bottom", "var(--lumo-space-m)").set("width", "100%").set("box-sizing", "border-box");
H3 sectionTitle = new H3("Leistungsdaten");
section.add(sectionTitle);
VerticalLayout performanceLayout = new VerticalLayout();
performanceLayout.setSpacing(true);
performanceLayout.setWidthFull();
// Kilometers field
HorizontalLayout kilometersLayout = new HorizontalLayout();
kilometersLayout.setWidthFull();
Span kilometersLabel = new Span("Gefahrene Kilometer:");
kilometersLabel.getStyle().set("width", "200px");
kilometersField = new IntegerField();
kilometersField.setWidth("150px");
kilometersField.setMin(0);
kilometersField.setValue(currentJob.getKilometersDriven() != null ? currentJob.getKilometersDriven() : 0);
kilometersField.addValueChangeListener(e -> updateSummarySection());
kilometersLayout.add(kilometersLabel, kilometersField);
performanceLayout.add(kilometersLayout);
// Time field (in 15-minute units)
HorizontalLayout timeLayout = new HorizontalLayout();
timeLayout.setWidthFull();
Span timeLabel = new Span("Arbeitszeit (15-Minuten-Einheiten):");
timeLabel.getStyle().set("width", "200px");
timeField = new IntegerField();
timeField.setWidth("150px");
timeField.setMin(0);
timeField.setValue(currentJob.getTimeIn15MinUnits() != null ? currentJob.getTimeIn15MinUnits() : 0);
timeField.addValueChangeListener(e -> updateSummarySection());
timeLayout.add(timeLabel, timeField);
performanceLayout.add(timeLayout);
section.add(performanceLayout);
return section;
}
private Div createServicesSelectionSection() {
servicesSection = new Div();
servicesSection.getStyle().set("border", "1px solid var(--lumo-contrast-20pct)")
.set("border-radius", "var(--lumo-border-radius-m)").set("padding", "var(--lumo-space-m)")
.set("margin-bottom", "var(--lumo-space-m)").set("width", "100%").set("box-sizing", "border-box");
H3 sectionTitle = new H3("Leistungen auswählen");
servicesSection.add(sectionTitle);
// Load services for current user (only once)
if (allUserServices == null) {
String currentUserId = securityService.getCurrentUserId().toHexString();
allUserServices = serviceRepository.findByUserId(currentUserId);
}
// Initialize with 2 empty rows if gridRows is empty
if (gridRows.isEmpty()) {
gridRows.add(new ServiceRow());
gridRows.add(new ServiceRow());
}
// Create grid with editable rows
servicesGrid = new Grid<>();
servicesGrid.setWidthFull();
servicesGrid.setAllRowsVisible(true);
// Service selection column (ComboBox)
servicesGrid.addComponentColumn(row -> {
ComboBox<Service> serviceCombo = new ComboBox<>();
serviceCombo.setItems(allUserServices);
serviceCombo.setItemLabelGenerator(Service::getName);
serviceCombo.setPlaceholder("Leistung auswählen...");
serviceCombo.setWidthFull();
serviceCombo.setValue(row.getService());
serviceCombo.addValueChangeListener(event -> {
row.setService(event.getValue());
// Refresh the grid to show updated calculation basis and price
servicesGrid.getDataProvider().refreshItem(row);
updateSummarySection();
});
return serviceCombo;
}).setHeader("Leistung").setAutoWidth(true).setFlexGrow(2);
// Calculation basis column
servicesGrid.addColumn(row -> {
if (row.getService() != null && row.getService().getCalculationBasis() != null) {
return switch (row.getService().getCalculationBasis()) {
case DISTANCE -> "Gefahrene Kilometer";
case TIME -> "Zeit";
case FLAT_RATE -> "Pauschal";
};
}
return "";
}).setHeader("Berechnungsgrundlage").setAutoWidth(true).setFlexGrow(1);
// Price column
servicesGrid.addColumn(row -> {
if (row.getService() != null) {
BigDecimal price = calculateServicePrice(row.getService());
if (price != null) {
return price.setScale(2, RoundingMode.HALF_UP) + "";
}
}
return "";
}).setHeader("Preis").setAutoWidth(true).setFlexGrow(1).setKey("price");
servicesGrid.setItems(gridRows);
servicesSection.add(servicesGrid);
// Add button to add new row
Button addButton = new Button("Leistung hinzufügen", e -> {
ServiceRow newRow = new ServiceRow();
gridRows.add(newRow);
servicesGrid.getDataProvider().refreshAll();
});
addButton.getStyle().set("margin-top", "var(--lumo-space-m)");
servicesSection.add(addButton);
return servicesSection;
}
private void refreshServicesGrid() {
if (servicesGrid != null) {
servicesGrid.getDataProvider().refreshAll();
}
}
private List<Service> getSelectedServices() {
return gridRows.stream().filter(row -> row.getService() != null).map(ServiceRow::getService).toList();
}
private Div createSummarySection() {
Div section = new Div();
section.getStyle().set("border", "1px solid var(--lumo-contrast-20pct)")
.set("border-radius", "var(--lumo-border-radius-m)").set("padding", "var(--lumo-space-m)")
.set("margin-bottom", "var(--lumo-space-m)").set("width", "100%").set("box-sizing", "border-box");
H3 sectionTitle = new H3("Zusammenfassung");
section.add(sectionTitle);
// Calculate totals
BigDecimal netAmount = calculateNetAmount();
BigDecimal vatRate = calculateAverageVatRate();
BigDecimal vatAmount = netAmount.multiply(vatRate);
BigDecimal totalAmount = netAmount.add(vatAmount);
VerticalLayout summaryInfo = new VerticalLayout();
summaryInfo.setSpacing(true);
summaryInfo.setWidthFull();
// Show only net sum, VAT sums, and total amount without individual services
summaryInfo.add(new HorizontalLayout(new Span("Nettosumme:"),
new Span(netAmount.setScale(2, RoundingMode.HALF_UP) + "")));
summaryInfo
.add(new HorizontalLayout(
new Span("Mehrwertsteuer ("
+ vatRate.multiply(new BigDecimal("100")).setScale(0, RoundingMode.HALF_UP) + "%):"),
new Span(vatAmount.setScale(2, RoundingMode.HALF_UP) + "")));
summaryInfo.add(new HorizontalLayout(new Span("Gesamtbetrag (brutto):"),
new Span(totalAmount.setScale(2, RoundingMode.HALF_UP) + "")));
section.add(summaryInfo);
return section;
}
private BigDecimal calculateServicePrice(Service service) {
if (service.getCalculationBasis() == null) {
return null;
}
if (service.getCalculationBasis() == Service.CalculationBasis.FLAT_RATE && service.getPrice() != null) {
return service.getPrice();
} else if (service.getCalculationBasis() == Service.CalculationBasis.DISTANCE
&& service.getPricePerKilometer() != null && kilometersField != null
&& kilometersField.getValue() != null) {
BigDecimal kilometers = new BigDecimal(kilometersField.getValue());
return service.getPricePerKilometer().multiply(kilometers);
} else if (service.getCalculationBasis() == Service.CalculationBasis.TIME
&& service.getPricePer15Minutes() != null && timeField != null && timeField.getValue() != null) {
BigDecimal timeUnits = new BigDecimal(timeField.getValue());
return service.getPricePer15Minutes().multiply(timeUnits);
}
return null;
}
private BigDecimal calculateNetAmount() {
BigDecimal total = BigDecimal.ZERO;
for (Service service : getSelectedServices()) {
if (service.getCalculationBasis() == Service.CalculationBasis.FLAT_RATE && service.getPrice() != null) {
total = total.add(service.getPrice());
} else if (service.getCalculationBasis() == Service.CalculationBasis.DISTANCE
&& service.getPricePerKilometer() != null && kilometersField != null
&& kilometersField.getValue() != null) {
BigDecimal kilometers = new BigDecimal(kilometersField.getValue());
BigDecimal serviceTotal = service.getPricePerKilometer().multiply(kilometers);
total = total.add(serviceTotal);
} else if (service.getCalculationBasis() == Service.CalculationBasis.TIME
&& service.getPricePer15Minutes() != null && timeField != null && timeField.getValue() != null) {
BigDecimal timeUnits = new BigDecimal(timeField.getValue());
BigDecimal serviceTotal = service.getPricePer15Minutes().multiply(timeUnits);
total = total.add(serviceTotal);
}
}
return total;
}
private BigDecimal calculateAverageVatRate() {
List<Service> selectedServicesList = getSelectedServices();
if (selectedServicesList.isEmpty()) {
return new BigDecimal("0.19"); // Default 19% VAT
}
BigDecimal totalVat = BigDecimal.ZERO;
int count = 0;
for (Service service : selectedServicesList) {
if (service.getVatRate() != null) {
totalVat = totalVat.add(service.getVatRate());
count++;
}
}
if (count > 0) {
return totalVat.divide(new BigDecimal(count), 4, RoundingMode.HALF_UP);
}
return new BigDecimal("0.19"); // Default 19% VAT
}
private void updateSummarySection() {
// Update the job with new values
if (kilometersField != null && kilometersField.getValue() != null) {
currentJob.setKilometersDriven(kilometersField.getValue());
}
if (timeField != null && timeField.getValue() != null) {
currentJob.setTimeIn15MinUnits(timeField.getValue());
}
// Refresh the services grid to update calculated prices
refreshServicesGrid();
// Recreate the summary section to update the values
int summarySectionIndex = getComponentCount() - 2; // Summary section is second to last
Div newSummarySection = createSummarySection();
remove(getComponentAt(summarySectionIndex)); // Remove old summary section
addComponentAtIndex(summarySectionIndex, newSummarySection); // Add new summary section
}
private String extractCompanyName(String customerSelection) {
if (customerSelection == null || customerSelection.isBlank()) {
return "";
}
// Format: "Firmenname | Vorname Nachname" - extrahiere nur den Firmennamen
int separatorIndex = customerSelection.indexOf(" | ");
if (separatorIndex > 0) {
return customerSelection.substring(0, separatorIndex).trim();
}
return customerSelection.trim();
}
private void createInvoice() {
if (getSelectedServices().isEmpty()) {
Notification.show("Bitte wählen Sie mindestens eine Leistung aus", 3000, Notification.Position.BOTTOM_END);
return;
}
// Save the updated job with kilometers and time
jobRepository.save(currentJob);
// Calculate totals for the invoice
BigDecimal netAmount = calculateNetAmount();
BigDecimal vatRate = calculateAverageVatRate();
BigDecimal vatAmount = netAmount.multiply(vatRate);
BigDecimal totalAmount = netAmount.add(vatAmount);
String message = String.format("Rechnung erstellt! Nettosumme: %s €, MwSt: %s €, Gesamt: %s €",
netAmount.setScale(2, RoundingMode.HALF_UP), vatAmount.setScale(2, RoundingMode.HALF_UP),
totalAmount.setScale(2, RoundingMode.HALF_UP));
Notification.show(message, 5000, Notification.Position.BOTTOM_END);
}
}

View File

@@ -49,10 +49,7 @@ public class InvoiceGeneratorView extends VerticalLayout {
setMargin(false); setMargin(false);
setWidth("100%"); setWidth("100%");
setHeight("100%"); setHeight("100%");
getStyle() getStyle().set("overflow", "hidden").set("box-sizing", "border-box").set("display", "flex")
.set("overflow", "hidden")
.set("box-sizing", "border-box")
.set("display", "flex")
.set("flex-direction", "column"); .set("flex-direction", "column");
// Hauptlayout: Links (Templates) | Mitte (Canvas) | Rechts (Eigenschaften) // Hauptlayout: Links (Templates) | Mitte (Canvas) | Rechts (Eigenschaften)
@@ -66,27 +63,19 @@ public class InvoiceGeneratorView extends VerticalLayout {
VerticalLayout leftPanel = createTemplatesPanel(); VerticalLayout leftPanel = createTemplatesPanel();
leftPanel.setWidth("250px"); leftPanel.setWidth("250px");
leftPanel.setHeightFull(); leftPanel.setHeightFull();
leftPanel.getStyle() leftPanel.getStyle().set("flex-shrink", "0").set("min-width", "250px").set("overflow", "auto");
.set("flex-shrink", "0")
.set("min-width", "250px")
.set("overflow", "auto");
// Mitte: Canvas mit Konva.js // Mitte: Canvas mit Konva.js
VerticalLayout centerPanel = createCanvasPanel(); VerticalLayout centerPanel = createCanvasPanel();
centerPanel.setWidth("60%"); centerPanel.setWidth("60%");
centerPanel.setHeightFull(); centerPanel.setHeightFull();
centerPanel.getStyle() centerPanel.getStyle().set("flex-grow", "1").set("min-width", "0");
.set("flex-grow", "1")
.set("min-width", "0");
// Rechte Seite: Eigenschaften // Rechte Seite: Eigenschaften
propertiesPanel = createPropertiesPanel(); propertiesPanel = createPropertiesPanel();
propertiesPanel.setWidth("300px"); propertiesPanel.setWidth("300px");
propertiesPanel.setHeightFull(); propertiesPanel.setHeightFull();
propertiesPanel.getStyle() propertiesPanel.getStyle().set("flex-shrink", "0").set("min-width", "300px").set("overflow", "auto");
.set("flex-shrink", "0")
.set("min-width", "300px")
.set("overflow", "auto");
mainLayout.add(leftPanel, centerPanel, propertiesPanel); mainLayout.add(leftPanel, centerPanel, propertiesPanel);
mainLayout.expand(centerPanel); mainLayout.expand(centerPanel);
@@ -96,9 +85,7 @@ public class InvoiceGeneratorView extends VerticalLayout {
// Aktions-Buttons unter dem Canvas (fixe Höhe) // Aktions-Buttons unter dem Canvas (fixe Höhe)
HorizontalLayout actionLayout = createActionButtons(); HorizontalLayout actionLayout = createActionButtons();
actionLayout.setHeight("60px"); actionLayout.setHeight("60px");
actionLayout.getStyle() actionLayout.getStyle().set("flex-shrink", "0").set("padding", "0 var(--lumo-space-m)");
.set("flex-shrink", "0")
.set("padding", "0 var(--lumo-space-m)");
add(actionLayout); add(actionLayout);
} }
@@ -106,14 +93,9 @@ public class InvoiceGeneratorView extends VerticalLayout {
protected void onAttach(AttachEvent attachEvent) { protected void onAttach(AttachEvent attachEvent) {
super.onAttach(attachEvent); super.onAttach(attachEvent);
// Register this view instance and initialize the canvas // Register this view instance and initialize the canvas
getElement().executeJs( getElement().executeJs("window.invoiceGeneratorView = this;" + "if (window.invoiceGenerator) {"
"window.invoiceGeneratorView = this;" + + " console.log('Initializing invoice generator...');" + " window.invoiceGenerator.init();"
"if (window.invoiceGenerator) {" + + "} else {" + " console.error('Invoice generator not found');" + "}");
" console.log('Initializing invoice generator...');" +
" window.invoiceGenerator.init();" +
"} else {" +
" console.error('Invoice generator not found');" +
"}");
} }
private VerticalLayout createTemplatesPanel() { private VerticalLayout createTemplatesPanel() {
@@ -121,15 +103,11 @@ public class InvoiceGeneratorView extends VerticalLayout {
panel.setPadding(true); panel.setPadding(true);
panel.setSpacing(true); panel.setSpacing(true);
panel.setHeightFull(); panel.setHeightFull();
panel.getStyle() panel.getStyle().set("background-color", "var(--lumo-contrast-5pct)")
.set("background-color", "var(--lumo-contrast-5pct)") .set("border-radius", "var(--lumo-border-radius-m)").set("overflow", "auto");
.set("border-radius", "var(--lumo-border-radius-m)")
.set("overflow", "auto");
Span header = new Span("Textbausteine"); Span header = new Span("Textbausteine");
header.getStyle() header.getStyle().set("font-weight", "bold").set("font-size", "var(--lumo-font-size-l)");
.set("font-weight", "bold")
.set("font-size", "var(--lumo-font-size-l)");
// Draggable Templates // Draggable Templates
Div textBlock = createDraggableTemplate("Textfeld", VaadinIcon.TEXT_LABEL, "text"); Div textBlock = createDraggableTemplate("Textfeld", VaadinIcon.TEXT_LABEL, "text");
@@ -150,17 +128,10 @@ public class InvoiceGeneratorView extends VerticalLayout {
private Div createDraggableTemplate(String label, VaadinIcon icon, String type) { private Div createDraggableTemplate(String label, VaadinIcon icon, String type) {
Div template = new Div(); Div template = new Div();
template.setText(label); template.setText(label);
template.getStyle() template.getStyle().set("padding", "var(--lumo-space-m)").set("margin", "var(--lumo-space-xs) 0")
.set("padding", "var(--lumo-space-m)") .set("background-color", "var(--lumo-base-color)").set("border", "1px solid var(--lumo-contrast-20pct)")
.set("margin", "var(--lumo-space-xs) 0") .set("border-radius", "var(--lumo-border-radius-m)").set("cursor", "grab").set("display", "flex")
.set("background-color", "var(--lumo-base-color)") .set("align-items", "center").set("gap", "var(--lumo-space-s)").set("user-select", "none");
.set("border", "1px solid var(--lumo-contrast-20pct)")
.set("border-radius", "var(--lumo-border-radius-m)")
.set("cursor", "grab")
.set("display", "flex")
.set("align-items", "center")
.set("gap", "var(--lumo-space-s)")
.set("user-select", "none");
// Icon hinzufügen // Icon hinzufügen
Icon templateIcon = icon.create(); Icon templateIcon = icon.create();
@@ -173,15 +144,12 @@ public class InvoiceGeneratorView extends VerticalLayout {
template.getElement().setAttribute("data-template-label", label); template.getElement().setAttribute("data-template-label", label);
// JavaScript Event Listener für Drag Start // JavaScript Event Listener für Drag Start
template.getElement().executeJs( template.getElement()
"this.addEventListener('dragstart', function(e) {" + .executeJs("this.addEventListener('dragstart', function(e) {"
" e.dataTransfer.setData('template-type', this.getAttribute('data-template-type'));" + + " e.dataTransfer.setData('template-type', this.getAttribute('data-template-type'));"
" e.dataTransfer.setData('template-label', this.getAttribute('data-template-label'));" + + " e.dataTransfer.setData('template-label', this.getAttribute('data-template-label'));"
" this.style.opacity = '0.5';" + + " this.style.opacity = '0.5';" + "});" + "this.addEventListener('dragend', function(e) {"
"});" + + " this.style.opacity = '1';" + "});");
"this.addEventListener('dragend', function(e) {" +
" this.style.opacity = '1';" +
"});");
return template; return template;
} }
@@ -197,37 +165,26 @@ public class InvoiceGeneratorView extends VerticalLayout {
canvasContainer.setId("invoice-canvas-container"); canvasContainer.setId("invoice-canvas-container");
canvasContainer.setWidth("100%"); canvasContainer.setWidth("100%");
canvasContainer.setHeight("100%"); canvasContainer.setHeight("100%");
canvasContainer.getStyle() canvasContainer.getStyle().set("background-color", "#e8e8e8")
.set("background-color", "#e8e8e8")
.set("border", "2px dashed var(--lumo-contrast-30pct)") .set("border", "2px dashed var(--lumo-contrast-30pct)")
.set("border-radius", "var(--lumo-border-radius-m)") .set("border-radius", "var(--lumo-border-radius-m)").set("position", "relative")
.set("position", "relative") .set("overflow", "hidden").set("cursor", "default");
.set("overflow", "hidden")
.set("cursor", "default");
// Drop Zone Event Listener // Drop Zone Event Listener
canvasContainer.getElement().executeJs( canvasContainer.getElement()
"var container = this;" + .executeJs("var container = this;" + "container.addEventListener('dragover', function(e) {"
"container.addEventListener('dragover', function(e) {" + + " e.preventDefault();" + " e.dataTransfer.dropEffect = 'copy';"
" e.preventDefault();" + + " container.style.borderColor = 'var(--lumo-primary-color)';" + "});"
" e.dataTransfer.dropEffect = 'copy';" + + "container.addEventListener('dragleave', function(e) {"
" container.style.borderColor = 'var(--lumo-primary-color)';" + + " container.style.borderColor = 'var(--lumo-contrast-30pct)';" + "});"
"});" + + "container.addEventListener('drop', function(e) {" + " e.preventDefault();"
"container.addEventListener('dragleave', function(e) {" + + " container.style.borderColor = 'var(--lumo-contrast-30pct)';"
" container.style.borderColor = 'var(--lumo-contrast-30pct)';" + + " var templateType = e.dataTransfer.getData('template-type');"
"});" + + " var templateLabel = e.dataTransfer.getData('template-label');"
"container.addEventListener('drop', function(e) {" + + " if (templateType && window.invoiceGenerator) {"
" e.preventDefault();" + + " var rect = container.getBoundingClientRect();" + " var x = e.clientX - rect.left;"
" container.style.borderColor = 'var(--lumo-contrast-30pct)';" + + " var y = e.clientY - rect.top;"
" var templateType = e.dataTransfer.getData('template-type');" + + " window.invoiceGenerator.addElement(templateType, templateLabel, x, y);" + " }" + "});");
" var templateLabel = e.dataTransfer.getData('template-label');" +
" if (templateType && window.invoiceGenerator) {" +
" var rect = container.getBoundingClientRect();" +
" var x = e.clientX - rect.left;" +
" var y = e.clientY - rect.top;" +
" window.invoiceGenerator.addElement(templateType, templateLabel, x, y);" +
" }" +
"});");
panel.add(canvasContainer); panel.add(canvasContainer);
panel.expand(canvasContainer); panel.expand(canvasContainer);
@@ -240,22 +197,17 @@ public class InvoiceGeneratorView extends VerticalLayout {
panel.setPadding(true); panel.setPadding(true);
panel.setSpacing(true); panel.setSpacing(true);
panel.setHeightFull(); panel.setHeightFull();
panel.getStyle() panel.getStyle().set("background-color", "var(--lumo-contrast-5pct)")
.set("background-color", "var(--lumo-contrast-5pct)") .set("border-radius", "var(--lumo-border-radius-m)").set("overflow", "auto");
.set("border-radius", "var(--lumo-border-radius-m)")
.set("overflow", "auto");
Span header = new Span("Eigenschaften"); Span header = new Span("Eigenschaften");
header.getStyle() header.getStyle().set("font-weight", "bold").set("font-size", "var(--lumo-font-size-l)");
.set("font-weight", "bold")
.set("font-size", "var(--lumo-font-size-l)");
// Info-Text wenn kein Element ausgewählt // Info-Text wenn kein Element ausgewählt
selectedElementInfo = new Div(); selectedElementInfo = new Div();
selectedElementInfo.setText("Klicken Sie auf ein Element im Canvas, um dessen Eigenschaften zu bearbeiten."); selectedElementInfo.setText("Klicken Sie auf ein Element im Canvas, um dessen Eigenschaften zu bearbeiten.");
selectedElementInfo.getStyle() selectedElementInfo.getStyle().set("color", "var(--lumo-secondary-text-color)").set("font-size",
.set("color", "var(--lumo-secondary-text-color)") "var(--lumo-font-size-s)");
.set("font-size", "var(--lumo-font-size-s)");
panel.add(header, selectedElementInfo); panel.add(header, selectedElementInfo);
@@ -362,10 +314,7 @@ public class InvoiceGeneratorView extends VerticalLayout {
Div pdfContainer = new Div(); Div pdfContainer = new Div();
pdfContainer.setWidth("100%"); pdfContainer.setWidth("100%");
pdfContainer.setHeight("100%"); pdfContainer.setHeight("100%");
pdfContainer.getStyle() pdfContainer.getStyle().set("display", "flex").set("flex-direction", "column").set("overflow", "hidden");
.set("display", "flex")
.set("flex-direction", "column")
.set("overflow", "hidden");
// Use an iframe with data URL for PDF display // Use an iframe with data URL for PDF display
String base64Pdf = java.util.Base64.getEncoder().encodeToString(pdfBytes); String base64Pdf = java.util.Base64.getEncoder().encodeToString(pdfBytes);
@@ -375,9 +324,7 @@ public class InvoiceGeneratorView extends VerticalLayout {
pdfFrame.setWidth("100%"); pdfFrame.setWidth("100%");
pdfFrame.setHeight("100%"); pdfFrame.setHeight("100%");
pdfFrame.getElement().setAttribute("src", dataUrl); pdfFrame.getElement().setAttribute("src", dataUrl);
pdfFrame.getStyle() pdfFrame.getStyle().set("border", "none").set("flex-grow", "1");
.set("border", "none")
.set("flex-grow", "1");
pdfContainer.add(pdfFrame); pdfContainer.add(pdfFrame);
@@ -387,11 +334,9 @@ public class InvoiceGeneratorView extends VerticalLayout {
// Download button // Download button
Button downloadButton = new Button("Herunterladen", e -> { Button downloadButton = new Button("Herunterladen", e -> {
getElement().executeJs( getElement()
"const link = document.createElement('a');" + .executeJs("const link = document.createElement('a');" + "link.href = 'data:application/pdf;base64,"
"link.href = 'data:application/pdf;base64," + base64Pdf + "';" + + base64Pdf + "';" + "link.download = 'vorschau.pdf';" + "link.click();");
"link.download = 'vorschau.pdf';" +
"link.click();");
}); });
downloadButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY); downloadButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
@@ -412,9 +357,7 @@ public class InvoiceGeneratorView extends VerticalLayout {
propertiesPanel.removeAll(); propertiesPanel.removeAll();
Span header = new Span("Eigenschaften"); Span header = new Span("Eigenschaften");
header.getStyle() header.getStyle().set("font-weight", "bold").set("font-size", "var(--lumo-font-size-l)");
.set("font-weight", "bold")
.set("font-size", "var(--lumo-font-size-l)");
// Element Typ Anzeige // Element Typ Anzeige
Span typeLabel = new Span("Typ: " + elementType); Span typeLabel = new Span("Typ: " + elementType);
@@ -440,10 +383,9 @@ public class InvoiceGeneratorView extends VerticalLayout {
String dataUrl = "data:" + mimeType + ";base64," + base64; String dataUrl = "data:" + mimeType + ";base64," + base64;
// An JavaScript übergeben // An JavaScript übergeben
getElement().executeJs( getElement()
"if (window.invoiceGenerator) { window.invoiceGenerator.updateElementImage('" .executeJs("if (window.invoiceGenerator) { window.invoiceGenerator.updateElementImage('"
+ elementId + "', $0); }", + elementId + "', $0); }", dataUrl);
dataUrl);
showNotification("Bild erfolgreich hochgeladen"); showNotification("Bild erfolgreich hochgeladen");
} catch (Exception ex) { } catch (Exception ex) {
showNotification("Fehler beim Hochladen: " + ex.getMessage()); showNotification("Fehler beim Hochladen: " + ex.getMessage());
@@ -463,10 +405,8 @@ public class InvoiceGeneratorView extends VerticalLayout {
textField.setValue(text != null ? text : ""); textField.setValue(text != null ? text : "");
textField.setWidthFull(); textField.setWidthFull();
textField.addValueChangeListener(e -> { textField.addValueChangeListener(e -> {
getElement().executeJs( getElement().executeJs("if (window.invoiceGenerator) { window.invoiceGenerator.updateElementText('"
"if (window.invoiceGenerator) { window.invoiceGenerator.updateElementText('" + elementId + elementId + "', $0); }", e.getValue());
+ "', $0); }",
e.getValue());
}); });
propertiesPanel.add(textField); propertiesPanel.add(textField);
} }
@@ -478,10 +418,9 @@ public class InvoiceGeneratorView extends VerticalLayout {
xField.addValueChangeListener(e -> { xField.addValueChangeListener(e -> {
try { try {
double newX = Double.parseDouble(e.getValue()); double newX = Double.parseDouble(e.getValue());
getElement().executeJs( getElement()
"if (window.invoiceGenerator) { window.invoiceGenerator.updateElementPosition('" + elementId .executeJs("if (window.invoiceGenerator) { window.invoiceGenerator.updateElementPosition('"
+ "', $0, null); }", + elementId + "', $0, null); }", newX);
newX);
} catch (NumberFormatException ignored) { } catch (NumberFormatException ignored) {
} }
}); });
@@ -494,10 +433,9 @@ public class InvoiceGeneratorView extends VerticalLayout {
yField.addValueChangeListener(e -> { yField.addValueChangeListener(e -> {
try { try {
double newY = Double.parseDouble(e.getValue()); double newY = Double.parseDouble(e.getValue());
getElement().executeJs( getElement()
"if (window.invoiceGenerator) { window.invoiceGenerator.updateElementPosition('" + elementId .executeJs("if (window.invoiceGenerator) { window.invoiceGenerator.updateElementPosition('"
+ "', null, $0); }", + elementId + "', null, $0); }", newY);
newY);
} catch (NumberFormatException ignored) { } catch (NumberFormatException ignored) {
} }
}); });
@@ -536,17 +474,12 @@ public class InvoiceGeneratorView extends VerticalLayout {
// Farbvorschau-Box // Farbvorschau-Box
Div colorPreview = new Div(); Div colorPreview = new Div();
colorPreview.getStyle() colorPreview.getStyle().set("width", "40px").set("height", "30px").set("background-color", currentColor)
.set("width", "40px")
.set("height", "30px")
.set("background-color", currentColor)
.set("border", "1px solid var(--lumo-contrast-30pct)") .set("border", "1px solid var(--lumo-contrast-30pct)")
.set("border-radius", "var(--lumo-border-radius-m)"); .set("border-radius", "var(--lumo-border-radius-m)");
Span colorHexLabel = new Span(currentColor); Span colorHexLabel = new Span(currentColor);
colorHexLabel.getStyle() colorHexLabel.getStyle().set("font-family", "monospace").set("font-size", "var(--lumo-font-size-s)");
.set("font-family", "monospace")
.set("font-size", "var(--lumo-font-size-s)");
colorPreviewLayout.add(colorPreview, colorHexLabel); colorPreviewLayout.add(colorPreview, colorHexLabel);
@@ -562,10 +495,7 @@ public class InvoiceGeneratorView extends VerticalLayout {
Input dialogColorPicker = new Input(); Input dialogColorPicker = new Input();
dialogColorPicker.setType("color"); dialogColorPicker.setType("color");
dialogColorPicker.setValue(currentColor); dialogColorPicker.setValue(currentColor);
dialogColorPicker.getStyle() dialogColorPicker.getStyle().set("width", "100%").set("height", "50px").set("padding", "0");
.set("width", "100%")
.set("height", "50px")
.set("padding", "0");
// Hex-Eingabe im Dialog // Hex-Eingabe im Dialog
TextField dialogHexField = new TextField("Hex-Farbwert"); TextField dialogHexField = new TextField("Hex-Farbwert");
@@ -602,10 +532,8 @@ public class InvoiceGeneratorView extends VerticalLayout {
colorPreview.getStyle().set("background-color", newColor); colorPreview.getStyle().set("background-color", newColor);
colorHexLabel.setText(newColor); colorHexLabel.setText(newColor);
// Apply to element // Apply to element
getElement().executeJs( getElement().executeJs("if (window.invoiceGenerator) { window.invoiceGenerator.updateElementColor('"
"if (window.invoiceGenerator) { window.invoiceGenerator.updateElementColor('" + elementId + "', $0); }", newColor);
+ elementId + "', $0); }",
newColor);
colorDialog.close(); colorDialog.close();
showNotification("Farbe übernommen"); showNotification("Farbe übernommen");
}); });
@@ -635,8 +563,7 @@ public class InvoiceGeneratorView extends VerticalLayout {
deleteButton.setWidthFull(); deleteButton.setWidthFull();
deleteButton.addClickListener(e -> { deleteButton.addClickListener(e -> {
getElement().executeJs( getElement().executeJs(
"if (window.invoiceGenerator) { window.invoiceGenerator.deleteElement('" + elementId "if (window.invoiceGenerator) { window.invoiceGenerator.deleteElement('" + elementId + "'); }");
+ "'); }");
resetPropertiesPanel(); resetPropertiesPanel();
}); });
propertiesPanel.add(deleteButton); propertiesPanel.add(deleteButton);
@@ -652,15 +579,13 @@ public class InvoiceGeneratorView extends VerticalLayout {
propertiesPanel.removeAll(); propertiesPanel.removeAll();
Span header = new Span("Eigenschaften"); Span header = new Span("Eigenschaften");
header.getStyle() header.getStyle().set("font-weight", "bold").set("font-size", "var(--lumo-font-size-l)");
.set("font-weight", "bold")
.set("font-size", "var(--lumo-font-size-l)");
selectedElementInfo = new Div(); selectedElementInfo = new Div();
selectedElementInfo.setText("Klicken Sie auf ein Element im Canvas, um dessen Eigenschaften zu bearbeiten."); selectedElementInfo
selectedElementInfo.getStyle() .setText("Klicken Sie auf ein Element im Canvas, um dessen Eigenschaften zu bearbeiten.");
.set("color", "var(--lumo-secondary-text-color)") selectedElementInfo.getStyle().set("color", "var(--lumo-secondary-text-color)").set("font-size",
.set("font-size", "var(--lumo-font-size-s)"); "var(--lumo-font-size-s)");
propertiesPanel.add(header, selectedElementInfo); propertiesPanel.add(header, selectedElementInfo);
})); }));

View File

@@ -430,11 +430,8 @@ public class JobSummaryView extends Main implements HasUrlParameter<String> {
// Prüfe ob App-Tracking aktiviert ist und Job nicht erledigt/storniert // Prüfe ob App-Tracking aktiviert ist und Job nicht erledigt/storniert
LocationPosition appUserPosition = null; LocationPosition appUserPosition = null;
boolean showAppUserPosition = job.isDigitalProcessing() boolean showAppUserPosition = job.isDigitalProcessing() && job.getStatus() != JobStatus.COMPLETED
&& job.getStatus() != JobStatus.COMPLETED && job.getStatus() != JobStatus.CANCELLED && job.getAppUser() != null && !job.getAppUser().isBlank();
&& job.getStatus() != JobStatus.CANCELLED
&& job.getAppUser() != null
&& !job.getAppUser().isBlank();
if (showAppUserPosition) { if (showAppUserPosition) {
appUserPosition = locationService.getLatestPosition(job.getAppUser()); appUserPosition = locationService.getLatestPosition(job.getAppUser());
@@ -457,7 +454,8 @@ public class JobSummaryView extends Main implements HasUrlParameter<String> {
// Position für JavaScript vorbereiten // Position für JavaScript vorbereiten
final LocationPosition position = appUserPosition; final LocationPosition position = appUserPosition;
final boolean hasPosition = position != null && position.getLatitude() != null && position.getLongitude() != null; final boolean hasPosition = position != null && position.getLatitude() != null
&& position.getLongitude() != null;
final String appUserId = showAppUserPosition ? job.getAppUser() : ""; final String appUserId = showAppUserPosition ? job.getAppUser() : "";
final boolean shouldUpdate = showAppUserPosition; final boolean shouldUpdate = showAppUserPosition;
@@ -466,7 +464,8 @@ public class JobSummaryView extends Main implements HasUrlParameter<String> {
map.getElement().executeJs(js, map.getElement(), routeInfo.getElement()); map.getElement().executeJs(js, map.getElement(), routeInfo.getElement());
} }
private String buildMapJs(String origin, String destination, boolean hasPosition, LocationPosition position, String appUserId, boolean shouldUpdate) { private String buildMapJs(String origin, String destination, boolean hasPosition, LocationPosition position,
String appUserId, boolean shouldUpdate) {
String apiKey = getGoogleMapsApiKey(); String apiKey = getGoogleMapsApiKey();
// Explizit mit Punkt als Dezimaltrennzeichen formatieren // Explizit mit Punkt als Dezimaltrennzeichen formatieren
String lat = hasPosition ? String.format(java.util.Locale.US, "%.6f", position.getLatitude()) : "0"; String lat = hasPosition ? String.format(java.util.Locale.US, "%.6f", position.getLatitude()) : "0";
@@ -655,16 +654,9 @@ public class JobSummaryView extends Main implements HasUrlParameter<String> {
init(); init();
} }
})(); })();
""".formatted( """
escapeJs(origin), .formatted(escapeJs(origin), escapeJs(destination), escapeJs(apiKey), lat, lng,
escapeJs(destination), Boolean.toString(hasPosition), escapeJs(appUserId), Boolean.toString(shouldUpdate));
escapeJs(apiKey),
lat,
lng,
Boolean.toString(hasPosition),
escapeJs(appUserId),
Boolean.toString(shouldUpdate)
);
} }
// Hilfsfunktion zum einfachen Escapen von JS-Zeichen in Strings // Hilfsfunktion zum einfachen Escapen von JS-Zeichen in Strings

View File

@@ -341,17 +341,13 @@ public class MessagesView extends Main {
} }
private void requestBrowserPermissions(UI ui) { private void requestBrowserPermissions(UI ui) {
ui.getPage().executeJs( ui.getPage()
"if ('Notification' in window && Notification.permission === 'default') {" .executeJs("if ('Notification' in window && Notification.permission === 'default') {"
+ " Notification.requestPermission();" + " Notification.requestPermission();" + "}" + "if (!window._votianAudioCtx) {"
+ "}"
+ "if (!window._votianAudioCtx) {"
+ " window._votianAudioCtx = new (window.AudioContext || window.webkitAudioContext)();" + " window._votianAudioCtx = new (window.AudioContext || window.webkitAudioContext)();"
+ " document.addEventListener('click', function _resumeAudio() {" + " document.addEventListener('click', function _resumeAudio() {"
+ " window._votianAudioCtx.resume();" + " window._votianAudioCtx.resume();"
+ " document.removeEventListener('click', _resumeAudio);" + " document.removeEventListener('click', _resumeAudio);" + " }, { once: true });" + "}");
+ " }, { once: true });"
+ "}");
} }
@Override @Override
@@ -377,27 +373,18 @@ public class MessagesView extends Main {
String preview = resolvePreview(message); String preview = resolvePreview(message);
// Play notification sound // Play notification sound
ui.getPage().executeJs( ui.getPage()
"try {" .executeJs("try {" + " const ctx = new (window.AudioContext || window.webkitAudioContext)();"
+ " const ctx = new (window.AudioContext || window.webkitAudioContext)();" + " ctx.resume().then(() => {" + " const osc = ctx.createOscillator();"
+ " ctx.resume().then(() => {" + " const gain = ctx.createGain();" + " osc.connect(gain);"
+ " const osc = ctx.createOscillator();" + " gain.connect(ctx.destination);" + " osc.frequency.value = 800;"
+ " const gain = ctx.createGain();" + " osc.type = 'sine';" + " gain.gain.setValueAtTime(0.3, ctx.currentTime);"
+ " osc.connect(gain);"
+ " gain.connect(ctx.destination);"
+ " osc.frequency.value = 800;"
+ " osc.type = 'sine';"
+ " gain.gain.setValueAtTime(0.3, ctx.currentTime);"
+ " gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.5);" + " gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.5);"
+ " osc.start(ctx.currentTime);" + " osc.start(ctx.currentTime);" + " osc.stop(ctx.currentTime + 0.5);" + " });"
+ " osc.stop(ctx.currentTime + 0.5);"
+ " });"
+ "} catch(e) { console.warn('Notification sound failed:', e); }"); + "} catch(e) { console.warn('Notification sound failed:', e); }");
// Show notification // Show notification
Notification notification = Notification.show( Notification notification = Notification.show("Neue Nachricht von " + senderName + ": " + preview, 4000,
"Neue Nachricht von " + senderName + ": " + preview,
4000,
Notification.Position.TOP_END); Notification.Position.TOP_END);
notification.addThemeVariants(NotificationVariant.LUMO_PRIMARY); notification.addThemeVariants(NotificationVariant.LUMO_PRIMARY);
@@ -410,12 +397,8 @@ public class MessagesView extends Main {
return "Unbekannt"; return "Unbekannt";
} }
List<AppUser> appUsers = cachedAppUsers != null ? cachedAppUsers : List.of(); List<AppUser> appUsers = cachedAppUsers != null ? cachedAppUsers : List.of();
return appUsers.stream() return appUsers.stream().filter(user -> clientId.equals(user.getIdAsString())
.filter(user -> clientId.equals(user.getIdAsString()) || clientId.equals(user.getEmail()) || clientId.equals(user.getAppCode())).findFirst()
|| clientId.equals(user.getEmail()) .map(this::buildClientName).orElse(clientId);
|| clientId.equals(user.getAppCode()))
.findFirst()
.map(this::buildClientName)
.orElse(clientId);
} }
} }

View File

@@ -107,7 +107,8 @@ public class ShowJobsView extends VerticalLayout {
grid.addColumn(job -> extractCompanyName(job.getCustomerSelection())).setHeader("Auftraggeber") grid.addColumn(job -> extractCompanyName(job.getCustomerSelection())).setHeader("Auftraggeber")
.setAutoWidth(true).setFlexGrow(1).setSortable(true); .setAutoWidth(true).setFlexGrow(1).setSortable(true);
grid.addColumn(Job::getJobNumber).setHeader("Auftragsnummer").setAutoWidth(true).setSortable(true); grid.addColumn(Job::getJobNumber).setHeader("Auftragsnummer").setAutoWidth(true).setSortable(true);
grid.addColumn(job -> DateTimeFormatUtil.formatDateTime(job.getCreatedAt())).setHeader("Auftragsdatum").setAutoWidth(true).setSortable(true); grid.addColumn(job -> DateTimeFormatUtil.formatDateTime(job.getCreatedAt())).setHeader("Auftragsdatum")
.setAutoWidth(true).setSortable(true);
grid.addColumn(Job::getDeliveryCity).setHeader("Zielort").setAutoWidth(true).setFlexGrow(1).setSortable(true); grid.addColumn(Job::getDeliveryCity).setHeader("Zielort").setAutoWidth(true).setFlexGrow(1).setSortable(true);
// Action column: manual completion for jobs without digital processing // Action column: manual completion for jobs without digital processing
@@ -126,8 +127,27 @@ public class ShowJobsView extends VerticalLayout {
return new com.vaadin.flow.component.html.Span(); return new com.vaadin.flow.component.html.Span();
}).setHeader("").setAutoWidth(true).setFlexGrow(0); }).setHeader("").setAutoWidth(true).setFlexGrow(0);
// Invoice column - only show for completed jobs
grid.addComponentColumn(job -> {
if (job.getStatus() == JobStatus.COMPLETED) {
Button invoiceBtn = new Button(new Icon(VaadinIcon.DOLLAR));
invoiceBtn.addThemeVariants(ButtonVariant.LUMO_TERTIARY, ButtonVariant.LUMO_SUCCESS);
invoiceBtn.setTooltipText("Rechnung erstellen");
invoiceBtn.addClickListener(e -> {
e.getSource().getElement().getNode(); // prevent row click
getUI().ifPresent(ui -> ui.navigate("create_invoice/" + job.getId().toHexString()));
});
return invoiceBtn;
}
return new com.vaadin.flow.component.html.Span();
}).setHeader("").setWidth("60px").setFlexGrow(0);
// Delete column (last column, right side) // Delete column (last column, right side)
grid.addComponentColumn(job -> { grid.addComponentColumn(job -> {
if (job.getStatus() == JobStatus.COMPLETED || job.getStatus() == JobStatus.CANCELLED
|| job.getStatus() == JobStatus.DELIVERED) {
return new com.vaadin.flow.component.html.Span(); // No delete button for completed jobs
}
Button deleteBtn = new Button(new Icon(VaadinIcon.TRASH)); Button deleteBtn = new Button(new Icon(VaadinIcon.TRASH));
deleteBtn.addThemeVariants(ButtonVariant.LUMO_TERTIARY, ButtonVariant.LUMO_ERROR); deleteBtn.addThemeVariants(ButtonVariant.LUMO_TERTIARY, ButtonVariant.LUMO_ERROR);
deleteBtn.setTooltipText("Auftrag löschen"); deleteBtn.setTooltipText("Auftrag löschen");
@@ -187,7 +207,8 @@ public class ShowJobsView extends VerticalLayout {
private void showDeleteJobDialog(Job job) { private void showDeleteJobDialog(Job job) {
ConfirmDialog dialog = new ConfirmDialog(); ConfirmDialog dialog = new ConfirmDialog();
dialog.setHeader("Auftrag löschen"); dialog.setHeader("Auftrag löschen");
dialog.setText("Möchten Sie den Auftrag " + job.getJobNumber() + " wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden."); dialog.setText("Möchten Sie den Auftrag " + job.getJobNumber()
+ " wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.");
dialog.setCancelable(true); dialog.setCancelable(true);
dialog.setCancelText("Abbrechen"); dialog.setCancelText("Abbrechen");
dialog.setConfirmText("Löschen"); dialog.setConfirmText("Löschen");
@@ -224,11 +245,8 @@ public class ShowJobsView extends VerticalLayout {
return; return;
} }
Map<String, Object> payload = Map.of( Map<String, Object> payload = Map.of("type", "job_deleted", "jobId", job.getId().toHexString(), "jobNumber",
"type", "job_deleted", job.getJobNumber() != null ? job.getJobNumber() : "", "deletedAt", LocalDateTime.now().toString());
"jobId", job.getId().toHexString(),
"jobNumber", job.getJobNumber() != null ? job.getJobNumber() : "",
"deletedAt", LocalDateTime.now().toString());
log.info("[JOB] Sending job_deleted to {}: {}", appUserId, payload); log.info("[JOB] Sending job_deleted to {}: {}", appUserId, payload);
messagingPublisher.publishAsJson(appUserId, "job_deleted", payload); messagingPublisher.publishAsJson(appUserId, "job_deleted", payload);

View File

@@ -225,7 +225,7 @@ public class StartView extends VerticalLayout implements BeforeEnterObserver {
featuresGrid.setWidthFull(); featuresGrid.setWidthFull();
featuresGrid.setSpacing(true); featuresGrid.setSpacing(true);
featuresGrid.setJustifyContentMode(FlexComponent.JustifyContentMode.CENTER); featuresGrid.setJustifyContentMode(FlexComponent.JustifyContentMode.CENTER);
featuresGrid.setDefaultVerticalComponentAlignment(FlexComponent.Alignment.START); featuresGrid.setDefaultVerticalComponentAlignment(FlexComponent.Alignment.STRETCH);
// Feature Cards // Feature Cards
featuresGrid.add(createFeatureCard(VaadinIcon.COG, "Einrichtungsassistent", featuresGrid.add(createFeatureCard(VaadinIcon.COG, "Einrichtungsassistent",
@@ -249,6 +249,7 @@ public class StartView extends VerticalLayout implements BeforeEnterObserver {
card.getStyle().set("background-color", "var(--lumo-base-color)"); card.getStyle().set("background-color", "var(--lumo-base-color)");
card.getStyle().set("box-shadow", "var(--lumo-box-shadow-xs)"); card.getStyle().set("box-shadow", "var(--lumo-box-shadow-xs)");
card.setWidth("300px"); card.setWidth("300px");
card.setHeightFull();
Icon icon = iconType.create(); Icon icon = iconType.create();
icon.setSize("48px"); icon.setSize("48px");

View File

@@ -0,0 +1,13 @@
package de.assecutor.votianlt.repository;
import de.assecutor.votianlt.model.Service;
import org.springframework.data.mongodb.repository.MongoRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface ServiceRepository extends MongoRepository<Service, String> {
List<Service> findByUserId(String userId);
void deleteByUserId(String userId);
}

View File

@@ -27,8 +27,7 @@ public class SecurityConfig extends VaadinWebSecurity {
new AntPathRequestMatcher("/frontend/**"), new AntPathRequestMatcher("/webjars/**"), new AntPathRequestMatcher("/frontend/**"), new AntPathRequestMatcher("/webjars/**"),
new AntPathRequestMatcher("/h2-console/**"), new AntPathRequestMatcher("/h2-console/**"),
new AntPathRequestMatcher("/frontend-es5/**", "/frontend-es6/**"), new AntPathRequestMatcher("/frontend-es5/**", "/frontend-es6/**"),
new AntPathRequestMatcher("/mcp/**"), new AntPathRequestMatcher("/mcp/**"), new AntPathRequestMatcher("/ws/**"))
new AntPathRequestMatcher("/ws/**"))
.permitAll()); .permitAll());
// Standard-CSRF-Konfiguration // Standard-CSRF-Konfiguration

View File

@@ -244,14 +244,15 @@ public class CustomerInvoiceService {
} }
/** /**
* Generate a PDF preview from canvas template data. * Generate a PDF preview from canvas template data. Creates an HTML
* Creates an HTML representation of the canvas elements and converts it to PDF. * representation of the canvas elements and converts it to PDF.
*/ */
public byte[] generatePdfFromCanvasTemplate(String jsonTemplateData) throws Exception { public byte[] generatePdfFromCanvasTemplate(String jsonTemplateData) throws Exception {
return generatePdfFromCanvasTemplate(jsonTemplateData, null); return generatePdfFromCanvasTemplate(jsonTemplateData, null);
} }
public byte[] generatePdfFromCanvasTemplate(String jsonTemplateData, de.assecutor.votianlt.model.User user) throws Exception { public byte[] generatePdfFromCanvasTemplate(String jsonTemplateData, de.assecutor.votianlt.model.User user)
throws Exception {
// Parse the JSON template data // Parse the JSON template data
com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper(); com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper();
com.fasterxml.jackson.databind.JsonNode rootNode = mapper.readTree(jsonTemplateData); com.fasterxml.jackson.databind.JsonNode rootNode = mapper.readTree(jsonTemplateData);
@@ -264,7 +265,8 @@ public class CustomerInvoiceService {
htmlBuilder.append("<meta charset='UTF-8'>"); htmlBuilder.append("<meta charset='UTF-8'>");
htmlBuilder.append("<style>"); htmlBuilder.append("<style>");
htmlBuilder.append("@page { size: A4; margin: 0; }"); htmlBuilder.append("@page { size: A4; margin: 0; }");
htmlBuilder.append("body { margin: 0; padding: 0; width: 210mm; height: 297mm; position: relative; font-family: Arial, sans-serif; }"); htmlBuilder.append(
"body { margin: 0; padding: 0; width: 210mm; height: 297mm; position: relative; font-family: Arial, sans-serif; }");
htmlBuilder.append(".element { position: absolute; box-sizing: border-box; overflow: hidden; }"); htmlBuilder.append(".element { position: absolute; box-sizing: border-box; overflow: hidden; }");
htmlBuilder.append(".text { white-space: nowrap; overflow: visible; }"); htmlBuilder.append(".text { white-space: nowrap; overflow: visible; }");
htmlBuilder.append(".line { border-top: 1px solid #333; }"); htmlBuilder.append(".line { border-top: 1px solid #333; }");
@@ -316,7 +318,8 @@ public class CustomerInvoiceService {
text = element.has("text") ? element.get("text").asText("") : ""; text = element.has("text") ? element.get("text").asText("") : "";
} }
// Use percentage values if available, otherwise fall back to legacy pixel values // Use percentage values if available, otherwise fall back to legacy pixel
// values
double xPercent, yPercent, widthPercent, heightPercent; double xPercent, yPercent, widthPercent, heightPercent;
if (element.has("xPercent")) { if (element.has("xPercent")) {
xPercent = element.get("xPercent").asDouble(0); xPercent = element.get("xPercent").asDouble(0);
@@ -359,12 +362,15 @@ public class CustomerInvoiceService {
htmlBuilder.append("left:").append(String.format(java.util.Locale.US, "%.2f", mmX)).append("mm;"); 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;"); htmlBuilder.append("top:").append(String.format(java.util.Locale.US, "%.2f", mmY)).append("mm;");
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("height:").append(String.format(java.util.Locale.US, "%.2f", mmHeight))
.append("mm;");
htmlBuilder.append("font-size:").append(fontSize).append("pt;"); 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(";"); htmlBuilder.append("color:").append(color).append(";");
if (!fontStyle.isEmpty()) { if (!fontStyle.isEmpty()) {
if (fontStyle.contains("bold")) htmlBuilder.append("font-weight:bold;"); if (fontStyle.contains("bold"))
htmlBuilder.append("font-weight:bold;");
} }
// Vertically center content // Vertically center content
htmlBuilder.append("display:flex;align-items:center;"); htmlBuilder.append("display:flex;align-items:center;");
@@ -393,17 +399,15 @@ public class CustomerInvoiceService {
// Escape HTML special characters in text AFTER variable replacement // Escape HTML special characters in text AFTER variable replacement
if (text != null) { if (text != null) {
text = text.replace("&", "&amp;") text = text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("\"", "&quot;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")
.replace("'", "&#x27;"); .replace("'", "&#x27;");
} else { } else {
text = ""; text = "";
} }
if ("line".equals(type)) { if ("line".equals(type)) {
htmlBuilder.append("<hr style='margin:0;border:none;border-top:1px solid #333;height:0;width:100%;'/>"); htmlBuilder.append(
"<hr style='margin:0;border:none;border-top:1px solid #333;height:0;width:100%;'/>");
} else if ("image".equals(type)) { } else if ("image".equals(type)) {
if (element.has("imageData") && !element.get("imageData").asText().isEmpty()) { if (element.has("imageData") && !element.get("imageData").asText().isEmpty()) {
String imageData = element.get("imageData").asText(); String imageData = element.get("imageData").asText();
@@ -412,11 +416,14 @@ public class CustomerInvoiceService {
imageData = "data:image/png;base64," + imageData; imageData = "data:image/png;base64," + imageData;
} }
// Use object-fit:contain to preserve aspect ratio // Use object-fit:contain to preserve aspect ratio
htmlBuilder.append("<div style='width:100%;height:100%;display:flex;align-items:center;justify-content:center;overflow:hidden;'>"); htmlBuilder.append(
htmlBuilder.append("<img src=\"").append(imageData.replace("\"", "%22")).append("\" style='max-width:100%;max-height:100%;object-fit:contain;' alt='Bild' />"); "<div style='width:100%;height:100%;display:flex;align-items:center;justify-content:center;overflow:hidden;'>");
htmlBuilder.append("<img src=\"").append(imageData.replace("\"", "%22"))
.append("\" style='max-width:100%;max-height:100%;object-fit:contain;' alt='Bild' />");
htmlBuilder.append("</div>"); htmlBuilder.append("</div>");
} else { } else {
htmlBuilder.append("<div style='width:100%;height:100%;background:#f0f0f0;display:flex;align-items:center;justify-content:center;font-size:10pt;color:#666;'>[Bild]</div>"); htmlBuilder.append(
"<div style='width:100%;height:100%;background:#f0f0f0;display:flex;align-items:center;justify-content:center;font-size:10pt;color:#666;'>[Bild]</div>");
} }
} else { } else {
// Wrap text in a span to prevent flexbox issues // Wrap text in a span to prevent flexbox issues

View File

@@ -49,8 +49,8 @@ public class LocationService {
Double heading = extractDouble(payload.get("heading")); Double heading = extractDouble(payload.get("heading"));
Instant timestamp = extractInstant(payload.get("timestamp")); Instant timestamp = extractInstant(payload.get("timestamp"));
LocationPosition position = new LocationPosition( LocationPosition position = new LocationPosition(appUserId, latitude, longitude, accuracy, altitude, speed,
appUserId, latitude, longitude, accuracy, altitude, speed, heading, timestamp); heading, timestamp);
locationPositionRepository.save(position); locationPositionRepository.save(position);
log.debug("[Location] Saved position for {}: lat={}, lon={}", appUserId, latitude, longitude); log.debug("[Location] Saved position for {}: lat={}, lon={}", appUserId, latitude, longitude);
@@ -68,7 +68,8 @@ public class LocationService {
* @return The latest position or null if none found * @return The latest position or null if none found
*/ */
public LocationPosition getLatestPosition(String appUserId) { public LocationPosition getLatestPosition(String appUserId) {
List<LocationPosition> positions = locationPositionRepository.findTop1ByAppUserIdOrderByTimestampDesc(appUserId); List<LocationPosition> positions = locationPositionRepository
.findTop1ByAppUserIdOrderByTimestampDesc(appUserId);
return positions.isEmpty() ? null : positions.get(0); return positions.isEmpty() ? null : positions.get(0);
} }
@@ -87,9 +88,9 @@ public class LocationService {
} }
/** /**
* Cleanup old positions. Runs every 5 minutes. * Cleanup old positions. Runs every 5 minutes. Note: Positions also have a TTL
* Note: Positions also have a TTL index that auto-deletes after 60 minutes, * index that auto-deletes after 60 minutes, but this scheduled cleanup ensures
* but this scheduled cleanup ensures immediate removal and logging. * immediate removal and logging.
*/ */
@Scheduled(fixedRate = 300000) // 5 minutes @Scheduled(fixedRate = 300000) // 5 minutes
public void cleanupOldPositions() { public void cleanupOldPositions() {

View File

@@ -115,8 +115,8 @@ public class MessageService {
} }
/** /**
* Publish message to topic for the receiver. * Publish message to topic for the receiver. Only sends if client is connected,
* Only sends if client is connected, otherwise keeps NOTSEND status. * otherwise keeps NOTSEND status.
*/ */
private void publishMessage(Message message, String receiver) { private void publishMessage(Message message, String receiver) {
try { try {
@@ -132,18 +132,16 @@ public class MessageService {
byte[] data = objectMapper.writeValueAsString(payload).getBytes(StandardCharsets.UTF_8); byte[] data = objectMapper.writeValueAsString(payload).getBytes(StandardCharsets.UTF_8);
// Use WebSocketService directly to get CompletableFuture for delivery tracking // Use WebSocketService directly to get CompletableFuture for delivery tracking
webSocketService.sendToClient(receiver, "message", data) webSocketService.sendToClient(receiver, "message", data).thenRun(() -> {
.thenRun(() -> {
// Success: mark as sent // Success: mark as sent
message.markAsSent(); message.markAsSent();
messageRepository.save(message); messageRepository.save(message);
log.debug("[Messaging] Message {} delivered to client {}, marked as SEND", log.debug("[Messaging] Message {} delivered to client {}, marked as SEND", message.getIdAsString(),
message.getIdAsString(), receiver); receiver);
}) }).exceptionally(ex -> {
.exceptionally(ex -> {
// Failed to deliver: keep NOTSEND status // Failed to deliver: keep NOTSEND status
log.debug("[Messaging] Failed to deliver message {} to client {}: {}", log.debug("[Messaging] Failed to deliver message {} to client {}: {}", message.getIdAsString(),
message.getIdAsString(), receiver, ex.getMessage()); receiver, ex.getMessage());
return null; return null;
}); });
@@ -160,8 +158,8 @@ public class MessageService {
} }
/** /**
* Send pending messages to a client that just connected. * Send pending messages to a client that just connected. Called after
* Called after successful authentication. * successful authentication.
* *
* @param receiver * @param receiver
* AppUser ID (clientId) * AppUser ID (clientId)
@@ -184,14 +182,12 @@ public class MessageService {
ChatMessageOutboundPayload payload = ChatMessageOutboundPayload.fromMessage(message); ChatMessageOutboundPayload payload = ChatMessageOutboundPayload.fromMessage(message);
byte[] data = objectMapper.writeValueAsString(payload).getBytes(StandardCharsets.UTF_8); byte[] data = objectMapper.writeValueAsString(payload).getBytes(StandardCharsets.UTF_8);
webSocketService.sendToClient(receiver, "message", data) webSocketService.sendToClient(receiver, "message", data).thenRun(() -> {
.thenRun(() -> {
message.markAsSent(); message.markAsSent();
messageRepository.save(message); messageRepository.save(message);
}) }).exceptionally(ex -> {
.exceptionally(ex -> { log.error("[Messaging] Failed to send pending message {}: {}", message.getIdAsString(),
log.error("[Messaging] Failed to send pending message {}: {}", ex.getMessage());
message.getIdAsString(), ex.getMessage());
return null; return null;
}); });
sentCount++; sentCount++;