Erweiterungen
This commit is contained in:
@@ -17,3 +17,6 @@ History currently uses brief German titles; shift to imperative, scoped summarie
|
||||
|
||||
## 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.
|
||||
|
||||
# Misc
|
||||
Never start the application; leave that to the user.
|
||||
@@ -27,7 +27,8 @@ public class LocationApiController {
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
@GetMapping("/{appUserId}")
|
||||
|
||||
@@ -36,8 +36,8 @@ import java.util.Optional;
|
||||
import org.bson.types.ObjectId;
|
||||
|
||||
/**
|
||||
* Message controller for handling real-time communication with apps.
|
||||
* Provides endpoints for sending and receiving messages via WebSocket.
|
||||
* Message controller for handling real-time communication with apps. Provides
|
||||
* endpoints for sending and receiving messages via WebSocket.
|
||||
*/
|
||||
@Component
|
||||
@Slf4j
|
||||
@@ -409,9 +409,9 @@ public class MessageController {
|
||||
|
||||
/**
|
||||
* Handle incoming message from a client via WebSocket. Client sends to
|
||||
* /server/message with payload: { "content": "message payload",
|
||||
* "contentType": "TEXT|IMAGE", "jobId": "optional job id", "jobNumber":
|
||||
* "optional job number" }
|
||||
* /server/message with payload: { "content": "message payload", "contentType":
|
||||
* "TEXT|IMAGE", "jobId": "optional job id", "jobNumber": "optional job number"
|
||||
* }
|
||||
*
|
||||
* The appUserId is determined from the authenticated WebSocket session.
|
||||
*/
|
||||
|
||||
BIN
src/main/java/de/assecutor/votianlt/dto/AppLoginRequest.class
Normal file
BIN
src/main/java/de/assecutor/votianlt/dto/AppLoginRequest.class
Normal file
Binary file not shown.
BIN
src/main/java/de/assecutor/votianlt/dto/AppLoginResponse.class
Normal file
BIN
src/main/java/de/assecutor/votianlt/dto/AppLoginResponse.class
Normal file
Binary file not shown.
@@ -10,6 +10,9 @@ import lombok.NoArgsConstructor;
|
||||
public class AppLoginResponse {
|
||||
private boolean success;
|
||||
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;
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -113,9 +113,7 @@ public class MessagingConfig {
|
||||
|
||||
// Send success response to the now-authenticated session
|
||||
// locationTrackingEnabled: true = client should send position updates
|
||||
Map<String, Object> authResponse = Map.of(
|
||||
"success", true,
|
||||
"message", response.getMessage(),
|
||||
Map<String, Object> authResponse = Map.of("success", true, "message", response.getMessage(),
|
||||
"locationTrackingEnabled", true);
|
||||
byte[] responseBytes = objectMapper.writeValueAsBytes(authResponse);
|
||||
webSocketService.sendToClient(appUserId, "auth", responseBytes);
|
||||
|
||||
@@ -255,8 +255,8 @@ public class WebSocketService extends TextWebSocketHandler {
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a pending session as authenticated under the given appUserId.
|
||||
* Called by MessagingConfig after successful login.
|
||||
* Register a pending session as authenticated under the given appUserId. Called
|
||||
* by MessagingConfig after successful login.
|
||||
*/
|
||||
public void registerAuthenticatedSession(String wsSessionId, String appUserId) {
|
||||
PendingSession pending = pendingSessions.get(wsSessionId);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -9,8 +9,8 @@ import org.springframework.data.mongodb.core.mapping.Document;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* Stores invoice template data for a user.
|
||||
* Contains the JSON representation of the canvas elements.
|
||||
* Stores invoice template data for a user. Contains the JSON representation of
|
||||
* the canvas elements.
|
||||
*/
|
||||
@Document(collection = "invoice_templates")
|
||||
@Data
|
||||
|
||||
@@ -133,6 +133,14 @@ public class Job {
|
||||
@Field("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
|
||||
* job id is returned as a string when jobs are retrieved via API.
|
||||
|
||||
@@ -77,8 +77,8 @@ public class LocationPosition {
|
||||
@Indexed(expireAfter = "3600s") // TTL index: auto-delete after 60 minutes
|
||||
private Instant receivedAt;
|
||||
|
||||
public LocationPosition(String appUserId, Double latitude, Double longitude, Double accuracy,
|
||||
Double altitude, Double speed, Double heading, Instant timestamp) {
|
||||
public LocationPosition(String appUserId, Double latitude, Double longitude, Double accuracy, Double altitude,
|
||||
Double speed, Double heading, Instant timestamp) {
|
||||
this.appUserId = appUserId;
|
||||
this.latitude = latitude;
|
||||
this.longitude = longitude;
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
package de.assecutor.votianlt.model;
|
||||
|
||||
/**
|
||||
* Delivery status for messages sent to clients.
|
||||
* Tracks whether a message was successfully delivered via WebSocket.
|
||||
* Delivery status for messages sent to clients. Tracks whether a message was
|
||||
* successfully delivered via WebSocket.
|
||||
*/
|
||||
public enum MessageDeliveryStatus {
|
||||
NOTSEND("Nicht gesendet"),
|
||||
SEND("Gesendet");
|
||||
NOTSEND("Nicht gesendet"), SEND("Gesendet");
|
||||
|
||||
private final String displayName;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
138
src/main/java/de/assecutor/votianlt/model/Service.java
Normal file
138
src/main/java/de/assecutor/votianlt/model/Service.java
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -182,8 +182,8 @@ public class AddJobService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Sendet den neu erstellten Job per WebSocket an den zugewiesenen Client, falls dieser
|
||||
* online ist.
|
||||
* Sendet den neu erstellten Job per WebSocket an den zugewiesenen Client, falls
|
||||
* dieser online ist.
|
||||
*/
|
||||
private void notifyClientJobCreated(Job job) {
|
||||
if (!job.isDigitalProcessing()) {
|
||||
|
||||
@@ -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®ion=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®ion=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;
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,7 @@ import de.assecutor.votianlt.model.task.CommentTask;
|
||||
import de.assecutor.votianlt.pages.service.AddJobService;
|
||||
import de.assecutor.votianlt.pages.service.CustomerService;
|
||||
import de.assecutor.votianlt.pages.service.AddCustomerService;
|
||||
import de.assecutor.votianlt.pages.service.AddressValidationService;
|
||||
import de.assecutor.votianlt.model.Customer;
|
||||
import de.assecutor.votianlt.pages.service.AppUserService;
|
||||
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.User;
|
||||
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 lombok.extern.slf4j.Slf4j;
|
||||
import de.assecutor.votianlt.model.CargoItem;
|
||||
import de.assecutor.votianlt.model.AddressValidationResult;
|
||||
import de.assecutor.votianlt.model.RouteCalculationResult;
|
||||
import java.time.LocalDate;
|
||||
import java.util.*;
|
||||
import java.util.Objects;
|
||||
@@ -73,6 +81,8 @@ public class AddJobView extends Main {
|
||||
private final AppUserService appUserService;
|
||||
private final TaskTemplateService taskTemplateService;
|
||||
private final SecurityService securityService;
|
||||
private final ServiceRepository serviceRepository;
|
||||
private final AddressValidationService addressValidationService;
|
||||
|
||||
// Customer selection
|
||||
private ComboBox<String> customerSelection;
|
||||
@@ -108,8 +118,12 @@ public class AddJobView extends Main {
|
||||
private Checkbox digitalProcessing;
|
||||
private ComboBox<AppUser> appUser;
|
||||
|
||||
// Price field
|
||||
private TextField price;
|
||||
// Services for the job
|
||||
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
|
||||
private DatePicker pickupDate;
|
||||
@@ -152,19 +166,36 @@ public class AddJobView extends Main {
|
||||
// Available app users for the current user
|
||||
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,
|
||||
CustomerService customerService, AppUserService appUserService, TaskTemplateService taskTemplateService,
|
||||
SecurityService securityService) {
|
||||
SecurityService securityService, ServiceRepository serviceRepository,
|
||||
AddressValidationService addressValidationService) {
|
||||
this.addJobService = addJobService;
|
||||
this.addCustomerService = addCustomerService;
|
||||
this.customerService = customerService;
|
||||
this.appUserService = appUserService;
|
||||
this.taskTemplateService = taskTemplateService;
|
||||
this.securityService = securityService;
|
||||
this.serviceRepository = serviceRepository;
|
||||
this.addressValidationService = addressValidationService;
|
||||
initializeComponents();
|
||||
setupLayout();
|
||||
setupValidation();
|
||||
loadDraftIfExists();
|
||||
loadMandatoryServices();
|
||||
}
|
||||
|
||||
private void initializeComponents() {
|
||||
@@ -357,20 +388,7 @@ public class AddJobView extends Main {
|
||||
user -> user.getVorname() + " " + user.getNachname() + " (" + user.getEmail() + ")");
|
||||
appUser.setPlaceholder("App-Nutzer auswählen...");
|
||||
|
||||
// Price field
|
||||
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);
|
||||
}
|
||||
});
|
||||
// Services grid will be initialized in createPriceAndSubmitTab()
|
||||
// Date picker fields for appointments
|
||||
pickupDate = new DatePicker("Datum");
|
||||
pickupDate.setRequiredIndicatorVisible(true);
|
||||
@@ -412,7 +430,7 @@ public class AddJobView extends Main {
|
||||
|
||||
// Create TabSheet for organizing the form
|
||||
// TabSheet and Tab references for dynamic label updates
|
||||
TabSheet tabSheet = new TabSheet();
|
||||
tabSheet = new TabSheet();
|
||||
tabSheet.setSizeFull();
|
||||
|
||||
// Tab 1: Customer & Addresses
|
||||
@@ -437,7 +455,10 @@ public class AddJobView extends Main {
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
||||
@@ -573,24 +594,200 @@ public class AddJobView extends Main {
|
||||
tabContent.setSizeFull();
|
||||
tabContent.setPadding(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();
|
||||
content.setPadding(false);
|
||||
content.setSpacing(true);
|
||||
content.setWidth("720px");
|
||||
content.setWidthFull();
|
||||
content.setDefaultHorizontalComponentAlignment(FlexComponent.Alignment.STRETCH);
|
||||
|
||||
// Preis (netto) - moved from createTasksAndNotesSection
|
||||
H3 priceTitle = new H3("Preis (netto)");
|
||||
priceTitle.getStyle().set("margin", "0");
|
||||
content.add(priceTitle, price);
|
||||
// Title
|
||||
H3 servicesTitle = new H3("Leistungen");
|
||||
servicesTitle.getStyle().set("margin", "0");
|
||||
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);
|
||||
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() {
|
||||
VerticalLayout section = new VerticalLayout();
|
||||
section.setSpacing(true);
|
||||
@@ -833,21 +1030,15 @@ public class AddJobView extends Main {
|
||||
|
||||
binder.forField(deliveryCity).asRequired("").bind(Job::getDeliveryCity, Job::setDeliveryCity);
|
||||
|
||||
// Bind price field: Komma-Zahlen in Punkt-Zahlen umsetzen, dann nach BigDecimal
|
||||
// konvertieren
|
||||
binder.forField(price).withNullRepresentation("").asRequired("Preis erforderlich").withConverter((String s) -> {
|
||||
if (s == null || s.trim().isEmpty())
|
||||
return null;
|
||||
String normalized = s.replace(" ", "").replace(".", "").replace(',', '.');
|
||||
try {
|
||||
return new java.math.BigDecimal(normalized);
|
||||
} catch (NumberFormatException ex) {
|
||||
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);
|
||||
// Price is now calculated from selected services - bind to job price for
|
||||
// storage
|
||||
binder.forField(new com.vaadin.flow.component.textfield.TextField()).withConverter((String str) -> {
|
||||
// Calculate total from selected services
|
||||
BigDecimal total = selectedServices.stream()
|
||||
.map(svc -> svc.getEffectivePrice() != null ? svc.getEffectivePrice() : BigDecimal.ZERO)
|
||||
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
return total;
|
||||
}, (java.math.BigDecimal bd) -> bd == null ? "" : bd.toString()).bind(Job::getPrice, Job::setPrice);
|
||||
|
||||
// Bind date picker fields with validation
|
||||
binder.forField(pickupDate).asRequired("")
|
||||
@@ -952,7 +1143,7 @@ public class AddJobView extends Main {
|
||||
// List of all required fields
|
||||
TextField[] requiredTextFields = { pickupFirstName, pickupLastName, pickupStreet, pickupHouseNumber, pickupZip,
|
||||
pickupCity, deliveryFirstName, deliveryLastName, deliveryStreet, deliveryHouseNumber, deliveryZip,
|
||||
deliveryCity, price };
|
||||
deliveryCity };
|
||||
|
||||
// List of required date fields
|
||||
DatePicker[] requiredDateFields = { pickupDate, deliveryDate };
|
||||
@@ -1078,8 +1269,8 @@ public class AddJobView extends Main {
|
||||
|
||||
private boolean hasAppointmentValidationErrors() {
|
||||
LocalDate today = LocalDate.now();
|
||||
return pickupDate.getValue() == null || deliveryDate.getValue() == null
|
||||
|| pickupDate.getValue().isBefore(today) || deliveryDate.getValue().isBefore(today);
|
||||
return pickupDate.getValue() == null || deliveryDate.getValue() == null || pickupDate.getValue().isBefore(today)
|
||||
|| deliveryDate.getValue().isBefore(today);
|
||||
}
|
||||
|
||||
private boolean hasCargoValidationErrors() {
|
||||
@@ -1105,7 +1296,8 @@ public class AddJobView extends Main {
|
||||
|
||||
private boolean hasTasksValidationErrors() {
|
||||
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) {
|
||||
String description = task.getDescription();
|
||||
if (description == null || description.trim().isEmpty()) {
|
||||
@@ -1133,7 +1325,8 @@ public class AddJobView extends Main {
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -1153,6 +1346,15 @@ public class AddJobView extends Main {
|
||||
if (remarkArea != null)
|
||||
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
|
||||
if (binder.writeBeanIfValid(job)) {
|
||||
// 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)
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
@@ -1591,8 +1818,12 @@ public class AddJobView extends Main {
|
||||
digitalProcessing.setValue(true);
|
||||
appUser.clear();
|
||||
|
||||
// Price field
|
||||
price.clear();
|
||||
// Clear services
|
||||
selectedServices.clear();
|
||||
if (servicesGrid != null) {
|
||||
servicesGrid.getDataProvider().refreshAll();
|
||||
}
|
||||
updatePriceSummary();
|
||||
|
||||
// Benutzer-Feedback
|
||||
Notification.show("Alle Felder wurden geleert", 2000, Notification.Position.BOTTOM_CENTER);
|
||||
@@ -2435,4 +2666,374 @@ public class AddJobView extends Main {
|
||||
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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -49,10 +49,7 @@ public class InvoiceGeneratorView extends VerticalLayout {
|
||||
setMargin(false);
|
||||
setWidth("100%");
|
||||
setHeight("100%");
|
||||
getStyle()
|
||||
.set("overflow", "hidden")
|
||||
.set("box-sizing", "border-box")
|
||||
.set("display", "flex")
|
||||
getStyle().set("overflow", "hidden").set("box-sizing", "border-box").set("display", "flex")
|
||||
.set("flex-direction", "column");
|
||||
|
||||
// Hauptlayout: Links (Templates) | Mitte (Canvas) | Rechts (Eigenschaften)
|
||||
@@ -66,27 +63,19 @@ public class InvoiceGeneratorView extends VerticalLayout {
|
||||
VerticalLayout leftPanel = createTemplatesPanel();
|
||||
leftPanel.setWidth("250px");
|
||||
leftPanel.setHeightFull();
|
||||
leftPanel.getStyle()
|
||||
.set("flex-shrink", "0")
|
||||
.set("min-width", "250px")
|
||||
.set("overflow", "auto");
|
||||
leftPanel.getStyle().set("flex-shrink", "0").set("min-width", "250px").set("overflow", "auto");
|
||||
|
||||
// Mitte: Canvas mit Konva.js
|
||||
VerticalLayout centerPanel = createCanvasPanel();
|
||||
centerPanel.setWidth("60%");
|
||||
centerPanel.setHeightFull();
|
||||
centerPanel.getStyle()
|
||||
.set("flex-grow", "1")
|
||||
.set("min-width", "0");
|
||||
centerPanel.getStyle().set("flex-grow", "1").set("min-width", "0");
|
||||
|
||||
// Rechte Seite: Eigenschaften
|
||||
propertiesPanel = createPropertiesPanel();
|
||||
propertiesPanel.setWidth("300px");
|
||||
propertiesPanel.setHeightFull();
|
||||
propertiesPanel.getStyle()
|
||||
.set("flex-shrink", "0")
|
||||
.set("min-width", "300px")
|
||||
.set("overflow", "auto");
|
||||
propertiesPanel.getStyle().set("flex-shrink", "0").set("min-width", "300px").set("overflow", "auto");
|
||||
|
||||
mainLayout.add(leftPanel, centerPanel, propertiesPanel);
|
||||
mainLayout.expand(centerPanel);
|
||||
@@ -96,9 +85,7 @@ public class InvoiceGeneratorView extends VerticalLayout {
|
||||
// Aktions-Buttons unter dem Canvas (fixe Höhe)
|
||||
HorizontalLayout actionLayout = createActionButtons();
|
||||
actionLayout.setHeight("60px");
|
||||
actionLayout.getStyle()
|
||||
.set("flex-shrink", "0")
|
||||
.set("padding", "0 var(--lumo-space-m)");
|
||||
actionLayout.getStyle().set("flex-shrink", "0").set("padding", "0 var(--lumo-space-m)");
|
||||
add(actionLayout);
|
||||
}
|
||||
|
||||
@@ -106,14 +93,9 @@ public class InvoiceGeneratorView extends VerticalLayout {
|
||||
protected void onAttach(AttachEvent attachEvent) {
|
||||
super.onAttach(attachEvent);
|
||||
// Register this view instance and initialize the canvas
|
||||
getElement().executeJs(
|
||||
"window.invoiceGeneratorView = this;" +
|
||||
"if (window.invoiceGenerator) {" +
|
||||
" console.log('Initializing invoice generator...');" +
|
||||
" window.invoiceGenerator.init();" +
|
||||
"} else {" +
|
||||
" console.error('Invoice generator not found');" +
|
||||
"}");
|
||||
getElement().executeJs("window.invoiceGeneratorView = this;" + "if (window.invoiceGenerator) {"
|
||||
+ " console.log('Initializing invoice generator...');" + " window.invoiceGenerator.init();"
|
||||
+ "} else {" + " console.error('Invoice generator not found');" + "}");
|
||||
}
|
||||
|
||||
private VerticalLayout createTemplatesPanel() {
|
||||
@@ -121,15 +103,11 @@ public class InvoiceGeneratorView extends VerticalLayout {
|
||||
panel.setPadding(true);
|
||||
panel.setSpacing(true);
|
||||
panel.setHeightFull();
|
||||
panel.getStyle()
|
||||
.set("background-color", "var(--lumo-contrast-5pct)")
|
||||
.set("border-radius", "var(--lumo-border-radius-m)")
|
||||
.set("overflow", "auto");
|
||||
panel.getStyle().set("background-color", "var(--lumo-contrast-5pct)")
|
||||
.set("border-radius", "var(--lumo-border-radius-m)").set("overflow", "auto");
|
||||
|
||||
Span header = new Span("Textbausteine");
|
||||
header.getStyle()
|
||||
.set("font-weight", "bold")
|
||||
.set("font-size", "var(--lumo-font-size-l)");
|
||||
header.getStyle().set("font-weight", "bold").set("font-size", "var(--lumo-font-size-l)");
|
||||
|
||||
// Draggable Templates
|
||||
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) {
|
||||
Div template = new Div();
|
||||
template.setText(label);
|
||||
template.getStyle()
|
||||
.set("padding", "var(--lumo-space-m)")
|
||||
.set("margin", "var(--lumo-space-xs) 0")
|
||||
.set("background-color", "var(--lumo-base-color)")
|
||||
.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");
|
||||
template.getStyle().set("padding", "var(--lumo-space-m)").set("margin", "var(--lumo-space-xs) 0")
|
||||
.set("background-color", "var(--lumo-base-color)").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 templateIcon = icon.create();
|
||||
@@ -173,15 +144,12 @@ public class InvoiceGeneratorView extends VerticalLayout {
|
||||
template.getElement().setAttribute("data-template-label", label);
|
||||
|
||||
// JavaScript Event Listener für Drag Start
|
||||
template.getElement().executeJs(
|
||||
"this.addEventListener('dragstart', function(e) {" +
|
||||
" e.dataTransfer.setData('template-type', this.getAttribute('data-template-type'));" +
|
||||
" e.dataTransfer.setData('template-label', this.getAttribute('data-template-label'));" +
|
||||
" this.style.opacity = '0.5';" +
|
||||
"});" +
|
||||
"this.addEventListener('dragend', function(e) {" +
|
||||
" this.style.opacity = '1';" +
|
||||
"});");
|
||||
template.getElement()
|
||||
.executeJs("this.addEventListener('dragstart', function(e) {"
|
||||
+ " e.dataTransfer.setData('template-type', this.getAttribute('data-template-type'));"
|
||||
+ " e.dataTransfer.setData('template-label', this.getAttribute('data-template-label'));"
|
||||
+ " this.style.opacity = '0.5';" + "});" + "this.addEventListener('dragend', function(e) {"
|
||||
+ " this.style.opacity = '1';" + "});");
|
||||
|
||||
return template;
|
||||
}
|
||||
@@ -197,37 +165,26 @@ public class InvoiceGeneratorView extends VerticalLayout {
|
||||
canvasContainer.setId("invoice-canvas-container");
|
||||
canvasContainer.setWidth("100%");
|
||||
canvasContainer.setHeight("100%");
|
||||
canvasContainer.getStyle()
|
||||
.set("background-color", "#e8e8e8")
|
||||
canvasContainer.getStyle().set("background-color", "#e8e8e8")
|
||||
.set("border", "2px dashed var(--lumo-contrast-30pct)")
|
||||
.set("border-radius", "var(--lumo-border-radius-m)")
|
||||
.set("position", "relative")
|
||||
.set("overflow", "hidden")
|
||||
.set("cursor", "default");
|
||||
.set("border-radius", "var(--lumo-border-radius-m)").set("position", "relative")
|
||||
.set("overflow", "hidden").set("cursor", "default");
|
||||
|
||||
// Drop Zone Event Listener
|
||||
canvasContainer.getElement().executeJs(
|
||||
"var container = this;" +
|
||||
"container.addEventListener('dragover', function(e) {" +
|
||||
" e.preventDefault();" +
|
||||
" e.dataTransfer.dropEffect = 'copy';" +
|
||||
" container.style.borderColor = 'var(--lumo-primary-color)';" +
|
||||
"});" +
|
||||
"container.addEventListener('dragleave', function(e) {" +
|
||||
" container.style.borderColor = 'var(--lumo-contrast-30pct)';" +
|
||||
"});" +
|
||||
"container.addEventListener('drop', function(e) {" +
|
||||
" e.preventDefault();" +
|
||||
" container.style.borderColor = 'var(--lumo-contrast-30pct)';" +
|
||||
" var templateType = e.dataTransfer.getData('template-type');" +
|
||||
" 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);" +
|
||||
" }" +
|
||||
"});");
|
||||
canvasContainer.getElement()
|
||||
.executeJs("var container = this;" + "container.addEventListener('dragover', function(e) {"
|
||||
+ " e.preventDefault();" + " e.dataTransfer.dropEffect = 'copy';"
|
||||
+ " container.style.borderColor = 'var(--lumo-primary-color)';" + "});"
|
||||
+ "container.addEventListener('dragleave', function(e) {"
|
||||
+ " container.style.borderColor = 'var(--lumo-contrast-30pct)';" + "});"
|
||||
+ "container.addEventListener('drop', function(e) {" + " e.preventDefault();"
|
||||
+ " container.style.borderColor = 'var(--lumo-contrast-30pct)';"
|
||||
+ " var templateType = e.dataTransfer.getData('template-type');"
|
||||
+ " 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.expand(canvasContainer);
|
||||
@@ -240,22 +197,17 @@ public class InvoiceGeneratorView extends VerticalLayout {
|
||||
panel.setPadding(true);
|
||||
panel.setSpacing(true);
|
||||
panel.setHeightFull();
|
||||
panel.getStyle()
|
||||
.set("background-color", "var(--lumo-contrast-5pct)")
|
||||
.set("border-radius", "var(--lumo-border-radius-m)")
|
||||
.set("overflow", "auto");
|
||||
panel.getStyle().set("background-color", "var(--lumo-contrast-5pct)")
|
||||
.set("border-radius", "var(--lumo-border-radius-m)").set("overflow", "auto");
|
||||
|
||||
Span header = new Span("Eigenschaften");
|
||||
header.getStyle()
|
||||
.set("font-weight", "bold")
|
||||
.set("font-size", "var(--lumo-font-size-l)");
|
||||
header.getStyle().set("font-weight", "bold").set("font-size", "var(--lumo-font-size-l)");
|
||||
|
||||
// Info-Text wenn kein Element ausgewählt
|
||||
selectedElementInfo = new Div();
|
||||
selectedElementInfo.setText("Klicken Sie auf ein Element im Canvas, um dessen Eigenschaften zu bearbeiten.");
|
||||
selectedElementInfo.getStyle()
|
||||
.set("color", "var(--lumo-secondary-text-color)")
|
||||
.set("font-size", "var(--lumo-font-size-s)");
|
||||
selectedElementInfo.getStyle().set("color", "var(--lumo-secondary-text-color)").set("font-size",
|
||||
"var(--lumo-font-size-s)");
|
||||
|
||||
panel.add(header, selectedElementInfo);
|
||||
|
||||
@@ -362,10 +314,7 @@ public class InvoiceGeneratorView extends VerticalLayout {
|
||||
Div pdfContainer = new Div();
|
||||
pdfContainer.setWidth("100%");
|
||||
pdfContainer.setHeight("100%");
|
||||
pdfContainer.getStyle()
|
||||
.set("display", "flex")
|
||||
.set("flex-direction", "column")
|
||||
.set("overflow", "hidden");
|
||||
pdfContainer.getStyle().set("display", "flex").set("flex-direction", "column").set("overflow", "hidden");
|
||||
|
||||
// Use an iframe with data URL for PDF display
|
||||
String base64Pdf = java.util.Base64.getEncoder().encodeToString(pdfBytes);
|
||||
@@ -375,9 +324,7 @@ public class InvoiceGeneratorView extends VerticalLayout {
|
||||
pdfFrame.setWidth("100%");
|
||||
pdfFrame.setHeight("100%");
|
||||
pdfFrame.getElement().setAttribute("src", dataUrl);
|
||||
pdfFrame.getStyle()
|
||||
.set("border", "none")
|
||||
.set("flex-grow", "1");
|
||||
pdfFrame.getStyle().set("border", "none").set("flex-grow", "1");
|
||||
|
||||
pdfContainer.add(pdfFrame);
|
||||
|
||||
@@ -387,11 +334,9 @@ public class InvoiceGeneratorView extends VerticalLayout {
|
||||
|
||||
// Download button
|
||||
Button downloadButton = new Button("Herunterladen", e -> {
|
||||
getElement().executeJs(
|
||||
"const link = document.createElement('a');" +
|
||||
"link.href = 'data:application/pdf;base64," + base64Pdf + "';" +
|
||||
"link.download = 'vorschau.pdf';" +
|
||||
"link.click();");
|
||||
getElement()
|
||||
.executeJs("const link = document.createElement('a');" + "link.href = 'data:application/pdf;base64,"
|
||||
+ base64Pdf + "';" + "link.download = 'vorschau.pdf';" + "link.click();");
|
||||
});
|
||||
downloadButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
|
||||
|
||||
@@ -412,9 +357,7 @@ public class InvoiceGeneratorView extends VerticalLayout {
|
||||
propertiesPanel.removeAll();
|
||||
|
||||
Span header = new Span("Eigenschaften");
|
||||
header.getStyle()
|
||||
.set("font-weight", "bold")
|
||||
.set("font-size", "var(--lumo-font-size-l)");
|
||||
header.getStyle().set("font-weight", "bold").set("font-size", "var(--lumo-font-size-l)");
|
||||
|
||||
// Element Typ Anzeige
|
||||
Span typeLabel = new Span("Typ: " + elementType);
|
||||
@@ -440,10 +383,9 @@ public class InvoiceGeneratorView extends VerticalLayout {
|
||||
String dataUrl = "data:" + mimeType + ";base64," + base64;
|
||||
|
||||
// An JavaScript übergeben
|
||||
getElement().executeJs(
|
||||
"if (window.invoiceGenerator) { window.invoiceGenerator.updateElementImage('"
|
||||
+ elementId + "', $0); }",
|
||||
dataUrl);
|
||||
getElement()
|
||||
.executeJs("if (window.invoiceGenerator) { window.invoiceGenerator.updateElementImage('"
|
||||
+ elementId + "', $0); }", dataUrl);
|
||||
showNotification("Bild erfolgreich hochgeladen");
|
||||
} catch (Exception ex) {
|
||||
showNotification("Fehler beim Hochladen: " + ex.getMessage());
|
||||
@@ -463,10 +405,8 @@ public class InvoiceGeneratorView extends VerticalLayout {
|
||||
textField.setValue(text != null ? text : "");
|
||||
textField.setWidthFull();
|
||||
textField.addValueChangeListener(e -> {
|
||||
getElement().executeJs(
|
||||
"if (window.invoiceGenerator) { window.invoiceGenerator.updateElementText('" + elementId
|
||||
+ "', $0); }",
|
||||
e.getValue());
|
||||
getElement().executeJs("if (window.invoiceGenerator) { window.invoiceGenerator.updateElementText('"
|
||||
+ elementId + "', $0); }", e.getValue());
|
||||
});
|
||||
propertiesPanel.add(textField);
|
||||
}
|
||||
@@ -478,10 +418,9 @@ public class InvoiceGeneratorView extends VerticalLayout {
|
||||
xField.addValueChangeListener(e -> {
|
||||
try {
|
||||
double newX = Double.parseDouble(e.getValue());
|
||||
getElement().executeJs(
|
||||
"if (window.invoiceGenerator) { window.invoiceGenerator.updateElementPosition('" + elementId
|
||||
+ "', $0, null); }",
|
||||
newX);
|
||||
getElement()
|
||||
.executeJs("if (window.invoiceGenerator) { window.invoiceGenerator.updateElementPosition('"
|
||||
+ elementId + "', $0, null); }", newX);
|
||||
} catch (NumberFormatException ignored) {
|
||||
}
|
||||
});
|
||||
@@ -494,10 +433,9 @@ public class InvoiceGeneratorView extends VerticalLayout {
|
||||
yField.addValueChangeListener(e -> {
|
||||
try {
|
||||
double newY = Double.parseDouble(e.getValue());
|
||||
getElement().executeJs(
|
||||
"if (window.invoiceGenerator) { window.invoiceGenerator.updateElementPosition('" + elementId
|
||||
+ "', null, $0); }",
|
||||
newY);
|
||||
getElement()
|
||||
.executeJs("if (window.invoiceGenerator) { window.invoiceGenerator.updateElementPosition('"
|
||||
+ elementId + "', null, $0); }", newY);
|
||||
} catch (NumberFormatException ignored) {
|
||||
}
|
||||
});
|
||||
@@ -536,17 +474,12 @@ public class InvoiceGeneratorView extends VerticalLayout {
|
||||
|
||||
// Farbvorschau-Box
|
||||
Div colorPreview = new Div();
|
||||
colorPreview.getStyle()
|
||||
.set("width", "40px")
|
||||
.set("height", "30px")
|
||||
.set("background-color", currentColor)
|
||||
colorPreview.getStyle().set("width", "40px").set("height", "30px").set("background-color", currentColor)
|
||||
.set("border", "1px solid var(--lumo-contrast-30pct)")
|
||||
.set("border-radius", "var(--lumo-border-radius-m)");
|
||||
|
||||
Span colorHexLabel = new Span(currentColor);
|
||||
colorHexLabel.getStyle()
|
||||
.set("font-family", "monospace")
|
||||
.set("font-size", "var(--lumo-font-size-s)");
|
||||
colorHexLabel.getStyle().set("font-family", "monospace").set("font-size", "var(--lumo-font-size-s)");
|
||||
|
||||
colorPreviewLayout.add(colorPreview, colorHexLabel);
|
||||
|
||||
@@ -562,10 +495,7 @@ public class InvoiceGeneratorView extends VerticalLayout {
|
||||
Input dialogColorPicker = new Input();
|
||||
dialogColorPicker.setType("color");
|
||||
dialogColorPicker.setValue(currentColor);
|
||||
dialogColorPicker.getStyle()
|
||||
.set("width", "100%")
|
||||
.set("height", "50px")
|
||||
.set("padding", "0");
|
||||
dialogColorPicker.getStyle().set("width", "100%").set("height", "50px").set("padding", "0");
|
||||
|
||||
// Hex-Eingabe im Dialog
|
||||
TextField dialogHexField = new TextField("Hex-Farbwert");
|
||||
@@ -602,10 +532,8 @@ public class InvoiceGeneratorView extends VerticalLayout {
|
||||
colorPreview.getStyle().set("background-color", newColor);
|
||||
colorHexLabel.setText(newColor);
|
||||
// Apply to element
|
||||
getElement().executeJs(
|
||||
"if (window.invoiceGenerator) { window.invoiceGenerator.updateElementColor('"
|
||||
+ elementId + "', $0); }",
|
||||
newColor);
|
||||
getElement().executeJs("if (window.invoiceGenerator) { window.invoiceGenerator.updateElementColor('"
|
||||
+ elementId + "', $0); }", newColor);
|
||||
colorDialog.close();
|
||||
showNotification("Farbe übernommen");
|
||||
});
|
||||
@@ -635,8 +563,7 @@ public class InvoiceGeneratorView extends VerticalLayout {
|
||||
deleteButton.setWidthFull();
|
||||
deleteButton.addClickListener(e -> {
|
||||
getElement().executeJs(
|
||||
"if (window.invoiceGenerator) { window.invoiceGenerator.deleteElement('" + elementId
|
||||
+ "'); }");
|
||||
"if (window.invoiceGenerator) { window.invoiceGenerator.deleteElement('" + elementId + "'); }");
|
||||
resetPropertiesPanel();
|
||||
});
|
||||
propertiesPanel.add(deleteButton);
|
||||
@@ -652,15 +579,13 @@ public class InvoiceGeneratorView extends VerticalLayout {
|
||||
propertiesPanel.removeAll();
|
||||
|
||||
Span header = new Span("Eigenschaften");
|
||||
header.getStyle()
|
||||
.set("font-weight", "bold")
|
||||
.set("font-size", "var(--lumo-font-size-l)");
|
||||
header.getStyle().set("font-weight", "bold").set("font-size", "var(--lumo-font-size-l)");
|
||||
|
||||
selectedElementInfo = new Div();
|
||||
selectedElementInfo.setText("Klicken Sie auf ein Element im Canvas, um dessen Eigenschaften zu bearbeiten.");
|
||||
selectedElementInfo.getStyle()
|
||||
.set("color", "var(--lumo-secondary-text-color)")
|
||||
.set("font-size", "var(--lumo-font-size-s)");
|
||||
selectedElementInfo
|
||||
.setText("Klicken Sie auf ein Element im Canvas, um dessen Eigenschaften zu bearbeiten.");
|
||||
selectedElementInfo.getStyle().set("color", "var(--lumo-secondary-text-color)").set("font-size",
|
||||
"var(--lumo-font-size-s)");
|
||||
|
||||
propertiesPanel.add(header, selectedElementInfo);
|
||||
}));
|
||||
|
||||
@@ -430,11 +430,8 @@ public class JobSummaryView extends Main implements HasUrlParameter<String> {
|
||||
|
||||
// Prüfe ob App-Tracking aktiviert ist und Job nicht erledigt/storniert
|
||||
LocationPosition appUserPosition = null;
|
||||
boolean showAppUserPosition = job.isDigitalProcessing()
|
||||
&& job.getStatus() != JobStatus.COMPLETED
|
||||
&& job.getStatus() != JobStatus.CANCELLED
|
||||
&& job.getAppUser() != null
|
||||
&& !job.getAppUser().isBlank();
|
||||
boolean showAppUserPosition = job.isDigitalProcessing() && job.getStatus() != JobStatus.COMPLETED
|
||||
&& job.getStatus() != JobStatus.CANCELLED && job.getAppUser() != null && !job.getAppUser().isBlank();
|
||||
|
||||
if (showAppUserPosition) {
|
||||
appUserPosition = locationService.getLatestPosition(job.getAppUser());
|
||||
@@ -457,7 +454,8 @@ public class JobSummaryView extends Main implements HasUrlParameter<String> {
|
||||
|
||||
// Position für JavaScript vorbereiten
|
||||
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 boolean shouldUpdate = showAppUserPosition;
|
||||
|
||||
@@ -466,7 +464,8 @@ public class JobSummaryView extends Main implements HasUrlParameter<String> {
|
||||
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();
|
||||
// Explizit mit Punkt als Dezimaltrennzeichen formatieren
|
||||
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();
|
||||
}
|
||||
})();
|
||||
""".formatted(
|
||||
escapeJs(origin),
|
||||
escapeJs(destination),
|
||||
escapeJs(apiKey),
|
||||
lat,
|
||||
lng,
|
||||
Boolean.toString(hasPosition),
|
||||
escapeJs(appUserId),
|
||||
Boolean.toString(shouldUpdate)
|
||||
);
|
||||
"""
|
||||
.formatted(escapeJs(origin), escapeJs(destination), escapeJs(apiKey), lat, lng,
|
||||
Boolean.toString(hasPosition), escapeJs(appUserId), Boolean.toString(shouldUpdate));
|
||||
}
|
||||
|
||||
// Hilfsfunktion zum einfachen Escapen von JS-Zeichen in Strings
|
||||
|
||||
@@ -341,17 +341,13 @@ public class MessagesView extends Main {
|
||||
}
|
||||
|
||||
private void requestBrowserPermissions(UI ui) {
|
||||
ui.getPage().executeJs(
|
||||
"if ('Notification' in window && Notification.permission === 'default') {"
|
||||
+ " Notification.requestPermission();"
|
||||
+ "}"
|
||||
+ "if (!window._votianAudioCtx) {"
|
||||
ui.getPage()
|
||||
.executeJs("if ('Notification' in window && Notification.permission === 'default') {"
|
||||
+ " Notification.requestPermission();" + "}" + "if (!window._votianAudioCtx) {"
|
||||
+ " window._votianAudioCtx = new (window.AudioContext || window.webkitAudioContext)();"
|
||||
+ " document.addEventListener('click', function _resumeAudio() {"
|
||||
+ " window._votianAudioCtx.resume();"
|
||||
+ " document.removeEventListener('click', _resumeAudio);"
|
||||
+ " }, { once: true });"
|
||||
+ "}");
|
||||
+ " document.removeEventListener('click', _resumeAudio);" + " }, { once: true });" + "}");
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -377,27 +373,18 @@ public class MessagesView extends Main {
|
||||
String preview = resolvePreview(message);
|
||||
|
||||
// Play notification sound
|
||||
ui.getPage().executeJs(
|
||||
"try {"
|
||||
+ " const ctx = new (window.AudioContext || window.webkitAudioContext)();"
|
||||
+ " ctx.resume().then(() => {"
|
||||
+ " const osc = ctx.createOscillator();"
|
||||
+ " const gain = ctx.createGain();"
|
||||
+ " osc.connect(gain);"
|
||||
+ " gain.connect(ctx.destination);"
|
||||
+ " osc.frequency.value = 800;"
|
||||
+ " osc.type = 'sine';"
|
||||
+ " gain.gain.setValueAtTime(0.3, ctx.currentTime);"
|
||||
ui.getPage()
|
||||
.executeJs("try {" + " const ctx = new (window.AudioContext || window.webkitAudioContext)();"
|
||||
+ " ctx.resume().then(() => {" + " const osc = ctx.createOscillator();"
|
||||
+ " const gain = ctx.createGain();" + " 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);"
|
||||
+ " osc.start(ctx.currentTime);"
|
||||
+ " osc.stop(ctx.currentTime + 0.5);"
|
||||
+ " });"
|
||||
+ " osc.start(ctx.currentTime);" + " osc.stop(ctx.currentTime + 0.5);" + " });"
|
||||
+ "} catch(e) { console.warn('Notification sound failed:', e); }");
|
||||
|
||||
// Show notification
|
||||
Notification notification = Notification.show(
|
||||
"Neue Nachricht von " + senderName + ": " + preview,
|
||||
4000,
|
||||
Notification notification = Notification.show("Neue Nachricht von " + senderName + ": " + preview, 4000,
|
||||
Notification.Position.TOP_END);
|
||||
notification.addThemeVariants(NotificationVariant.LUMO_PRIMARY);
|
||||
|
||||
@@ -410,12 +397,8 @@ public class MessagesView extends Main {
|
||||
return "Unbekannt";
|
||||
}
|
||||
List<AppUser> appUsers = cachedAppUsers != null ? cachedAppUsers : List.of();
|
||||
return appUsers.stream()
|
||||
.filter(user -> clientId.equals(user.getIdAsString())
|
||||
|| clientId.equals(user.getEmail())
|
||||
|| clientId.equals(user.getAppCode()))
|
||||
.findFirst()
|
||||
.map(this::buildClientName)
|
||||
.orElse(clientId);
|
||||
return appUsers.stream().filter(user -> clientId.equals(user.getIdAsString())
|
||||
|| clientId.equals(user.getEmail()) || clientId.equals(user.getAppCode())).findFirst()
|
||||
.map(this::buildClientName).orElse(clientId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,7 +107,8 @@ public class ShowJobsView extends VerticalLayout {
|
||||
grid.addColumn(job -> extractCompanyName(job.getCustomerSelection())).setHeader("Auftraggeber")
|
||||
.setAutoWidth(true).setFlexGrow(1).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);
|
||||
|
||||
// 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();
|
||||
}).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)
|
||||
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));
|
||||
deleteBtn.addThemeVariants(ButtonVariant.LUMO_TERTIARY, ButtonVariant.LUMO_ERROR);
|
||||
deleteBtn.setTooltipText("Auftrag löschen");
|
||||
@@ -187,7 +207,8 @@ public class ShowJobsView extends VerticalLayout {
|
||||
private void showDeleteJobDialog(Job job) {
|
||||
ConfirmDialog dialog = new ConfirmDialog();
|
||||
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.setCancelText("Abbrechen");
|
||||
dialog.setConfirmText("Löschen");
|
||||
@@ -224,11 +245,8 @@ public class ShowJobsView extends VerticalLayout {
|
||||
return;
|
||||
}
|
||||
|
||||
Map<String, Object> payload = Map.of(
|
||||
"type", "job_deleted",
|
||||
"jobId", job.getId().toHexString(),
|
||||
"jobNumber", job.getJobNumber() != null ? job.getJobNumber() : "",
|
||||
"deletedAt", LocalDateTime.now().toString());
|
||||
Map<String, Object> payload = Map.of("type", "job_deleted", "jobId", job.getId().toHexString(), "jobNumber",
|
||||
job.getJobNumber() != null ? job.getJobNumber() : "", "deletedAt", LocalDateTime.now().toString());
|
||||
|
||||
log.info("[JOB] Sending job_deleted to {}: {}", appUserId, payload);
|
||||
messagingPublisher.publishAsJson(appUserId, "job_deleted", payload);
|
||||
|
||||
@@ -225,7 +225,7 @@ public class StartView extends VerticalLayout implements BeforeEnterObserver {
|
||||
featuresGrid.setWidthFull();
|
||||
featuresGrid.setSpacing(true);
|
||||
featuresGrid.setJustifyContentMode(FlexComponent.JustifyContentMode.CENTER);
|
||||
featuresGrid.setDefaultVerticalComponentAlignment(FlexComponent.Alignment.START);
|
||||
featuresGrid.setDefaultVerticalComponentAlignment(FlexComponent.Alignment.STRETCH);
|
||||
|
||||
// Feature Cards
|
||||
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("box-shadow", "var(--lumo-box-shadow-xs)");
|
||||
card.setWidth("300px");
|
||||
card.setHeightFull();
|
||||
|
||||
Icon icon = iconType.create();
|
||||
icon.setSize("48px");
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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);
|
||||
}
|
||||
@@ -27,8 +27,7 @@ public class SecurityConfig extends VaadinWebSecurity {
|
||||
new AntPathRequestMatcher("/frontend/**"), new AntPathRequestMatcher("/webjars/**"),
|
||||
new AntPathRequestMatcher("/h2-console/**"),
|
||||
new AntPathRequestMatcher("/frontend-es5/**", "/frontend-es6/**"),
|
||||
new AntPathRequestMatcher("/mcp/**"),
|
||||
new AntPathRequestMatcher("/ws/**"))
|
||||
new AntPathRequestMatcher("/mcp/**"), new AntPathRequestMatcher("/ws/**"))
|
||||
.permitAll());
|
||||
|
||||
// Standard-CSRF-Konfiguration
|
||||
|
||||
@@ -244,14 +244,15 @@ public class CustomerInvoiceService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a PDF preview from canvas template data.
|
||||
* Creates an HTML representation of the canvas elements and converts it to PDF.
|
||||
* Generate a PDF preview from canvas template data. Creates an HTML
|
||||
* representation of the canvas elements and converts it to PDF.
|
||||
*/
|
||||
public byte[] generatePdfFromCanvasTemplate(String jsonTemplateData) throws Exception {
|
||||
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
|
||||
com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper();
|
||||
com.fasterxml.jackson.databind.JsonNode rootNode = mapper.readTree(jsonTemplateData);
|
||||
@@ -264,7 +265,8 @@ public class CustomerInvoiceService {
|
||||
htmlBuilder.append("<meta charset='UTF-8'>");
|
||||
htmlBuilder.append("<style>");
|
||||
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(".text { white-space: nowrap; overflow: visible; }");
|
||||
htmlBuilder.append(".line { border-top: 1px solid #333; }");
|
||||
@@ -316,7 +318,8 @@ public class CustomerInvoiceService {
|
||||
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;
|
||||
if (element.has("xPercent")) {
|
||||
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("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("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("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(";");
|
||||
if (!fontStyle.isEmpty()) {
|
||||
if (fontStyle.contains("bold")) htmlBuilder.append("font-weight:bold;");
|
||||
if (fontStyle.contains("bold"))
|
||||
htmlBuilder.append("font-weight:bold;");
|
||||
}
|
||||
// Vertically center content
|
||||
htmlBuilder.append("display:flex;align-items:center;");
|
||||
@@ -393,17 +399,15 @@ public class CustomerInvoiceService {
|
||||
|
||||
// Escape HTML special characters in text AFTER variable replacement
|
||||
if (text != null) {
|
||||
text = text.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("\"", """)
|
||||
text = text.replace("&", "&").replace("<", "<").replace(">", ">").replace("\"", """)
|
||||
.replace("'", "'");
|
||||
} else {
|
||||
text = "";
|
||||
}
|
||||
|
||||
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)) {
|
||||
if (element.has("imageData") && !element.get("imageData").asText().isEmpty()) {
|
||||
String imageData = element.get("imageData").asText();
|
||||
@@ -412,11 +416,14 @@ public class CustomerInvoiceService {
|
||||
imageData = "data:image/png;base64," + imageData;
|
||||
}
|
||||
// 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("<img src=\"").append(imageData.replace("\"", "%22")).append("\" style='max-width:100%;max-height:100%;object-fit:contain;' alt='Bild' />");
|
||||
htmlBuilder.append(
|
||||
"<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>");
|
||||
} 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 {
|
||||
// Wrap text in a span to prevent flexbox issues
|
||||
|
||||
@@ -49,8 +49,8 @@ public class LocationService {
|
||||
Double heading = extractDouble(payload.get("heading"));
|
||||
Instant timestamp = extractInstant(payload.get("timestamp"));
|
||||
|
||||
LocationPosition position = new LocationPosition(
|
||||
appUserId, latitude, longitude, accuracy, altitude, speed, heading, timestamp);
|
||||
LocationPosition position = new LocationPosition(appUserId, latitude, longitude, accuracy, altitude, speed,
|
||||
heading, timestamp);
|
||||
|
||||
locationPositionRepository.save(position);
|
||||
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
|
||||
*/
|
||||
public LocationPosition getLatestPosition(String appUserId) {
|
||||
List<LocationPosition> positions = locationPositionRepository.findTop1ByAppUserIdOrderByTimestampDesc(appUserId);
|
||||
List<LocationPosition> positions = locationPositionRepository
|
||||
.findTop1ByAppUserIdOrderByTimestampDesc(appUserId);
|
||||
return positions.isEmpty() ? null : positions.get(0);
|
||||
}
|
||||
|
||||
@@ -87,9 +88,9 @@ public class LocationService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup old positions. Runs every 5 minutes.
|
||||
* Note: Positions also have a TTL index that auto-deletes after 60 minutes,
|
||||
* but this scheduled cleanup ensures immediate removal and logging.
|
||||
* Cleanup old positions. Runs every 5 minutes. Note: Positions also have a TTL
|
||||
* index that auto-deletes after 60 minutes, but this scheduled cleanup ensures
|
||||
* immediate removal and logging.
|
||||
*/
|
||||
@Scheduled(fixedRate = 300000) // 5 minutes
|
||||
public void cleanupOldPositions() {
|
||||
|
||||
@@ -115,8 +115,8 @@ public class MessageService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish message to topic for the receiver.
|
||||
* Only sends if client is connected, otherwise keeps NOTSEND status.
|
||||
* Publish message to topic for the receiver. Only sends if client is connected,
|
||||
* otherwise keeps NOTSEND status.
|
||||
*/
|
||||
private void publishMessage(Message message, String receiver) {
|
||||
try {
|
||||
@@ -132,18 +132,16 @@ public class MessageService {
|
||||
byte[] data = objectMapper.writeValueAsString(payload).getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
// Use WebSocketService directly to get CompletableFuture for delivery tracking
|
||||
webSocketService.sendToClient(receiver, "message", data)
|
||||
.thenRun(() -> {
|
||||
webSocketService.sendToClient(receiver, "message", data).thenRun(() -> {
|
||||
// Success: mark as sent
|
||||
message.markAsSent();
|
||||
messageRepository.save(message);
|
||||
log.debug("[Messaging] Message {} delivered to client {}, marked as SEND",
|
||||
message.getIdAsString(), receiver);
|
||||
})
|
||||
.exceptionally(ex -> {
|
||||
log.debug("[Messaging] Message {} delivered to client {}, marked as SEND", message.getIdAsString(),
|
||||
receiver);
|
||||
}).exceptionally(ex -> {
|
||||
// Failed to deliver: keep NOTSEND status
|
||||
log.debug("[Messaging] Failed to deliver message {} to client {}: {}",
|
||||
message.getIdAsString(), receiver, ex.getMessage());
|
||||
log.debug("[Messaging] Failed to deliver message {} to client {}: {}", message.getIdAsString(),
|
||||
receiver, ex.getMessage());
|
||||
return null;
|
||||
});
|
||||
|
||||
@@ -160,8 +158,8 @@ public class MessageService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Send pending messages to a client that just connected.
|
||||
* Called after successful authentication.
|
||||
* Send pending messages to a client that just connected. Called after
|
||||
* successful authentication.
|
||||
*
|
||||
* @param receiver
|
||||
* AppUser ID (clientId)
|
||||
@@ -184,14 +182,12 @@ public class MessageService {
|
||||
ChatMessageOutboundPayload payload = ChatMessageOutboundPayload.fromMessage(message);
|
||||
byte[] data = objectMapper.writeValueAsString(payload).getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
webSocketService.sendToClient(receiver, "message", data)
|
||||
.thenRun(() -> {
|
||||
webSocketService.sendToClient(receiver, "message", data).thenRun(() -> {
|
||||
message.markAsSent();
|
||||
messageRepository.save(message);
|
||||
})
|
||||
.exceptionally(ex -> {
|
||||
log.error("[Messaging] Failed to send pending message {}: {}",
|
||||
message.getIdAsString(), ex.getMessage());
|
||||
}).exceptionally(ex -> {
|
||||
log.error("[Messaging] Failed to send pending message {}: {}", message.getIdAsString(),
|
||||
ex.getMessage());
|
||||
return null;
|
||||
});
|
||||
sentCount++;
|
||||
|
||||
Reference in New Issue
Block a user