Erweiterungen
This commit is contained in:
@@ -17,3 +17,6 @@ History currently uses brief German titles; shift to imperative, scoped summarie
|
|||||||
|
|
||||||
## Security & Configuration Tips
|
## Security & Configuration Tips
|
||||||
External service credentials for MongoDB, SMTP, and MQTT belong in environment variables or a developer-specific `application-local.properties` kept out of version control. Document default ports and topics when touching `MqttConfig` so ops can replicate environments. For two-factor flows, keep shared secrets in secure storage and avoid logging codes during development.
|
External service credentials for MongoDB, SMTP, and MQTT belong in environment variables or a developer-specific `application-local.properties` kept out of version control. Document default ports and topics when touching `MqttConfig` so ops can replicate environments. For two-factor flows, keep shared secrets in secure storage and avoid logging codes during development.
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
Never start the application; leave that to the user.
|
||||||
@@ -27,7 +27,8 @@ public class LocationApiController {
|
|||||||
/**
|
/**
|
||||||
* Gibt die aktuelle Position eines App-Nutzers zurück.
|
* Gibt die aktuelle Position eines App-Nutzers zurück.
|
||||||
*
|
*
|
||||||
* @param appUserId die ID des App-Nutzers
|
* @param appUserId
|
||||||
|
* die ID des App-Nutzers
|
||||||
* @return die aktuelle Position oder 404 wenn keine vorhanden
|
* @return die aktuelle Position oder 404 wenn keine vorhanden
|
||||||
*/
|
*/
|
||||||
@GetMapping("/{appUserId}")
|
@GetMapping("/{appUserId}")
|
||||||
|
|||||||
@@ -36,8 +36,8 @@ import java.util.Optional;
|
|||||||
import org.bson.types.ObjectId;
|
import org.bson.types.ObjectId;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Message controller for handling real-time communication with apps.
|
* Message controller for handling real-time communication with apps. Provides
|
||||||
* Provides endpoints for sending and receiving messages via WebSocket.
|
* endpoints for sending and receiving messages via WebSocket.
|
||||||
*/
|
*/
|
||||||
@Component
|
@Component
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@@ -409,9 +409,9 @@ public class MessageController {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle incoming message from a client via WebSocket. Client sends to
|
* Handle incoming message from a client via WebSocket. Client sends to
|
||||||
* /server/message with payload: { "content": "message payload",
|
* /server/message with payload: { "content": "message payload", "contentType":
|
||||||
* "contentType": "TEXT|IMAGE", "jobId": "optional job id", "jobNumber":
|
* "TEXT|IMAGE", "jobId": "optional job id", "jobNumber": "optional job number"
|
||||||
* "optional job number" }
|
* }
|
||||||
*
|
*
|
||||||
* The appUserId is determined from the authenticated WebSocket session.
|
* The appUserId is determined from the authenticated WebSocket session.
|
||||||
*/
|
*/
|
||||||
|
|||||||
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 {
|
public class AppLoginResponse {
|
||||||
private boolean success;
|
private boolean success;
|
||||||
private String message;
|
private String message;
|
||||||
/** Only populated on success, for internal server-side routing. Not sent to client. */
|
/**
|
||||||
|
* Only populated on success, for internal server-side routing. Not sent to
|
||||||
|
* client.
|
||||||
|
*/
|
||||||
private String appUserId;
|
private String appUserId;
|
||||||
}
|
}
|
||||||
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
|
// Send success response to the now-authenticated session
|
||||||
// locationTrackingEnabled: true = client should send position updates
|
// locationTrackingEnabled: true = client should send position updates
|
||||||
Map<String, Object> authResponse = Map.of(
|
Map<String, Object> authResponse = Map.of("success", true, "message", response.getMessage(),
|
||||||
"success", true,
|
|
||||||
"message", response.getMessage(),
|
|
||||||
"locationTrackingEnabled", true);
|
"locationTrackingEnabled", true);
|
||||||
byte[] responseBytes = objectMapper.writeValueAsBytes(authResponse);
|
byte[] responseBytes = objectMapper.writeValueAsBytes(authResponse);
|
||||||
webSocketService.sendToClient(appUserId, "auth", responseBytes);
|
webSocketService.sendToClient(appUserId, "auth", responseBytes);
|
||||||
|
|||||||
@@ -255,8 +255,8 @@ public class WebSocketService extends TextWebSocketHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register a pending session as authenticated under the given appUserId.
|
* Register a pending session as authenticated under the given appUserId. Called
|
||||||
* Called by MessagingConfig after successful login.
|
* by MessagingConfig after successful login.
|
||||||
*/
|
*/
|
||||||
public void registerAuthenticatedSession(String wsSessionId, String appUserId) {
|
public void registerAuthenticatedSession(String wsSessionId, String appUserId) {
|
||||||
PendingSession pending = pendingSessions.get(wsSessionId);
|
PendingSession pending = pendingSessions.get(wsSessionId);
|
||||||
|
|||||||
@@ -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;
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stores invoice template data for a user.
|
* Stores invoice template data for a user. Contains the JSON representation of
|
||||||
* Contains the JSON representation of the canvas elements.
|
* the canvas elements.
|
||||||
*/
|
*/
|
||||||
@Document(collection = "invoice_templates")
|
@Document(collection = "invoice_templates")
|
||||||
@Data
|
@Data
|
||||||
|
|||||||
@@ -133,6 +133,14 @@ public class Job {
|
|||||||
@Field("price")
|
@Field("price")
|
||||||
private BigDecimal price;
|
private BigDecimal price;
|
||||||
|
|
||||||
|
// Gefahrene Kilometer für Rechnungsstellung
|
||||||
|
@Field("kilometers_driven")
|
||||||
|
private Integer kilometersDriven;
|
||||||
|
|
||||||
|
// Arbeitszeit in 15-Minuten-Einheiten für Rechnungsstellung
|
||||||
|
@Field("time_in_15min_units")
|
||||||
|
private Integer timeIn15MinUnits;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the ObjectId as string for JSON serialization. This ensures that the
|
* Returns the ObjectId as string for JSON serialization. This ensures that the
|
||||||
* job id is returned as a string when jobs are retrieved via API.
|
* job id is returned as a string when jobs are retrieved via API.
|
||||||
|
|||||||
@@ -77,8 +77,8 @@ public class LocationPosition {
|
|||||||
@Indexed(expireAfter = "3600s") // TTL index: auto-delete after 60 minutes
|
@Indexed(expireAfter = "3600s") // TTL index: auto-delete after 60 minutes
|
||||||
private Instant receivedAt;
|
private Instant receivedAt;
|
||||||
|
|
||||||
public LocationPosition(String appUserId, Double latitude, Double longitude, Double accuracy,
|
public LocationPosition(String appUserId, Double latitude, Double longitude, Double accuracy, Double altitude,
|
||||||
Double altitude, Double speed, Double heading, Instant timestamp) {
|
Double speed, Double heading, Instant timestamp) {
|
||||||
this.appUserId = appUserId;
|
this.appUserId = appUserId;
|
||||||
this.latitude = latitude;
|
this.latitude = latitude;
|
||||||
this.longitude = longitude;
|
this.longitude = longitude;
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
package de.assecutor.votianlt.model;
|
package de.assecutor.votianlt.model;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delivery status for messages sent to clients.
|
* Delivery status for messages sent to clients. Tracks whether a message was
|
||||||
* Tracks whether a message was successfully delivered via WebSocket.
|
* successfully delivered via WebSocket.
|
||||||
*/
|
*/
|
||||||
public enum MessageDeliveryStatus {
|
public enum MessageDeliveryStatus {
|
||||||
NOTSEND("Nicht gesendet"),
|
NOTSEND("Nicht gesendet"), SEND("Gesendet");
|
||||||
SEND("Gesendet");
|
|
||||||
|
|
||||||
private final String displayName;
|
private final String displayName;
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
* Sendet den neu erstellten Job per WebSocket an den zugewiesenen Client, falls
|
||||||
* online ist.
|
* dieser online ist.
|
||||||
*/
|
*/
|
||||||
private void notifyClientJobCreated(Job job) {
|
private void notifyClientJobCreated(Job job) {
|
||||||
if (!job.isDigitalProcessing()) {
|
if (!job.isDigitalProcessing()) {
|
||||||
|
|||||||
@@ -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.AddJobService;
|
||||||
import de.assecutor.votianlt.pages.service.CustomerService;
|
import de.assecutor.votianlt.pages.service.CustomerService;
|
||||||
import de.assecutor.votianlt.pages.service.AddCustomerService;
|
import de.assecutor.votianlt.pages.service.AddCustomerService;
|
||||||
|
import de.assecutor.votianlt.pages.service.AddressValidationService;
|
||||||
import de.assecutor.votianlt.model.Customer;
|
import de.assecutor.votianlt.model.Customer;
|
||||||
import de.assecutor.votianlt.pages.service.AppUserService;
|
import de.assecutor.votianlt.pages.service.AppUserService;
|
||||||
import de.assecutor.votianlt.model.AppUser;
|
import de.assecutor.votianlt.model.AppUser;
|
||||||
@@ -51,10 +52,17 @@ import de.assecutor.votianlt.pages.service.TaskTemplateService;
|
|||||||
import de.assecutor.votianlt.model.TaskTemplate;
|
import de.assecutor.votianlt.model.TaskTemplate;
|
||||||
import de.assecutor.votianlt.model.User;
|
import de.assecutor.votianlt.model.User;
|
||||||
import de.assecutor.votianlt.security.SecurityService;
|
import de.assecutor.votianlt.security.SecurityService;
|
||||||
|
import de.assecutor.votianlt.model.Service;
|
||||||
|
import de.assecutor.votianlt.repository.ServiceRepository;
|
||||||
|
import com.vaadin.flow.component.grid.Grid;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.math.RoundingMode;
|
||||||
|
|
||||||
import jakarta.annotation.security.RolesAllowed;
|
import jakarta.annotation.security.RolesAllowed;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import de.assecutor.votianlt.model.CargoItem;
|
import de.assecutor.votianlt.model.CargoItem;
|
||||||
|
import de.assecutor.votianlt.model.AddressValidationResult;
|
||||||
|
import de.assecutor.votianlt.model.RouteCalculationResult;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
@@ -73,6 +81,8 @@ public class AddJobView extends Main {
|
|||||||
private final AppUserService appUserService;
|
private final AppUserService appUserService;
|
||||||
private final TaskTemplateService taskTemplateService;
|
private final TaskTemplateService taskTemplateService;
|
||||||
private final SecurityService securityService;
|
private final SecurityService securityService;
|
||||||
|
private final ServiceRepository serviceRepository;
|
||||||
|
private final AddressValidationService addressValidationService;
|
||||||
|
|
||||||
// Customer selection
|
// Customer selection
|
||||||
private ComboBox<String> customerSelection;
|
private ComboBox<String> customerSelection;
|
||||||
@@ -108,8 +118,12 @@ public class AddJobView extends Main {
|
|||||||
private Checkbox digitalProcessing;
|
private Checkbox digitalProcessing;
|
||||||
private ComboBox<AppUser> appUser;
|
private ComboBox<AppUser> appUser;
|
||||||
|
|
||||||
// Price field
|
// Services for the job
|
||||||
private TextField price;
|
private Grid<Service> servicesGrid;
|
||||||
|
private final List<Service> selectedServices = new ArrayList<>();
|
||||||
|
private Span netTotalLabel;
|
||||||
|
private Span vatTotalLabel;
|
||||||
|
private Span grossTotalLabel;
|
||||||
|
|
||||||
// Date picker fields for appointments
|
// Date picker fields for appointments
|
||||||
private DatePicker pickupDate;
|
private DatePicker pickupDate;
|
||||||
@@ -152,19 +166,36 @@ public class AddJobView extends Main {
|
|||||||
// Available app users for the current user
|
// Available app users for the current user
|
||||||
private List<AppUser> availableAppUsers;
|
private List<AppUser> availableAppUsers;
|
||||||
|
|
||||||
|
// Adressvalidierung
|
||||||
|
private final Map<String, AddressValidationResult> addressValidationResults = new HashMap<>();
|
||||||
|
private RouteCalculationResult routeCalculationResult;
|
||||||
|
private String lastPickupStreet = "";
|
||||||
|
private String lastPickupHouseNumber = "";
|
||||||
|
private String lastPickupZip = "";
|
||||||
|
private String lastPickupCity = "";
|
||||||
|
private String lastDeliveryStreet = "";
|
||||||
|
private String lastDeliveryHouseNumber = "";
|
||||||
|
private String lastDeliveryZip = "";
|
||||||
|
private String lastDeliveryCity = "";
|
||||||
|
private TabSheet tabSheet;
|
||||||
|
|
||||||
public AddJobView(AddJobService addJobService, AddCustomerService addCustomerService,
|
public AddJobView(AddJobService addJobService, AddCustomerService addCustomerService,
|
||||||
CustomerService customerService, AppUserService appUserService, TaskTemplateService taskTemplateService,
|
CustomerService customerService, AppUserService appUserService, TaskTemplateService taskTemplateService,
|
||||||
SecurityService securityService) {
|
SecurityService securityService, ServiceRepository serviceRepository,
|
||||||
|
AddressValidationService addressValidationService) {
|
||||||
this.addJobService = addJobService;
|
this.addJobService = addJobService;
|
||||||
this.addCustomerService = addCustomerService;
|
this.addCustomerService = addCustomerService;
|
||||||
this.customerService = customerService;
|
this.customerService = customerService;
|
||||||
this.appUserService = appUserService;
|
this.appUserService = appUserService;
|
||||||
this.taskTemplateService = taskTemplateService;
|
this.taskTemplateService = taskTemplateService;
|
||||||
this.securityService = securityService;
|
this.securityService = securityService;
|
||||||
|
this.serviceRepository = serviceRepository;
|
||||||
|
this.addressValidationService = addressValidationService;
|
||||||
initializeComponents();
|
initializeComponents();
|
||||||
setupLayout();
|
setupLayout();
|
||||||
setupValidation();
|
setupValidation();
|
||||||
loadDraftIfExists();
|
loadDraftIfExists();
|
||||||
|
loadMandatoryServices();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void initializeComponents() {
|
private void initializeComponents() {
|
||||||
@@ -357,20 +388,7 @@ public class AddJobView extends Main {
|
|||||||
user -> user.getVorname() + " " + user.getNachname() + " (" + user.getEmail() + ")");
|
user -> user.getVorname() + " " + user.getNachname() + " (" + user.getEmail() + ")");
|
||||||
appUser.setPlaceholder("App-Nutzer auswählen...");
|
appUser.setPlaceholder("App-Nutzer auswählen...");
|
||||||
|
|
||||||
// Price field
|
// Services grid will be initialized in createPriceAndSubmitTab()
|
||||||
price = new TextField("Preis");
|
|
||||||
price.setPlaceholder("Betrag eingeben");
|
|
||||||
price.setRequiredIndicatorVisible(true);
|
|
||||||
|
|
||||||
// Erzwinge Komma als Dezimaltrennzeichen: ersetze Punkt beim Tippen
|
|
||||||
price.addValueChangeListener(e -> {
|
|
||||||
String v = e.getValue();
|
|
||||||
if (v != null && v.contains(".")) {
|
|
||||||
String replaced = v.replace('.', ',');
|
|
||||||
if (!replaced.equals(v))
|
|
||||||
price.setValue(replaced);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
// Date picker fields for appointments
|
// Date picker fields for appointments
|
||||||
pickupDate = new DatePicker("Datum");
|
pickupDate = new DatePicker("Datum");
|
||||||
pickupDate.setRequiredIndicatorVisible(true);
|
pickupDate.setRequiredIndicatorVisible(true);
|
||||||
@@ -412,7 +430,7 @@ public class AddJobView extends Main {
|
|||||||
|
|
||||||
// Create TabSheet for organizing the form
|
// Create TabSheet for organizing the form
|
||||||
// TabSheet and Tab references for dynamic label updates
|
// TabSheet and Tab references for dynamic label updates
|
||||||
TabSheet tabSheet = new TabSheet();
|
tabSheet = new TabSheet();
|
||||||
tabSheet.setSizeFull();
|
tabSheet.setSizeFull();
|
||||||
|
|
||||||
// Tab 1: Customer & Addresses
|
// Tab 1: Customer & Addresses
|
||||||
@@ -437,7 +455,10 @@ public class AddJobView extends Main {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Tab 5: Price & Submit
|
// Tab 5: Price & Submit
|
||||||
priceTab = tabSheet.add("Preis & Abschluss", createPriceAndSubmitTab());
|
priceTab = tabSheet.add("Leistungen und Preis", createPriceAndSubmitTab());
|
||||||
|
|
||||||
|
// Tab-Wechsel-Listener für Adressvalidierung
|
||||||
|
tabSheet.addSelectedChangeListener(this::onTabChange);
|
||||||
|
|
||||||
add(tabSheet);
|
add(tabSheet);
|
||||||
|
|
||||||
@@ -573,24 +594,200 @@ public class AddJobView extends Main {
|
|||||||
tabContent.setSizeFull();
|
tabContent.setSizeFull();
|
||||||
tabContent.setPadding(true);
|
tabContent.setPadding(true);
|
||||||
tabContent.setSpacing(true);
|
tabContent.setSpacing(true);
|
||||||
tabContent.setDefaultHorizontalComponentAlignment(FlexComponent.Alignment.CENTER);
|
tabContent.setDefaultHorizontalComponentAlignment(FlexComponent.Alignment.STRETCH);
|
||||||
|
|
||||||
// Container with fixed width to center content
|
// Container with full width like other tabs
|
||||||
VerticalLayout content = new VerticalLayout();
|
VerticalLayout content = new VerticalLayout();
|
||||||
content.setPadding(false);
|
content.setPadding(false);
|
||||||
content.setSpacing(true);
|
content.setSpacing(true);
|
||||||
content.setWidth("720px");
|
content.setWidthFull();
|
||||||
content.setDefaultHorizontalComponentAlignment(FlexComponent.Alignment.STRETCH);
|
content.setDefaultHorizontalComponentAlignment(FlexComponent.Alignment.STRETCH);
|
||||||
|
|
||||||
// Preis (netto) - moved from createTasksAndNotesSection
|
// Title
|
||||||
H3 priceTitle = new H3("Preis (netto)");
|
H3 servicesTitle = new H3("Leistungen");
|
||||||
priceTitle.getStyle().set("margin", "0");
|
servicesTitle.getStyle().set("margin", "0");
|
||||||
content.add(priceTitle, price);
|
content.add(servicesTitle);
|
||||||
|
|
||||||
|
// Services Grid
|
||||||
|
servicesGrid = new Grid<>();
|
||||||
|
servicesGrid.setWidthFull();
|
||||||
|
servicesGrid.setHeight("250px");
|
||||||
|
servicesGrid.setItems(selectedServices);
|
||||||
|
|
||||||
|
servicesGrid.addColumn(Service::getName).setHeader("Leistung").setSortable(true);
|
||||||
|
servicesGrid.addColumn(service -> {
|
||||||
|
if (service.getCalculationBasis() != null) {
|
||||||
|
return switch (service.getCalculationBasis()) {
|
||||||
|
case DISTANCE -> "Gefahrene Kilometer";
|
||||||
|
case TIME -> "Zeit";
|
||||||
|
case FLAT_RATE -> "Pauschal";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}).setHeader("Berechnung").setSortable(true);
|
||||||
|
servicesGrid.addColumn(service -> {
|
||||||
|
if (service.getEffectivePrice() != null) {
|
||||||
|
return service.getEffectivePrice().setScale(2, RoundingMode.HALF_UP) + " €";
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}).setHeader("Preis").setSortable(true);
|
||||||
|
servicesGrid.addColumn(service -> {
|
||||||
|
if (service.getVatRate() != null) {
|
||||||
|
return service.getVatRate().multiply(new BigDecimal("100")).setScale(0, RoundingMode.HALF_UP) + " %";
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}).setHeader("MwSt").setSortable(true);
|
||||||
|
servicesGrid.addComponentColumn(service -> {
|
||||||
|
Button removeButton = new Button(new Icon(VaadinIcon.TRASH));
|
||||||
|
removeButton.addThemeVariants(ButtonVariant.LUMO_ERROR, ButtonVariant.LUMO_TERTIARY,
|
||||||
|
ButtonVariant.LUMO_SMALL);
|
||||||
|
removeButton.addClickListener(e -> {
|
||||||
|
selectedServices.remove(service);
|
||||||
|
servicesGrid.getDataProvider().refreshAll();
|
||||||
|
updatePriceSummary();
|
||||||
|
triggerValidation();
|
||||||
|
updateTabLabels();
|
||||||
|
});
|
||||||
|
return removeButton;
|
||||||
|
}).setHeader("Aktion").setAutoWidth(true).setFlexGrow(0);
|
||||||
|
|
||||||
|
content.add(servicesGrid);
|
||||||
|
|
||||||
|
// Add Service Button
|
||||||
|
Button addServiceButton = new Button("Leistung hinzufügen", new Icon(VaadinIcon.PLUS));
|
||||||
|
addServiceButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
|
||||||
|
addServiceButton.addClickListener(e -> openAddServiceDialog());
|
||||||
|
content.add(addServiceButton);
|
||||||
|
|
||||||
|
// Price Summary
|
||||||
|
VerticalLayout summaryLayout = new VerticalLayout();
|
||||||
|
summaryLayout.setPadding(true);
|
||||||
|
summaryLayout.setSpacing(true);
|
||||||
|
summaryLayout.getStyle().set("border", "1px solid var(--lumo-contrast-20pct)");
|
||||||
|
summaryLayout.getStyle().set("border-radius", "var(--lumo-border-radius-m)");
|
||||||
|
summaryLayout.getStyle().set("background-color", "var(--lumo-contrast-5pct)");
|
||||||
|
summaryLayout.setWidthFull();
|
||||||
|
summaryLayout.setDefaultHorizontalComponentAlignment(FlexComponent.Alignment.STRETCH);
|
||||||
|
|
||||||
|
H3 summaryTitle = new H3("Zusammenfassung");
|
||||||
|
summaryTitle.getStyle().set("margin", "0");
|
||||||
|
summaryLayout.add(summaryTitle);
|
||||||
|
|
||||||
|
// Net total
|
||||||
|
HorizontalLayout netRow = new HorizontalLayout();
|
||||||
|
netRow.setWidthFull();
|
||||||
|
netRow.setJustifyContentMode(FlexComponent.JustifyContentMode.BETWEEN);
|
||||||
|
Span netLabel = new Span("Nettosumme:");
|
||||||
|
netTotalLabel = new Span("0,00 €");
|
||||||
|
netTotalLabel.getStyle().set("font-weight", "bold");
|
||||||
|
netRow.add(netLabel, netTotalLabel);
|
||||||
|
summaryLayout.add(netRow);
|
||||||
|
|
||||||
|
// VAT total
|
||||||
|
HorizontalLayout vatRow = new HorizontalLayout();
|
||||||
|
vatRow.setWidthFull();
|
||||||
|
vatRow.setJustifyContentMode(FlexComponent.JustifyContentMode.BETWEEN);
|
||||||
|
Span vatLabel = new Span("Umsatzsteuer:");
|
||||||
|
vatTotalLabel = new Span("0,00 €");
|
||||||
|
vatTotalLabel.getStyle().set("font-weight", "bold");
|
||||||
|
vatRow.add(vatLabel, vatTotalLabel);
|
||||||
|
summaryLayout.add(vatRow);
|
||||||
|
|
||||||
|
// Gross total
|
||||||
|
HorizontalLayout grossRow = new HorizontalLayout();
|
||||||
|
grossRow.setWidthFull();
|
||||||
|
grossRow.setJustifyContentMode(FlexComponent.JustifyContentMode.BETWEEN);
|
||||||
|
Span grossLabel = new Span("Bruttosumme:");
|
||||||
|
grossLabel.getStyle().set("font-size", "var(--lumo-font-size-l)");
|
||||||
|
grossTotalLabel = new Span("0,00 €");
|
||||||
|
grossTotalLabel.getStyle().set("font-size", "var(--lumo-font-size-l)");
|
||||||
|
grossTotalLabel.getStyle().set("font-weight", "bold");
|
||||||
|
grossTotalLabel.getStyle().set("color", "var(--lumo-primary-text-color)");
|
||||||
|
grossRow.add(grossLabel, grossTotalLabel);
|
||||||
|
summaryLayout.add(grossRow);
|
||||||
|
|
||||||
|
content.add(summaryLayout);
|
||||||
|
|
||||||
tabContent.add(content);
|
tabContent.add(content);
|
||||||
return tabContent;
|
return tabContent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void openAddServiceDialog() {
|
||||||
|
Dialog dialog = new Dialog();
|
||||||
|
dialog.setHeaderTitle("Leistung auswählen");
|
||||||
|
dialog.setWidth("500px");
|
||||||
|
|
||||||
|
VerticalLayout dialogContent = new VerticalLayout();
|
||||||
|
dialogContent.setPadding(true);
|
||||||
|
dialogContent.setSpacing(true);
|
||||||
|
|
||||||
|
// Load available services for current user
|
||||||
|
List<Service> availableServices = serviceRepository
|
||||||
|
.findByUserId(securityService.getCurrentDatabaseUser().getId().toString());
|
||||||
|
|
||||||
|
ComboBox<Service> serviceCombo = new ComboBox<>("Leistung");
|
||||||
|
serviceCombo.setWidthFull();
|
||||||
|
serviceCombo.setItems(availableServices);
|
||||||
|
serviceCombo.setItemLabelGenerator(service -> {
|
||||||
|
// Only show price for FLAT_RATE services
|
||||||
|
if (service.getCalculationBasis() == Service.CalculationBasis.FLAT_RATE
|
||||||
|
&& service.getEffectivePrice() != null) {
|
||||||
|
return service.getName() + " (" + service.getEffectivePrice().setScale(2, RoundingMode.HALF_UP) + " €)";
|
||||||
|
}
|
||||||
|
return service.getName();
|
||||||
|
});
|
||||||
|
serviceCombo.setPlaceholder("Leistung auswählen...");
|
||||||
|
serviceCombo.setRequired(true);
|
||||||
|
|
||||||
|
dialogContent.add(serviceCombo);
|
||||||
|
|
||||||
|
HorizontalLayout buttonLayout = new HorizontalLayout();
|
||||||
|
buttonLayout.setWidthFull();
|
||||||
|
buttonLayout.setJustifyContentMode(FlexComponent.JustifyContentMode.END);
|
||||||
|
buttonLayout.setSpacing(true);
|
||||||
|
|
||||||
|
Button cancelButton = new Button("Abbrechen", e -> dialog.close());
|
||||||
|
cancelButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
|
||||||
|
|
||||||
|
Button addButton = new Button("Hinzufügen", e -> {
|
||||||
|
if (serviceCombo.getValue() != null) {
|
||||||
|
selectedServices.add(serviceCombo.getValue());
|
||||||
|
servicesGrid.getDataProvider().refreshAll();
|
||||||
|
updatePriceSummary();
|
||||||
|
triggerValidation();
|
||||||
|
updateTabLabels();
|
||||||
|
dialog.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
addButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
|
||||||
|
|
||||||
|
buttonLayout.add(cancelButton, addButton);
|
||||||
|
dialogContent.add(buttonLayout);
|
||||||
|
|
||||||
|
dialog.add(dialogContent);
|
||||||
|
dialog.open();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updatePriceSummary() {
|
||||||
|
BigDecimal netTotal = BigDecimal.ZERO;
|
||||||
|
BigDecimal vatTotal = BigDecimal.ZERO;
|
||||||
|
BigDecimal grossTotal = BigDecimal.ZERO;
|
||||||
|
|
||||||
|
for (Service service : selectedServices) {
|
||||||
|
BigDecimal price = service.getEffectivePrice() != null ? service.getEffectivePrice() : BigDecimal.ZERO;
|
||||||
|
BigDecimal vatRate = service.getVatRate() != null ? service.getVatRate() : BigDecimal.ZERO;
|
||||||
|
|
||||||
|
netTotal = netTotal.add(price);
|
||||||
|
BigDecimal vatAmount = price.multiply(vatRate);
|
||||||
|
vatTotal = vatTotal.add(vatAmount);
|
||||||
|
grossTotal = grossTotal.add(price.add(vatAmount));
|
||||||
|
}
|
||||||
|
|
||||||
|
netTotalLabel.setText(netTotal.setScale(2, RoundingMode.HALF_UP).toString().replace(".", ",") + " €");
|
||||||
|
vatTotalLabel.setText(vatTotal.setScale(2, RoundingMode.HALF_UP).toString().replace(".", ",") + " €");
|
||||||
|
grossTotalLabel.setText(grossTotal.setScale(2, RoundingMode.HALF_UP).toString().replace(".", ",") + " €");
|
||||||
|
}
|
||||||
|
|
||||||
private VerticalLayout createPickupSection() {
|
private VerticalLayout createPickupSection() {
|
||||||
VerticalLayout section = new VerticalLayout();
|
VerticalLayout section = new VerticalLayout();
|
||||||
section.setSpacing(true);
|
section.setSpacing(true);
|
||||||
@@ -833,21 +1030,15 @@ public class AddJobView extends Main {
|
|||||||
|
|
||||||
binder.forField(deliveryCity).asRequired("").bind(Job::getDeliveryCity, Job::setDeliveryCity);
|
binder.forField(deliveryCity).asRequired("").bind(Job::getDeliveryCity, Job::setDeliveryCity);
|
||||||
|
|
||||||
// Bind price field: Komma-Zahlen in Punkt-Zahlen umsetzen, dann nach BigDecimal
|
// Price is now calculated from selected services - bind to job price for
|
||||||
// konvertieren
|
// storage
|
||||||
binder.forField(price).withNullRepresentation("").asRequired("Preis erforderlich").withConverter((String s) -> {
|
binder.forField(new com.vaadin.flow.component.textfield.TextField()).withConverter((String str) -> {
|
||||||
if (s == null || s.trim().isEmpty())
|
// Calculate total from selected services
|
||||||
return null;
|
BigDecimal total = selectedServices.stream()
|
||||||
String normalized = s.replace(" ", "").replace(".", "").replace(',', '.');
|
.map(svc -> svc.getEffectivePrice() != null ? svc.getEffectivePrice() : BigDecimal.ZERO)
|
||||||
try {
|
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||||
return new java.math.BigDecimal(normalized);
|
return total;
|
||||||
} catch (NumberFormatException ex) {
|
}, (java.math.BigDecimal bd) -> bd == null ? "" : bd.toString()).bind(Job::getPrice, Job::setPrice);
|
||||||
throw new NumberFormatException("Ungültiger Betrag");
|
|
||||||
}
|
|
||||||
}, (java.math.BigDecimal bd) -> bd == null ? "" : bd.toString(), "Ungültiger Betrag")
|
|
||||||
.withValidator(value -> value != null && value.compareTo(java.math.BigDecimal.ZERO) > 0,
|
|
||||||
"Der Preis muss größer als 0 sein")
|
|
||||||
.bind(Job::getPrice, Job::setPrice);
|
|
||||||
|
|
||||||
// Bind date picker fields with validation
|
// Bind date picker fields with validation
|
||||||
binder.forField(pickupDate).asRequired("")
|
binder.forField(pickupDate).asRequired("")
|
||||||
@@ -952,7 +1143,7 @@ public class AddJobView extends Main {
|
|||||||
// List of all required fields
|
// List of all required fields
|
||||||
TextField[] requiredTextFields = { pickupFirstName, pickupLastName, pickupStreet, pickupHouseNumber, pickupZip,
|
TextField[] requiredTextFields = { pickupFirstName, pickupLastName, pickupStreet, pickupHouseNumber, pickupZip,
|
||||||
pickupCity, deliveryFirstName, deliveryLastName, deliveryStreet, deliveryHouseNumber, deliveryZip,
|
pickupCity, deliveryFirstName, deliveryLastName, deliveryStreet, deliveryHouseNumber, deliveryZip,
|
||||||
deliveryCity, price };
|
deliveryCity };
|
||||||
|
|
||||||
// List of required date fields
|
// List of required date fields
|
||||||
DatePicker[] requiredDateFields = { pickupDate, deliveryDate };
|
DatePicker[] requiredDateFields = { pickupDate, deliveryDate };
|
||||||
@@ -1078,8 +1269,8 @@ public class AddJobView extends Main {
|
|||||||
|
|
||||||
private boolean hasAppointmentValidationErrors() {
|
private boolean hasAppointmentValidationErrors() {
|
||||||
LocalDate today = LocalDate.now();
|
LocalDate today = LocalDate.now();
|
||||||
return pickupDate.getValue() == null || deliveryDate.getValue() == null
|
return pickupDate.getValue() == null || deliveryDate.getValue() == null || pickupDate.getValue().isBefore(today)
|
||||||
|| pickupDate.getValue().isBefore(today) || deliveryDate.getValue().isBefore(today);
|
|| deliveryDate.getValue().isBefore(today);
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean hasCargoValidationErrors() {
|
private boolean hasCargoValidationErrors() {
|
||||||
@@ -1105,7 +1296,8 @@ public class AddJobView extends Main {
|
|||||||
|
|
||||||
private boolean hasTasksValidationErrors() {
|
private boolean hasTasksValidationErrors() {
|
||||||
for (BaseTask task : tasksState) {
|
for (BaseTask task : tasksState) {
|
||||||
// Check if any ConfirmationTask has an empty description or buttonText (required fields)
|
// Check if any ConfirmationTask has an empty description or buttonText
|
||||||
|
// (required fields)
|
||||||
if (task instanceof ConfirmationTask confirmationTask) {
|
if (task instanceof ConfirmationTask confirmationTask) {
|
||||||
String description = task.getDescription();
|
String description = task.getDescription();
|
||||||
if (description == null || description.trim().isEmpty()) {
|
if (description == null || description.trim().isEmpty()) {
|
||||||
@@ -1133,7 +1325,8 @@ public class AddJobView extends Main {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private boolean hasPriceValidationErrors() {
|
private boolean hasPriceValidationErrors() {
|
||||||
return isFieldEmpty(price);
|
// Price tab is valid when at least one service is selected
|
||||||
|
return selectedServices == null || selectedServices.isEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean isFieldEmpty(TextField field) {
|
private boolean isFieldEmpty(TextField field) {
|
||||||
@@ -1153,6 +1346,15 @@ public class AddJobView extends Main {
|
|||||||
if (remarkArea != null)
|
if (remarkArea != null)
|
||||||
job.setRemark(remarkArea.getValue());
|
job.setRemark(remarkArea.getValue());
|
||||||
|
|
||||||
|
// Calculate price from selected services
|
||||||
|
BigDecimal totalPrice = selectedServices.stream()
|
||||||
|
.map(s -> s.getEffectivePrice() != null ? s.getEffectivePrice() : BigDecimal.ZERO)
|
||||||
|
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||||
|
job.setPrice(totalPrice);
|
||||||
|
|
||||||
|
// Store selected service IDs in job (optional - if Job has serviceIds field)
|
||||||
|
// job.setServiceIds(selectedServices.stream().map(Service::getId).toList());
|
||||||
|
|
||||||
// Validate all required fields using the binder
|
// Validate all required fields using the binder
|
||||||
if (binder.writeBeanIfValid(job)) {
|
if (binder.writeBeanIfValid(job)) {
|
||||||
// Additional validation: If digital processing is enabled, app user must be
|
// Additional validation: If digital processing is enabled, app user must be
|
||||||
@@ -1248,6 +1450,31 @@ public class AddJobView extends Main {
|
|||||||
|
|
||||||
// Zusammenfassungs-Helfer entfernt (Route übernimmt Darstellung)
|
// Zusammenfassungs-Helfer entfernt (Route übernimmt Darstellung)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lädt verpflichtende Leistungen aus dem Leistungskatalog
|
||||||
|
*/
|
||||||
|
private void loadMandatoryServices() {
|
||||||
|
try {
|
||||||
|
User currentUser = securityService.getCurrentDatabaseUser();
|
||||||
|
if (currentUser != null) {
|
||||||
|
List<Service> userServices = serviceRepository.findByUserId(currentUser.getId().toString());
|
||||||
|
List<Service> mandatoryServices = userServices.stream().filter(Service::isMandatory).toList();
|
||||||
|
|
||||||
|
if (!mandatoryServices.isEmpty()) {
|
||||||
|
selectedServices.addAll(mandatoryServices);
|
||||||
|
if (servicesGrid != null) {
|
||||||
|
servicesGrid.getDataProvider().refreshAll();
|
||||||
|
}
|
||||||
|
updatePriceSummary();
|
||||||
|
triggerValidation();
|
||||||
|
updateTabLabels();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Fehler beim Laden der verpflichtenden Leistungen: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lädt einen bestehenden Entwurf, falls vorhanden
|
* Lädt einen bestehenden Entwurf, falls vorhanden
|
||||||
*/
|
*/
|
||||||
@@ -1591,8 +1818,12 @@ public class AddJobView extends Main {
|
|||||||
digitalProcessing.setValue(true);
|
digitalProcessing.setValue(true);
|
||||||
appUser.clear();
|
appUser.clear();
|
||||||
|
|
||||||
// Price field
|
// Clear services
|
||||||
price.clear();
|
selectedServices.clear();
|
||||||
|
if (servicesGrid != null) {
|
||||||
|
servicesGrid.getDataProvider().refreshAll();
|
||||||
|
}
|
||||||
|
updatePriceSummary();
|
||||||
|
|
||||||
// Benutzer-Feedback
|
// Benutzer-Feedback
|
||||||
Notification.show("Alle Felder wurden geleert", 2000, Notification.Position.BOTTOM_CENTER);
|
Notification.show("Alle Felder wurden geleert", 2000, Notification.Position.BOTTOM_CENTER);
|
||||||
@@ -2435,4 +2666,374 @@ public class AddJobView extends Main {
|
|||||||
return TaskType.CONFIRMATION; // fallback
|
return TaskType.CONFIRMATION; // fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Adressvalidierung
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wird aufgerufen, wenn der Benutzer den Tab wechselt. Prüft, ob vom Tab
|
||||||
|
* "Auftraggeber & Adressen" gewechselt wird und ob die Adressen geändert
|
||||||
|
* wurden.
|
||||||
|
*/
|
||||||
|
private void onTabChange(com.vaadin.flow.component.tabs.TabSheet.SelectedChangeEvent event) {
|
||||||
|
com.vaadin.flow.component.tabs.Tab previousTab = event.getPreviousTab();
|
||||||
|
com.vaadin.flow.component.tabs.Tab selectedTab = event.getSelectedTab();
|
||||||
|
|
||||||
|
// Nur prüfen, wenn vom Adress-Tab weg gewechselt wird
|
||||||
|
if (previousTab != addressesTab) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prüfen, ob Adressen geändert wurden
|
||||||
|
boolean pickupChanged = hasPickupAddressChanged();
|
||||||
|
boolean deliveryChanged = hasDeliveryAddressChanged();
|
||||||
|
|
||||||
|
if (!pickupChanged && !deliveryChanged) {
|
||||||
|
// Adressen nicht geändert, nichts zu tun
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tab-Wechsel vorübergehend verhindern, indem wir zurück zum Adress-Tab
|
||||||
|
// wechseln
|
||||||
|
// Der Dialog wird angezeigt und bei Bestätigung wird der Tab gewechselt
|
||||||
|
event.unregisterListener();
|
||||||
|
tabSheet.setSelectedTab(addressesTab);
|
||||||
|
|
||||||
|
// Validierungsdialog anzeigen
|
||||||
|
showAddressValidationDialog(selectedTab);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft, ob sich die Abholadresse geändert hat.
|
||||||
|
*/
|
||||||
|
private boolean hasPickupAddressChanged() {
|
||||||
|
String currentStreet = getValueOrEmpty(pickupStreet);
|
||||||
|
String currentHouseNumber = getValueOrEmpty(pickupHouseNumber);
|
||||||
|
String currentZip = getValueOrEmpty(pickupZip);
|
||||||
|
String currentCity = getValueOrEmpty(pickupCity);
|
||||||
|
|
||||||
|
boolean changed = !currentStreet.equals(lastPickupStreet) || !currentHouseNumber.equals(lastPickupHouseNumber)
|
||||||
|
|| !currentZip.equals(lastPickupZip) || !currentCity.equals(lastPickupCity);
|
||||||
|
|
||||||
|
return changed && !currentStreet.isEmpty() && !currentZip.isEmpty() && !currentCity.isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft, ob sich die Lieferadresse geändert hat.
|
||||||
|
*/
|
||||||
|
private boolean hasDeliveryAddressChanged() {
|
||||||
|
String currentStreet = getValueOrEmpty(deliveryStreet);
|
||||||
|
String currentHouseNumber = getValueOrEmpty(deliveryHouseNumber);
|
||||||
|
String currentZip = getValueOrEmpty(deliveryZip);
|
||||||
|
String currentCity = getValueOrEmpty(deliveryCity);
|
||||||
|
|
||||||
|
boolean changed = !currentStreet.equals(lastDeliveryStreet)
|
||||||
|
|| !currentHouseNumber.equals(lastDeliveryHouseNumber) || !currentZip.equals(lastDeliveryZip)
|
||||||
|
|| !currentCity.equals(lastDeliveryCity);
|
||||||
|
|
||||||
|
return changed && !currentStreet.isEmpty() && !currentZip.isEmpty() && !currentCity.isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String getValueOrEmpty(TextField field) {
|
||||||
|
return field.getValue() != null ? field.getValue().trim() : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zeigt den Adressvalidierungsdialog an.
|
||||||
|
*/
|
||||||
|
private void showAddressValidationDialog(com.vaadin.flow.component.tabs.Tab targetTab) {
|
||||||
|
final Dialog dialog = new Dialog();
|
||||||
|
dialog.setHeaderTitle("Adressen werden überprüft");
|
||||||
|
dialog.setWidth("500px");
|
||||||
|
dialog.setModal(true);
|
||||||
|
dialog.setCloseOnOutsideClick(false);
|
||||||
|
dialog.setCloseOnEsc(false);
|
||||||
|
|
||||||
|
final VerticalLayout content = new VerticalLayout();
|
||||||
|
content.setPadding(true);
|
||||||
|
content.setSpacing(true);
|
||||||
|
|
||||||
|
// Status-Labels für die Validierung
|
||||||
|
final Span pickupStatusLabel = new Span("Abholadresse wird überprüft...");
|
||||||
|
final Span deliveryStatusLabel = new Span("Lieferadresse wird überprüft...");
|
||||||
|
|
||||||
|
content.add(pickupStatusLabel, deliveryStatusLabel);
|
||||||
|
|
||||||
|
// Layout für die Ergebnisanzeige
|
||||||
|
final VerticalLayout resultLayout = new VerticalLayout();
|
||||||
|
resultLayout.setVisible(false);
|
||||||
|
resultLayout.setPadding(false);
|
||||||
|
resultLayout.setSpacing(true);
|
||||||
|
|
||||||
|
final Span pickupResultLabel = new Span();
|
||||||
|
final Span deliveryResultLabel = new Span();
|
||||||
|
|
||||||
|
// Route-Label für die Anzeige der berechneten Strecke
|
||||||
|
final Span routeResultLabel = new Span();
|
||||||
|
routeResultLabel.getStyle().set("font-weight", "bold");
|
||||||
|
routeResultLabel.getStyle().set("margin-top", "var(--lumo-space-s)");
|
||||||
|
routeResultLabel.setVisible(false);
|
||||||
|
|
||||||
|
resultLayout.add(pickupResultLabel, deliveryResultLabel, routeResultLabel);
|
||||||
|
content.add(resultLayout);
|
||||||
|
|
||||||
|
// Button-Layout (initial versteckt)
|
||||||
|
final HorizontalLayout buttonLayout = new HorizontalLayout();
|
||||||
|
buttonLayout.setWidthFull();
|
||||||
|
buttonLayout.setJustifyContentMode(FlexComponent.JustifyContentMode.END);
|
||||||
|
buttonLayout.setVisible(false);
|
||||||
|
|
||||||
|
final Button cancelButton = new Button("Zurück", e -> {
|
||||||
|
dialog.close();
|
||||||
|
// Im Adress-Tab bleiben
|
||||||
|
});
|
||||||
|
cancelButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
|
||||||
|
|
||||||
|
final Button continueButton = new Button("Trotzdem wechseln", e -> {
|
||||||
|
dialog.close();
|
||||||
|
// Zum Ziel-Tab wechseln
|
||||||
|
tabSheet.setSelectedTab(targetTab);
|
||||||
|
// Gespeicherte Adressen aktualisieren
|
||||||
|
saveCurrentAddresses();
|
||||||
|
});
|
||||||
|
continueButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
|
||||||
|
|
||||||
|
buttonLayout.add(cancelButton, continueButton);
|
||||||
|
content.add(buttonLayout);
|
||||||
|
|
||||||
|
dialog.add(content);
|
||||||
|
dialog.open();
|
||||||
|
|
||||||
|
// Asynchrone Validierung durchführen
|
||||||
|
getUI().ifPresent(ui -> {
|
||||||
|
// UI-Zugriff für Validierung
|
||||||
|
ui.access(() -> {
|
||||||
|
// Abholadresse validieren
|
||||||
|
final AddressValidationResult[] pickupResultHolder = new AddressValidationResult[1];
|
||||||
|
if (hasPickupAddressChanged()) {
|
||||||
|
pickupResultHolder[0] = addressValidationService.validateAddress("pickup",
|
||||||
|
getValueOrEmpty(pickupStreet), getValueOrEmpty(pickupHouseNumber),
|
||||||
|
getValueOrEmpty(pickupZip), getValueOrEmpty(pickupCity));
|
||||||
|
addressValidationResults.put("pickup", pickupResultHolder[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lieferadresse validieren
|
||||||
|
final AddressValidationResult[] deliveryResultHolder = new AddressValidationResult[1];
|
||||||
|
if (hasDeliveryAddressChanged()) {
|
||||||
|
deliveryResultHolder[0] = addressValidationService.validateAddress("delivery",
|
||||||
|
getValueOrEmpty(deliveryStreet), getValueOrEmpty(deliveryHouseNumber),
|
||||||
|
getValueOrEmpty(deliveryZip), getValueOrEmpty(deliveryCity));
|
||||||
|
addressValidationResults.put("delivery", deliveryResultHolder[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Route berechnen, wenn beide Adressen gültig sind
|
||||||
|
final RouteCalculationResult[] routeResultHolder = new RouteCalculationResult[1];
|
||||||
|
AddressValidationResult pickup = pickupResultHolder[0];
|
||||||
|
AddressValidationResult delivery = deliveryResultHolder[0];
|
||||||
|
|
||||||
|
if ((pickup != null && pickup.isValid()) && (delivery != null && delivery.isValid())) {
|
||||||
|
routeResultHolder[0] = addressValidationService.calculateRoute(pickup, delivery);
|
||||||
|
routeCalculationResult = routeResultHolder[0];
|
||||||
|
} else if (pickup == null) {
|
||||||
|
// Bereits validierte Abholadresse verwenden
|
||||||
|
AddressValidationResult existingPickup = addressValidationResults.get("pickup");
|
||||||
|
if (existingPickup != null && existingPickup.isValid() && delivery != null && delivery.isValid()) {
|
||||||
|
routeResultHolder[0] = addressValidationService.calculateRoute(existingPickup, delivery);
|
||||||
|
routeCalculationResult = routeResultHolder[0];
|
||||||
|
}
|
||||||
|
} else if (delivery == null) {
|
||||||
|
// Bereits validierte Lieferadresse verwenden
|
||||||
|
AddressValidationResult existingDelivery = addressValidationResults.get("delivery");
|
||||||
|
if (existingDelivery != null && existingDelivery.isValid() && pickup != null && pickup.isValid()) {
|
||||||
|
routeResultHolder[0] = addressValidationService.calculateRoute(pickup, existingDelivery);
|
||||||
|
routeCalculationResult = routeResultHolder[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UI aktualisieren
|
||||||
|
updateValidationDialogUI(pickup, delivery, pickupStatusLabel, deliveryStatusLabel, pickupResultLabel,
|
||||||
|
deliveryResultLabel, routeResultLabel, resultLayout, buttonLayout, continueButton, targetTab);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aktualisiert die UI des Validierungsdialogs mit den Ergebnissen.
|
||||||
|
*/
|
||||||
|
private void updateValidationDialogUI(AddressValidationResult pickupResult, AddressValidationResult deliveryResult,
|
||||||
|
Span pickupStatusLabel, Span deliveryStatusLabel, Span pickupResultLabel, Span deliveryResultLabel,
|
||||||
|
Span routeResultLabel, VerticalLayout resultLayout, HorizontalLayout buttonLayout, Button continueButton,
|
||||||
|
com.vaadin.flow.component.tabs.Tab targetTab) {
|
||||||
|
|
||||||
|
boolean hasInvalidAddress = false;
|
||||||
|
boolean bothAddressesValid = true;
|
||||||
|
|
||||||
|
// Abholadresse anzeigen
|
||||||
|
if (pickupResult != null) {
|
||||||
|
if (pickupResult.isValid()) {
|
||||||
|
pickupResultLabel.setText("✓ Abholadresse: " + pickupResult.getFormattedAddress());
|
||||||
|
pickupResultLabel.getStyle().set("color", "var(--lumo-success-text-color)");
|
||||||
|
} else {
|
||||||
|
pickupResultLabel.setText("⚠ Abholadresse: " + pickupResult.getValidationMessage());
|
||||||
|
pickupResultLabel.getStyle().set("color", "var(--lumo-error-text-color)");
|
||||||
|
hasInvalidAddress = true;
|
||||||
|
bothAddressesValid = false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
pickupResultLabel.setVisible(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lieferadresse anzeigen
|
||||||
|
if (deliveryResult != null) {
|
||||||
|
if (deliveryResult.isValid()) {
|
||||||
|
deliveryResultLabel.setText("✓ Lieferadresse: " + deliveryResult.getFormattedAddress());
|
||||||
|
deliveryResultLabel.getStyle().set("color", "var(--lumo-success-text-color)");
|
||||||
|
} else {
|
||||||
|
deliveryResultLabel.setText("⚠ Lieferadresse: " + deliveryResult.getValidationMessage());
|
||||||
|
deliveryResultLabel.getStyle().set("color", "var(--lumo-error-text-color)");
|
||||||
|
hasInvalidAddress = true;
|
||||||
|
bothAddressesValid = false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
deliveryResultLabel.setVisible(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prüfen, ob beide Adressen insgesamt gültig sind (auch aus vorherigen
|
||||||
|
// Validierungen)
|
||||||
|
AddressValidationResult existingPickup = addressValidationResults.get("pickup");
|
||||||
|
AddressValidationResult existingDelivery = addressValidationResults.get("delivery");
|
||||||
|
|
||||||
|
if (pickupResult != null && !pickupResult.isValid()) {
|
||||||
|
bothAddressesValid = false;
|
||||||
|
} else if (pickupResult == null && (existingPickup == null || !existingPickup.isValid())) {
|
||||||
|
bothAddressesValid = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deliveryResult != null && !deliveryResult.isValid()) {
|
||||||
|
bothAddressesValid = false;
|
||||||
|
} else if (deliveryResult == null && (existingDelivery == null || !existingDelivery.isValid())) {
|
||||||
|
bothAddressesValid = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Route anzeigen, wenn beide Adressen gültig sind
|
||||||
|
if (bothAddressesValid && routeCalculationResult != null && routeCalculationResult.isValid()) {
|
||||||
|
routeResultLabel.setText("🚛 Route: " + String.format("%.1f km", routeCalculationResult.getDistanceKm())
|
||||||
|
+ " (Fahrtzeit: " + routeCalculationResult.getFormattedDurationLong() + ")");
|
||||||
|
routeResultLabel.getStyle().set("color", "var(--lumo-primary-text-color)");
|
||||||
|
routeResultLabel.setVisible(true);
|
||||||
|
} else {
|
||||||
|
routeResultLabel.setVisible(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status-Labels ausblenden, Ergebnisse anzeigen
|
||||||
|
pickupStatusLabel.setVisible(false);
|
||||||
|
deliveryStatusLabel.setVisible(false);
|
||||||
|
resultLayout.setVisible(true);
|
||||||
|
|
||||||
|
// Farbliche Markierung der Adressfelder
|
||||||
|
updateAddressFieldStyles(pickupResult != null ? pickupResult : existingPickup,
|
||||||
|
deliveryResult != null ? deliveryResult : existingDelivery);
|
||||||
|
|
||||||
|
// Buttons anzeigen
|
||||||
|
buttonLayout.setVisible(true);
|
||||||
|
|
||||||
|
// Wenn beide Adressen gültig sind, direkt weiter
|
||||||
|
if (!hasInvalidAddress) {
|
||||||
|
continueButton.setText("Weiter");
|
||||||
|
} else {
|
||||||
|
continueButton.setText("Trotzdem wechseln");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aktualisiert die Hintergrundfarbe der Adressfelder basierend auf dem
|
||||||
|
* Validierungsergebnis. Hellgrün für validierte Adressen, hellgelb für nicht
|
||||||
|
* validierte.
|
||||||
|
*/
|
||||||
|
private void updateAddressFieldStyles(AddressValidationResult pickupResult,
|
||||||
|
AddressValidationResult deliveryResult) {
|
||||||
|
// Abholadresse - hellgrün (#90EE90) für validiert, hellgelb (#FFFACD) für nicht
|
||||||
|
// validiert
|
||||||
|
String pickupColor = (pickupResult != null && pickupResult.isValid()) ? "rgba(144, 238, 144, 0.5)" // Hellgrün
|
||||||
|
// mit
|
||||||
|
// Transparenz
|
||||||
|
: "rgba(255, 250, 205, 0.5)"; // Hellgelb mit Transparenz
|
||||||
|
|
||||||
|
pickupStreet.getStyle().set("--vaadin-input-field-background", pickupColor);
|
||||||
|
pickupHouseNumber.getStyle().set("--vaadin-input-field-background", pickupColor);
|
||||||
|
pickupZip.getStyle().set("--vaadin-input-field-background", pickupColor);
|
||||||
|
pickupCity.getStyle().set("--vaadin-input-field-background", pickupColor);
|
||||||
|
|
||||||
|
// Lieferadresse - hellgrün für validiert, hellgelb für nicht validiert
|
||||||
|
String deliveryColor = (deliveryResult != null && deliveryResult.isValid()) ? "rgba(144, 238, 144, 0.5)" // Hellgrün
|
||||||
|
// mit
|
||||||
|
// Transparenz
|
||||||
|
: "rgba(255, 250, 205, 0.5)"; // Hellgelb mit Transparenz
|
||||||
|
|
||||||
|
deliveryStreet.getStyle().set("--vaadin-input-field-background", deliveryColor);
|
||||||
|
deliveryHouseNumber.getStyle().set("--vaadin-input-field-background", deliveryColor);
|
||||||
|
deliveryZip.getStyle().set("--vaadin-input-field-background", deliveryColor);
|
||||||
|
deliveryCity.getStyle().set("--vaadin-input-field-background", deliveryColor);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Speichert die aktuellen Adressen als "zuletzt geprüft".
|
||||||
|
*/
|
||||||
|
private void saveCurrentAddresses() {
|
||||||
|
lastPickupStreet = getValueOrEmpty(pickupStreet);
|
||||||
|
lastPickupHouseNumber = getValueOrEmpty(pickupHouseNumber);
|
||||||
|
lastPickupZip = getValueOrEmpty(pickupZip);
|
||||||
|
lastPickupCity = getValueOrEmpty(pickupCity);
|
||||||
|
|
||||||
|
lastDeliveryStreet = getValueOrEmpty(deliveryStreet);
|
||||||
|
lastDeliveryHouseNumber = getValueOrEmpty(deliveryHouseNumber);
|
||||||
|
lastDeliveryZip = getValueOrEmpty(deliveryZip);
|
||||||
|
lastDeliveryCity = getValueOrEmpty(deliveryCity);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gibt das Validierungsergebnis für die Abholadresse zurück. Kann null sein,
|
||||||
|
* wenn noch keine Validierung durchgeführt wurde.
|
||||||
|
*/
|
||||||
|
public AddressValidationResult getPickupAddressValidationResult() {
|
||||||
|
return addressValidationResults.get("pickup");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gibt das Validierungsergebnis für die Lieferadresse zurück. Kann null sein,
|
||||||
|
* wenn noch keine Validierung durchgeführt wurde.
|
||||||
|
*/
|
||||||
|
public AddressValidationResult getDeliveryAddressValidationResult() {
|
||||||
|
return addressValidationResults.get("delivery");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gibt alle Validierungsergebnisse zurück.
|
||||||
|
*/
|
||||||
|
public Map<String, AddressValidationResult> getAddressValidationResults() {
|
||||||
|
return new HashMap<>(addressValidationResults);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gibt das Ergebnis der Routenberechnung zurück. Enthält die Entfernung in
|
||||||
|
* Kilometern und die Fahrtzeit, wenn beide Adressen validiert wurden.
|
||||||
|
*
|
||||||
|
* @return RouteCalculationResult oder null, wenn keine Berechnung durchgeführt
|
||||||
|
* wurde
|
||||||
|
*/
|
||||||
|
public RouteCalculationResult getRouteCalculationResult() {
|
||||||
|
return routeCalculationResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gibt die Entfernung zwischen Abhol- und Lieferadresse in Kilometern zurück.
|
||||||
|
*
|
||||||
|
* @return Entfernung in km oder 0.0, wenn keine Berechnung durchgeführt wurde
|
||||||
|
*/
|
||||||
|
public double getRouteDistanceKm() {
|
||||||
|
return routeCalculationResult != null && routeCalculationResult.isValid()
|
||||||
|
? routeCalculationResult.getDistanceKm()
|
||||||
|
: 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
setMargin(false);
|
||||||
setWidth("100%");
|
setWidth("100%");
|
||||||
setHeight("100%");
|
setHeight("100%");
|
||||||
getStyle()
|
getStyle().set("overflow", "hidden").set("box-sizing", "border-box").set("display", "flex")
|
||||||
.set("overflow", "hidden")
|
|
||||||
.set("box-sizing", "border-box")
|
|
||||||
.set("display", "flex")
|
|
||||||
.set("flex-direction", "column");
|
.set("flex-direction", "column");
|
||||||
|
|
||||||
// Hauptlayout: Links (Templates) | Mitte (Canvas) | Rechts (Eigenschaften)
|
// Hauptlayout: Links (Templates) | Mitte (Canvas) | Rechts (Eigenschaften)
|
||||||
@@ -66,27 +63,19 @@ public class InvoiceGeneratorView extends VerticalLayout {
|
|||||||
VerticalLayout leftPanel = createTemplatesPanel();
|
VerticalLayout leftPanel = createTemplatesPanel();
|
||||||
leftPanel.setWidth("250px");
|
leftPanel.setWidth("250px");
|
||||||
leftPanel.setHeightFull();
|
leftPanel.setHeightFull();
|
||||||
leftPanel.getStyle()
|
leftPanel.getStyle().set("flex-shrink", "0").set("min-width", "250px").set("overflow", "auto");
|
||||||
.set("flex-shrink", "0")
|
|
||||||
.set("min-width", "250px")
|
|
||||||
.set("overflow", "auto");
|
|
||||||
|
|
||||||
// Mitte: Canvas mit Konva.js
|
// Mitte: Canvas mit Konva.js
|
||||||
VerticalLayout centerPanel = createCanvasPanel();
|
VerticalLayout centerPanel = createCanvasPanel();
|
||||||
centerPanel.setWidth("60%");
|
centerPanel.setWidth("60%");
|
||||||
centerPanel.setHeightFull();
|
centerPanel.setHeightFull();
|
||||||
centerPanel.getStyle()
|
centerPanel.getStyle().set("flex-grow", "1").set("min-width", "0");
|
||||||
.set("flex-grow", "1")
|
|
||||||
.set("min-width", "0");
|
|
||||||
|
|
||||||
// Rechte Seite: Eigenschaften
|
// Rechte Seite: Eigenschaften
|
||||||
propertiesPanel = createPropertiesPanel();
|
propertiesPanel = createPropertiesPanel();
|
||||||
propertiesPanel.setWidth("300px");
|
propertiesPanel.setWidth("300px");
|
||||||
propertiesPanel.setHeightFull();
|
propertiesPanel.setHeightFull();
|
||||||
propertiesPanel.getStyle()
|
propertiesPanel.getStyle().set("flex-shrink", "0").set("min-width", "300px").set("overflow", "auto");
|
||||||
.set("flex-shrink", "0")
|
|
||||||
.set("min-width", "300px")
|
|
||||||
.set("overflow", "auto");
|
|
||||||
|
|
||||||
mainLayout.add(leftPanel, centerPanel, propertiesPanel);
|
mainLayout.add(leftPanel, centerPanel, propertiesPanel);
|
||||||
mainLayout.expand(centerPanel);
|
mainLayout.expand(centerPanel);
|
||||||
@@ -96,9 +85,7 @@ public class InvoiceGeneratorView extends VerticalLayout {
|
|||||||
// Aktions-Buttons unter dem Canvas (fixe Höhe)
|
// Aktions-Buttons unter dem Canvas (fixe Höhe)
|
||||||
HorizontalLayout actionLayout = createActionButtons();
|
HorizontalLayout actionLayout = createActionButtons();
|
||||||
actionLayout.setHeight("60px");
|
actionLayout.setHeight("60px");
|
||||||
actionLayout.getStyle()
|
actionLayout.getStyle().set("flex-shrink", "0").set("padding", "0 var(--lumo-space-m)");
|
||||||
.set("flex-shrink", "0")
|
|
||||||
.set("padding", "0 var(--lumo-space-m)");
|
|
||||||
add(actionLayout);
|
add(actionLayout);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,14 +93,9 @@ public class InvoiceGeneratorView extends VerticalLayout {
|
|||||||
protected void onAttach(AttachEvent attachEvent) {
|
protected void onAttach(AttachEvent attachEvent) {
|
||||||
super.onAttach(attachEvent);
|
super.onAttach(attachEvent);
|
||||||
// Register this view instance and initialize the canvas
|
// Register this view instance and initialize the canvas
|
||||||
getElement().executeJs(
|
getElement().executeJs("window.invoiceGeneratorView = this;" + "if (window.invoiceGenerator) {"
|
||||||
"window.invoiceGeneratorView = this;" +
|
+ " console.log('Initializing invoice generator...');" + " window.invoiceGenerator.init();"
|
||||||
"if (window.invoiceGenerator) {" +
|
+ "} else {" + " console.error('Invoice generator not found');" + "}");
|
||||||
" console.log('Initializing invoice generator...');" +
|
|
||||||
" window.invoiceGenerator.init();" +
|
|
||||||
"} else {" +
|
|
||||||
" console.error('Invoice generator not found');" +
|
|
||||||
"}");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private VerticalLayout createTemplatesPanel() {
|
private VerticalLayout createTemplatesPanel() {
|
||||||
@@ -121,15 +103,11 @@ public class InvoiceGeneratorView extends VerticalLayout {
|
|||||||
panel.setPadding(true);
|
panel.setPadding(true);
|
||||||
panel.setSpacing(true);
|
panel.setSpacing(true);
|
||||||
panel.setHeightFull();
|
panel.setHeightFull();
|
||||||
panel.getStyle()
|
panel.getStyle().set("background-color", "var(--lumo-contrast-5pct)")
|
||||||
.set("background-color", "var(--lumo-contrast-5pct)")
|
.set("border-radius", "var(--lumo-border-radius-m)").set("overflow", "auto");
|
||||||
.set("border-radius", "var(--lumo-border-radius-m)")
|
|
||||||
.set("overflow", "auto");
|
|
||||||
|
|
||||||
Span header = new Span("Textbausteine");
|
Span header = new Span("Textbausteine");
|
||||||
header.getStyle()
|
header.getStyle().set("font-weight", "bold").set("font-size", "var(--lumo-font-size-l)");
|
||||||
.set("font-weight", "bold")
|
|
||||||
.set("font-size", "var(--lumo-font-size-l)");
|
|
||||||
|
|
||||||
// Draggable Templates
|
// Draggable Templates
|
||||||
Div textBlock = createDraggableTemplate("Textfeld", VaadinIcon.TEXT_LABEL, "text");
|
Div textBlock = createDraggableTemplate("Textfeld", VaadinIcon.TEXT_LABEL, "text");
|
||||||
@@ -150,17 +128,10 @@ public class InvoiceGeneratorView extends VerticalLayout {
|
|||||||
private Div createDraggableTemplate(String label, VaadinIcon icon, String type) {
|
private Div createDraggableTemplate(String label, VaadinIcon icon, String type) {
|
||||||
Div template = new Div();
|
Div template = new Div();
|
||||||
template.setText(label);
|
template.setText(label);
|
||||||
template.getStyle()
|
template.getStyle().set("padding", "var(--lumo-space-m)").set("margin", "var(--lumo-space-xs) 0")
|
||||||
.set("padding", "var(--lumo-space-m)")
|
.set("background-color", "var(--lumo-base-color)").set("border", "1px solid var(--lumo-contrast-20pct)")
|
||||||
.set("margin", "var(--lumo-space-xs) 0")
|
.set("border-radius", "var(--lumo-border-radius-m)").set("cursor", "grab").set("display", "flex")
|
||||||
.set("background-color", "var(--lumo-base-color)")
|
.set("align-items", "center").set("gap", "var(--lumo-space-s)").set("user-select", "none");
|
||||||
.set("border", "1px solid var(--lumo-contrast-20pct)")
|
|
||||||
.set("border-radius", "var(--lumo-border-radius-m)")
|
|
||||||
.set("cursor", "grab")
|
|
||||||
.set("display", "flex")
|
|
||||||
.set("align-items", "center")
|
|
||||||
.set("gap", "var(--lumo-space-s)")
|
|
||||||
.set("user-select", "none");
|
|
||||||
|
|
||||||
// Icon hinzufügen
|
// Icon hinzufügen
|
||||||
Icon templateIcon = icon.create();
|
Icon templateIcon = icon.create();
|
||||||
@@ -173,15 +144,12 @@ public class InvoiceGeneratorView extends VerticalLayout {
|
|||||||
template.getElement().setAttribute("data-template-label", label);
|
template.getElement().setAttribute("data-template-label", label);
|
||||||
|
|
||||||
// JavaScript Event Listener für Drag Start
|
// JavaScript Event Listener für Drag Start
|
||||||
template.getElement().executeJs(
|
template.getElement()
|
||||||
"this.addEventListener('dragstart', function(e) {" +
|
.executeJs("this.addEventListener('dragstart', function(e) {"
|
||||||
" e.dataTransfer.setData('template-type', this.getAttribute('data-template-type'));" +
|
+ " e.dataTransfer.setData('template-type', this.getAttribute('data-template-type'));"
|
||||||
" e.dataTransfer.setData('template-label', this.getAttribute('data-template-label'));" +
|
+ " e.dataTransfer.setData('template-label', this.getAttribute('data-template-label'));"
|
||||||
" this.style.opacity = '0.5';" +
|
+ " this.style.opacity = '0.5';" + "});" + "this.addEventListener('dragend', function(e) {"
|
||||||
"});" +
|
+ " this.style.opacity = '1';" + "});");
|
||||||
"this.addEventListener('dragend', function(e) {" +
|
|
||||||
" this.style.opacity = '1';" +
|
|
||||||
"});");
|
|
||||||
|
|
||||||
return template;
|
return template;
|
||||||
}
|
}
|
||||||
@@ -197,37 +165,26 @@ public class InvoiceGeneratorView extends VerticalLayout {
|
|||||||
canvasContainer.setId("invoice-canvas-container");
|
canvasContainer.setId("invoice-canvas-container");
|
||||||
canvasContainer.setWidth("100%");
|
canvasContainer.setWidth("100%");
|
||||||
canvasContainer.setHeight("100%");
|
canvasContainer.setHeight("100%");
|
||||||
canvasContainer.getStyle()
|
canvasContainer.getStyle().set("background-color", "#e8e8e8")
|
||||||
.set("background-color", "#e8e8e8")
|
|
||||||
.set("border", "2px dashed var(--lumo-contrast-30pct)")
|
.set("border", "2px dashed var(--lumo-contrast-30pct)")
|
||||||
.set("border-radius", "var(--lumo-border-radius-m)")
|
.set("border-radius", "var(--lumo-border-radius-m)").set("position", "relative")
|
||||||
.set("position", "relative")
|
.set("overflow", "hidden").set("cursor", "default");
|
||||||
.set("overflow", "hidden")
|
|
||||||
.set("cursor", "default");
|
|
||||||
|
|
||||||
// Drop Zone Event Listener
|
// Drop Zone Event Listener
|
||||||
canvasContainer.getElement().executeJs(
|
canvasContainer.getElement()
|
||||||
"var container = this;" +
|
.executeJs("var container = this;" + "container.addEventListener('dragover', function(e) {"
|
||||||
"container.addEventListener('dragover', function(e) {" +
|
+ " e.preventDefault();" + " e.dataTransfer.dropEffect = 'copy';"
|
||||||
" e.preventDefault();" +
|
+ " container.style.borderColor = 'var(--lumo-primary-color)';" + "});"
|
||||||
" e.dataTransfer.dropEffect = 'copy';" +
|
+ "container.addEventListener('dragleave', function(e) {"
|
||||||
" container.style.borderColor = 'var(--lumo-primary-color)';" +
|
+ " container.style.borderColor = 'var(--lumo-contrast-30pct)';" + "});"
|
||||||
"});" +
|
+ "container.addEventListener('drop', function(e) {" + " e.preventDefault();"
|
||||||
"container.addEventListener('dragleave', function(e) {" +
|
+ " container.style.borderColor = 'var(--lumo-contrast-30pct)';"
|
||||||
" container.style.borderColor = 'var(--lumo-contrast-30pct)';" +
|
+ " var templateType = e.dataTransfer.getData('template-type');"
|
||||||
"});" +
|
+ " var templateLabel = e.dataTransfer.getData('template-label');"
|
||||||
"container.addEventListener('drop', function(e) {" +
|
+ " if (templateType && window.invoiceGenerator) {"
|
||||||
" e.preventDefault();" +
|
+ " var rect = container.getBoundingClientRect();" + " var x = e.clientX - rect.left;"
|
||||||
" container.style.borderColor = 'var(--lumo-contrast-30pct)';" +
|
+ " var y = e.clientY - rect.top;"
|
||||||
" var templateType = e.dataTransfer.getData('template-type');" +
|
+ " window.invoiceGenerator.addElement(templateType, templateLabel, x, y);" + " }" + "});");
|
||||||
" var templateLabel = e.dataTransfer.getData('template-label');" +
|
|
||||||
" if (templateType && window.invoiceGenerator) {" +
|
|
||||||
" var rect = container.getBoundingClientRect();" +
|
|
||||||
" var x = e.clientX - rect.left;" +
|
|
||||||
" var y = e.clientY - rect.top;" +
|
|
||||||
" window.invoiceGenerator.addElement(templateType, templateLabel, x, y);" +
|
|
||||||
" }" +
|
|
||||||
"});");
|
|
||||||
|
|
||||||
panel.add(canvasContainer);
|
panel.add(canvasContainer);
|
||||||
panel.expand(canvasContainer);
|
panel.expand(canvasContainer);
|
||||||
@@ -240,22 +197,17 @@ public class InvoiceGeneratorView extends VerticalLayout {
|
|||||||
panel.setPadding(true);
|
panel.setPadding(true);
|
||||||
panel.setSpacing(true);
|
panel.setSpacing(true);
|
||||||
panel.setHeightFull();
|
panel.setHeightFull();
|
||||||
panel.getStyle()
|
panel.getStyle().set("background-color", "var(--lumo-contrast-5pct)")
|
||||||
.set("background-color", "var(--lumo-contrast-5pct)")
|
.set("border-radius", "var(--lumo-border-radius-m)").set("overflow", "auto");
|
||||||
.set("border-radius", "var(--lumo-border-radius-m)")
|
|
||||||
.set("overflow", "auto");
|
|
||||||
|
|
||||||
Span header = new Span("Eigenschaften");
|
Span header = new Span("Eigenschaften");
|
||||||
header.getStyle()
|
header.getStyle().set("font-weight", "bold").set("font-size", "var(--lumo-font-size-l)");
|
||||||
.set("font-weight", "bold")
|
|
||||||
.set("font-size", "var(--lumo-font-size-l)");
|
|
||||||
|
|
||||||
// Info-Text wenn kein Element ausgewählt
|
// Info-Text wenn kein Element ausgewählt
|
||||||
selectedElementInfo = new Div();
|
selectedElementInfo = new Div();
|
||||||
selectedElementInfo.setText("Klicken Sie auf ein Element im Canvas, um dessen Eigenschaften zu bearbeiten.");
|
selectedElementInfo.setText("Klicken Sie auf ein Element im Canvas, um dessen Eigenschaften zu bearbeiten.");
|
||||||
selectedElementInfo.getStyle()
|
selectedElementInfo.getStyle().set("color", "var(--lumo-secondary-text-color)").set("font-size",
|
||||||
.set("color", "var(--lumo-secondary-text-color)")
|
"var(--lumo-font-size-s)");
|
||||||
.set("font-size", "var(--lumo-font-size-s)");
|
|
||||||
|
|
||||||
panel.add(header, selectedElementInfo);
|
panel.add(header, selectedElementInfo);
|
||||||
|
|
||||||
@@ -362,10 +314,7 @@ public class InvoiceGeneratorView extends VerticalLayout {
|
|||||||
Div pdfContainer = new Div();
|
Div pdfContainer = new Div();
|
||||||
pdfContainer.setWidth("100%");
|
pdfContainer.setWidth("100%");
|
||||||
pdfContainer.setHeight("100%");
|
pdfContainer.setHeight("100%");
|
||||||
pdfContainer.getStyle()
|
pdfContainer.getStyle().set("display", "flex").set("flex-direction", "column").set("overflow", "hidden");
|
||||||
.set("display", "flex")
|
|
||||||
.set("flex-direction", "column")
|
|
||||||
.set("overflow", "hidden");
|
|
||||||
|
|
||||||
// Use an iframe with data URL for PDF display
|
// Use an iframe with data URL for PDF display
|
||||||
String base64Pdf = java.util.Base64.getEncoder().encodeToString(pdfBytes);
|
String base64Pdf = java.util.Base64.getEncoder().encodeToString(pdfBytes);
|
||||||
@@ -375,9 +324,7 @@ public class InvoiceGeneratorView extends VerticalLayout {
|
|||||||
pdfFrame.setWidth("100%");
|
pdfFrame.setWidth("100%");
|
||||||
pdfFrame.setHeight("100%");
|
pdfFrame.setHeight("100%");
|
||||||
pdfFrame.getElement().setAttribute("src", dataUrl);
|
pdfFrame.getElement().setAttribute("src", dataUrl);
|
||||||
pdfFrame.getStyle()
|
pdfFrame.getStyle().set("border", "none").set("flex-grow", "1");
|
||||||
.set("border", "none")
|
|
||||||
.set("flex-grow", "1");
|
|
||||||
|
|
||||||
pdfContainer.add(pdfFrame);
|
pdfContainer.add(pdfFrame);
|
||||||
|
|
||||||
@@ -387,11 +334,9 @@ public class InvoiceGeneratorView extends VerticalLayout {
|
|||||||
|
|
||||||
// Download button
|
// Download button
|
||||||
Button downloadButton = new Button("Herunterladen", e -> {
|
Button downloadButton = new Button("Herunterladen", e -> {
|
||||||
getElement().executeJs(
|
getElement()
|
||||||
"const link = document.createElement('a');" +
|
.executeJs("const link = document.createElement('a');" + "link.href = 'data:application/pdf;base64,"
|
||||||
"link.href = 'data:application/pdf;base64," + base64Pdf + "';" +
|
+ base64Pdf + "';" + "link.download = 'vorschau.pdf';" + "link.click();");
|
||||||
"link.download = 'vorschau.pdf';" +
|
|
||||||
"link.click();");
|
|
||||||
});
|
});
|
||||||
downloadButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
|
downloadButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
|
||||||
|
|
||||||
@@ -412,9 +357,7 @@ public class InvoiceGeneratorView extends VerticalLayout {
|
|||||||
propertiesPanel.removeAll();
|
propertiesPanel.removeAll();
|
||||||
|
|
||||||
Span header = new Span("Eigenschaften");
|
Span header = new Span("Eigenschaften");
|
||||||
header.getStyle()
|
header.getStyle().set("font-weight", "bold").set("font-size", "var(--lumo-font-size-l)");
|
||||||
.set("font-weight", "bold")
|
|
||||||
.set("font-size", "var(--lumo-font-size-l)");
|
|
||||||
|
|
||||||
// Element Typ Anzeige
|
// Element Typ Anzeige
|
||||||
Span typeLabel = new Span("Typ: " + elementType);
|
Span typeLabel = new Span("Typ: " + elementType);
|
||||||
@@ -440,10 +383,9 @@ public class InvoiceGeneratorView extends VerticalLayout {
|
|||||||
String dataUrl = "data:" + mimeType + ";base64," + base64;
|
String dataUrl = "data:" + mimeType + ";base64," + base64;
|
||||||
|
|
||||||
// An JavaScript übergeben
|
// An JavaScript übergeben
|
||||||
getElement().executeJs(
|
getElement()
|
||||||
"if (window.invoiceGenerator) { window.invoiceGenerator.updateElementImage('"
|
.executeJs("if (window.invoiceGenerator) { window.invoiceGenerator.updateElementImage('"
|
||||||
+ elementId + "', $0); }",
|
+ elementId + "', $0); }", dataUrl);
|
||||||
dataUrl);
|
|
||||||
showNotification("Bild erfolgreich hochgeladen");
|
showNotification("Bild erfolgreich hochgeladen");
|
||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
showNotification("Fehler beim Hochladen: " + ex.getMessage());
|
showNotification("Fehler beim Hochladen: " + ex.getMessage());
|
||||||
@@ -463,10 +405,8 @@ public class InvoiceGeneratorView extends VerticalLayout {
|
|||||||
textField.setValue(text != null ? text : "");
|
textField.setValue(text != null ? text : "");
|
||||||
textField.setWidthFull();
|
textField.setWidthFull();
|
||||||
textField.addValueChangeListener(e -> {
|
textField.addValueChangeListener(e -> {
|
||||||
getElement().executeJs(
|
getElement().executeJs("if (window.invoiceGenerator) { window.invoiceGenerator.updateElementText('"
|
||||||
"if (window.invoiceGenerator) { window.invoiceGenerator.updateElementText('" + elementId
|
+ elementId + "', $0); }", e.getValue());
|
||||||
+ "', $0); }",
|
|
||||||
e.getValue());
|
|
||||||
});
|
});
|
||||||
propertiesPanel.add(textField);
|
propertiesPanel.add(textField);
|
||||||
}
|
}
|
||||||
@@ -478,10 +418,9 @@ public class InvoiceGeneratorView extends VerticalLayout {
|
|||||||
xField.addValueChangeListener(e -> {
|
xField.addValueChangeListener(e -> {
|
||||||
try {
|
try {
|
||||||
double newX = Double.parseDouble(e.getValue());
|
double newX = Double.parseDouble(e.getValue());
|
||||||
getElement().executeJs(
|
getElement()
|
||||||
"if (window.invoiceGenerator) { window.invoiceGenerator.updateElementPosition('" + elementId
|
.executeJs("if (window.invoiceGenerator) { window.invoiceGenerator.updateElementPosition('"
|
||||||
+ "', $0, null); }",
|
+ elementId + "', $0, null); }", newX);
|
||||||
newX);
|
|
||||||
} catch (NumberFormatException ignored) {
|
} catch (NumberFormatException ignored) {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -494,10 +433,9 @@ public class InvoiceGeneratorView extends VerticalLayout {
|
|||||||
yField.addValueChangeListener(e -> {
|
yField.addValueChangeListener(e -> {
|
||||||
try {
|
try {
|
||||||
double newY = Double.parseDouble(e.getValue());
|
double newY = Double.parseDouble(e.getValue());
|
||||||
getElement().executeJs(
|
getElement()
|
||||||
"if (window.invoiceGenerator) { window.invoiceGenerator.updateElementPosition('" + elementId
|
.executeJs("if (window.invoiceGenerator) { window.invoiceGenerator.updateElementPosition('"
|
||||||
+ "', null, $0); }",
|
+ elementId + "', null, $0); }", newY);
|
||||||
newY);
|
|
||||||
} catch (NumberFormatException ignored) {
|
} catch (NumberFormatException ignored) {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -536,17 +474,12 @@ public class InvoiceGeneratorView extends VerticalLayout {
|
|||||||
|
|
||||||
// Farbvorschau-Box
|
// Farbvorschau-Box
|
||||||
Div colorPreview = new Div();
|
Div colorPreview = new Div();
|
||||||
colorPreview.getStyle()
|
colorPreview.getStyle().set("width", "40px").set("height", "30px").set("background-color", currentColor)
|
||||||
.set("width", "40px")
|
|
||||||
.set("height", "30px")
|
|
||||||
.set("background-color", currentColor)
|
|
||||||
.set("border", "1px solid var(--lumo-contrast-30pct)")
|
.set("border", "1px solid var(--lumo-contrast-30pct)")
|
||||||
.set("border-radius", "var(--lumo-border-radius-m)");
|
.set("border-radius", "var(--lumo-border-radius-m)");
|
||||||
|
|
||||||
Span colorHexLabel = new Span(currentColor);
|
Span colorHexLabel = new Span(currentColor);
|
||||||
colorHexLabel.getStyle()
|
colorHexLabel.getStyle().set("font-family", "monospace").set("font-size", "var(--lumo-font-size-s)");
|
||||||
.set("font-family", "monospace")
|
|
||||||
.set("font-size", "var(--lumo-font-size-s)");
|
|
||||||
|
|
||||||
colorPreviewLayout.add(colorPreview, colorHexLabel);
|
colorPreviewLayout.add(colorPreview, colorHexLabel);
|
||||||
|
|
||||||
@@ -562,10 +495,7 @@ public class InvoiceGeneratorView extends VerticalLayout {
|
|||||||
Input dialogColorPicker = new Input();
|
Input dialogColorPicker = new Input();
|
||||||
dialogColorPicker.setType("color");
|
dialogColorPicker.setType("color");
|
||||||
dialogColorPicker.setValue(currentColor);
|
dialogColorPicker.setValue(currentColor);
|
||||||
dialogColorPicker.getStyle()
|
dialogColorPicker.getStyle().set("width", "100%").set("height", "50px").set("padding", "0");
|
||||||
.set("width", "100%")
|
|
||||||
.set("height", "50px")
|
|
||||||
.set("padding", "0");
|
|
||||||
|
|
||||||
// Hex-Eingabe im Dialog
|
// Hex-Eingabe im Dialog
|
||||||
TextField dialogHexField = new TextField("Hex-Farbwert");
|
TextField dialogHexField = new TextField("Hex-Farbwert");
|
||||||
@@ -602,10 +532,8 @@ public class InvoiceGeneratorView extends VerticalLayout {
|
|||||||
colorPreview.getStyle().set("background-color", newColor);
|
colorPreview.getStyle().set("background-color", newColor);
|
||||||
colorHexLabel.setText(newColor);
|
colorHexLabel.setText(newColor);
|
||||||
// Apply to element
|
// Apply to element
|
||||||
getElement().executeJs(
|
getElement().executeJs("if (window.invoiceGenerator) { window.invoiceGenerator.updateElementColor('"
|
||||||
"if (window.invoiceGenerator) { window.invoiceGenerator.updateElementColor('"
|
+ elementId + "', $0); }", newColor);
|
||||||
+ elementId + "', $0); }",
|
|
||||||
newColor);
|
|
||||||
colorDialog.close();
|
colorDialog.close();
|
||||||
showNotification("Farbe übernommen");
|
showNotification("Farbe übernommen");
|
||||||
});
|
});
|
||||||
@@ -635,8 +563,7 @@ public class InvoiceGeneratorView extends VerticalLayout {
|
|||||||
deleteButton.setWidthFull();
|
deleteButton.setWidthFull();
|
||||||
deleteButton.addClickListener(e -> {
|
deleteButton.addClickListener(e -> {
|
||||||
getElement().executeJs(
|
getElement().executeJs(
|
||||||
"if (window.invoiceGenerator) { window.invoiceGenerator.deleteElement('" + elementId
|
"if (window.invoiceGenerator) { window.invoiceGenerator.deleteElement('" + elementId + "'); }");
|
||||||
+ "'); }");
|
|
||||||
resetPropertiesPanel();
|
resetPropertiesPanel();
|
||||||
});
|
});
|
||||||
propertiesPanel.add(deleteButton);
|
propertiesPanel.add(deleteButton);
|
||||||
@@ -652,15 +579,13 @@ public class InvoiceGeneratorView extends VerticalLayout {
|
|||||||
propertiesPanel.removeAll();
|
propertiesPanel.removeAll();
|
||||||
|
|
||||||
Span header = new Span("Eigenschaften");
|
Span header = new Span("Eigenschaften");
|
||||||
header.getStyle()
|
header.getStyle().set("font-weight", "bold").set("font-size", "var(--lumo-font-size-l)");
|
||||||
.set("font-weight", "bold")
|
|
||||||
.set("font-size", "var(--lumo-font-size-l)");
|
|
||||||
|
|
||||||
selectedElementInfo = new Div();
|
selectedElementInfo = new Div();
|
||||||
selectedElementInfo.setText("Klicken Sie auf ein Element im Canvas, um dessen Eigenschaften zu bearbeiten.");
|
selectedElementInfo
|
||||||
selectedElementInfo.getStyle()
|
.setText("Klicken Sie auf ein Element im Canvas, um dessen Eigenschaften zu bearbeiten.");
|
||||||
.set("color", "var(--lumo-secondary-text-color)")
|
selectedElementInfo.getStyle().set("color", "var(--lumo-secondary-text-color)").set("font-size",
|
||||||
.set("font-size", "var(--lumo-font-size-s)");
|
"var(--lumo-font-size-s)");
|
||||||
|
|
||||||
propertiesPanel.add(header, selectedElementInfo);
|
propertiesPanel.add(header, selectedElementInfo);
|
||||||
}));
|
}));
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -341,17 +341,13 @@ public class MessagesView extends Main {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void requestBrowserPermissions(UI ui) {
|
private void requestBrowserPermissions(UI ui) {
|
||||||
ui.getPage().executeJs(
|
ui.getPage()
|
||||||
"if ('Notification' in window && Notification.permission === 'default') {"
|
.executeJs("if ('Notification' in window && Notification.permission === 'default') {"
|
||||||
+ " Notification.requestPermission();"
|
+ " Notification.requestPermission();" + "}" + "if (!window._votianAudioCtx) {"
|
||||||
+ "}"
|
|
||||||
+ "if (!window._votianAudioCtx) {"
|
|
||||||
+ " window._votianAudioCtx = new (window.AudioContext || window.webkitAudioContext)();"
|
+ " window._votianAudioCtx = new (window.AudioContext || window.webkitAudioContext)();"
|
||||||
+ " document.addEventListener('click', function _resumeAudio() {"
|
+ " document.addEventListener('click', function _resumeAudio() {"
|
||||||
+ " window._votianAudioCtx.resume();"
|
+ " window._votianAudioCtx.resume();"
|
||||||
+ " document.removeEventListener('click', _resumeAudio);"
|
+ " document.removeEventListener('click', _resumeAudio);" + " }, { once: true });" + "}");
|
||||||
+ " }, { once: true });"
|
|
||||||
+ "}");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -377,27 +373,18 @@ public class MessagesView extends Main {
|
|||||||
String preview = resolvePreview(message);
|
String preview = resolvePreview(message);
|
||||||
|
|
||||||
// Play notification sound
|
// Play notification sound
|
||||||
ui.getPage().executeJs(
|
ui.getPage()
|
||||||
"try {"
|
.executeJs("try {" + " const ctx = new (window.AudioContext || window.webkitAudioContext)();"
|
||||||
+ " const ctx = new (window.AudioContext || window.webkitAudioContext)();"
|
+ " ctx.resume().then(() => {" + " const osc = ctx.createOscillator();"
|
||||||
+ " ctx.resume().then(() => {"
|
+ " const gain = ctx.createGain();" + " osc.connect(gain);"
|
||||||
+ " const osc = ctx.createOscillator();"
|
+ " gain.connect(ctx.destination);" + " osc.frequency.value = 800;"
|
||||||
+ " const gain = ctx.createGain();"
|
+ " osc.type = 'sine';" + " gain.gain.setValueAtTime(0.3, ctx.currentTime);"
|
||||||
+ " osc.connect(gain);"
|
|
||||||
+ " gain.connect(ctx.destination);"
|
|
||||||
+ " osc.frequency.value = 800;"
|
|
||||||
+ " osc.type = 'sine';"
|
|
||||||
+ " gain.gain.setValueAtTime(0.3, ctx.currentTime);"
|
|
||||||
+ " gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.5);"
|
+ " gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.5);"
|
||||||
+ " osc.start(ctx.currentTime);"
|
+ " osc.start(ctx.currentTime);" + " osc.stop(ctx.currentTime + 0.5);" + " });"
|
||||||
+ " osc.stop(ctx.currentTime + 0.5);"
|
|
||||||
+ " });"
|
|
||||||
+ "} catch(e) { console.warn('Notification sound failed:', e); }");
|
+ "} catch(e) { console.warn('Notification sound failed:', e); }");
|
||||||
|
|
||||||
// Show notification
|
// Show notification
|
||||||
Notification notification = Notification.show(
|
Notification notification = Notification.show("Neue Nachricht von " + senderName + ": " + preview, 4000,
|
||||||
"Neue Nachricht von " + senderName + ": " + preview,
|
|
||||||
4000,
|
|
||||||
Notification.Position.TOP_END);
|
Notification.Position.TOP_END);
|
||||||
notification.addThemeVariants(NotificationVariant.LUMO_PRIMARY);
|
notification.addThemeVariants(NotificationVariant.LUMO_PRIMARY);
|
||||||
|
|
||||||
@@ -410,12 +397,8 @@ public class MessagesView extends Main {
|
|||||||
return "Unbekannt";
|
return "Unbekannt";
|
||||||
}
|
}
|
||||||
List<AppUser> appUsers = cachedAppUsers != null ? cachedAppUsers : List.of();
|
List<AppUser> appUsers = cachedAppUsers != null ? cachedAppUsers : List.of();
|
||||||
return appUsers.stream()
|
return appUsers.stream().filter(user -> clientId.equals(user.getIdAsString())
|
||||||
.filter(user -> clientId.equals(user.getIdAsString())
|
|| clientId.equals(user.getEmail()) || clientId.equals(user.getAppCode())).findFirst()
|
||||||
|| clientId.equals(user.getEmail())
|
.map(this::buildClientName).orElse(clientId);
|
||||||
|| clientId.equals(user.getAppCode()))
|
|
||||||
.findFirst()
|
|
||||||
.map(this::buildClientName)
|
|
||||||
.orElse(clientId);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,7 +107,8 @@ public class ShowJobsView extends VerticalLayout {
|
|||||||
grid.addColumn(job -> extractCompanyName(job.getCustomerSelection())).setHeader("Auftraggeber")
|
grid.addColumn(job -> extractCompanyName(job.getCustomerSelection())).setHeader("Auftraggeber")
|
||||||
.setAutoWidth(true).setFlexGrow(1).setSortable(true);
|
.setAutoWidth(true).setFlexGrow(1).setSortable(true);
|
||||||
grid.addColumn(Job::getJobNumber).setHeader("Auftragsnummer").setAutoWidth(true).setSortable(true);
|
grid.addColumn(Job::getJobNumber).setHeader("Auftragsnummer").setAutoWidth(true).setSortable(true);
|
||||||
grid.addColumn(job -> DateTimeFormatUtil.formatDateTime(job.getCreatedAt())).setHeader("Auftragsdatum").setAutoWidth(true).setSortable(true);
|
grid.addColumn(job -> DateTimeFormatUtil.formatDateTime(job.getCreatedAt())).setHeader("Auftragsdatum")
|
||||||
|
.setAutoWidth(true).setSortable(true);
|
||||||
grid.addColumn(Job::getDeliveryCity).setHeader("Zielort").setAutoWidth(true).setFlexGrow(1).setSortable(true);
|
grid.addColumn(Job::getDeliveryCity).setHeader("Zielort").setAutoWidth(true).setFlexGrow(1).setSortable(true);
|
||||||
|
|
||||||
// Action column: manual completion for jobs without digital processing
|
// Action column: manual completion for jobs without digital processing
|
||||||
@@ -126,8 +127,27 @@ public class ShowJobsView extends VerticalLayout {
|
|||||||
return new com.vaadin.flow.component.html.Span();
|
return new com.vaadin.flow.component.html.Span();
|
||||||
}).setHeader("").setAutoWidth(true).setFlexGrow(0);
|
}).setHeader("").setAutoWidth(true).setFlexGrow(0);
|
||||||
|
|
||||||
|
// Invoice column - only show for completed jobs
|
||||||
|
grid.addComponentColumn(job -> {
|
||||||
|
if (job.getStatus() == JobStatus.COMPLETED) {
|
||||||
|
Button invoiceBtn = new Button(new Icon(VaadinIcon.DOLLAR));
|
||||||
|
invoiceBtn.addThemeVariants(ButtonVariant.LUMO_TERTIARY, ButtonVariant.LUMO_SUCCESS);
|
||||||
|
invoiceBtn.setTooltipText("Rechnung erstellen");
|
||||||
|
invoiceBtn.addClickListener(e -> {
|
||||||
|
e.getSource().getElement().getNode(); // prevent row click
|
||||||
|
getUI().ifPresent(ui -> ui.navigate("create_invoice/" + job.getId().toHexString()));
|
||||||
|
});
|
||||||
|
return invoiceBtn;
|
||||||
|
}
|
||||||
|
return new com.vaadin.flow.component.html.Span();
|
||||||
|
}).setHeader("").setWidth("60px").setFlexGrow(0);
|
||||||
|
|
||||||
// Delete column (last column, right side)
|
// Delete column (last column, right side)
|
||||||
grid.addComponentColumn(job -> {
|
grid.addComponentColumn(job -> {
|
||||||
|
if (job.getStatus() == JobStatus.COMPLETED || job.getStatus() == JobStatus.CANCELLED
|
||||||
|
|| job.getStatus() == JobStatus.DELIVERED) {
|
||||||
|
return new com.vaadin.flow.component.html.Span(); // No delete button for completed jobs
|
||||||
|
}
|
||||||
Button deleteBtn = new Button(new Icon(VaadinIcon.TRASH));
|
Button deleteBtn = new Button(new Icon(VaadinIcon.TRASH));
|
||||||
deleteBtn.addThemeVariants(ButtonVariant.LUMO_TERTIARY, ButtonVariant.LUMO_ERROR);
|
deleteBtn.addThemeVariants(ButtonVariant.LUMO_TERTIARY, ButtonVariant.LUMO_ERROR);
|
||||||
deleteBtn.setTooltipText("Auftrag löschen");
|
deleteBtn.setTooltipText("Auftrag löschen");
|
||||||
@@ -187,7 +207,8 @@ public class ShowJobsView extends VerticalLayout {
|
|||||||
private void showDeleteJobDialog(Job job) {
|
private void showDeleteJobDialog(Job job) {
|
||||||
ConfirmDialog dialog = new ConfirmDialog();
|
ConfirmDialog dialog = new ConfirmDialog();
|
||||||
dialog.setHeader("Auftrag löschen");
|
dialog.setHeader("Auftrag löschen");
|
||||||
dialog.setText("Möchten Sie den Auftrag " + job.getJobNumber() + " wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.");
|
dialog.setText("Möchten Sie den Auftrag " + job.getJobNumber()
|
||||||
|
+ " wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.");
|
||||||
dialog.setCancelable(true);
|
dialog.setCancelable(true);
|
||||||
dialog.setCancelText("Abbrechen");
|
dialog.setCancelText("Abbrechen");
|
||||||
dialog.setConfirmText("Löschen");
|
dialog.setConfirmText("Löschen");
|
||||||
@@ -224,11 +245,8 @@ public class ShowJobsView extends VerticalLayout {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Map<String, Object> payload = Map.of(
|
Map<String, Object> payload = Map.of("type", "job_deleted", "jobId", job.getId().toHexString(), "jobNumber",
|
||||||
"type", "job_deleted",
|
job.getJobNumber() != null ? job.getJobNumber() : "", "deletedAt", LocalDateTime.now().toString());
|
||||||
"jobId", job.getId().toHexString(),
|
|
||||||
"jobNumber", job.getJobNumber() != null ? job.getJobNumber() : "",
|
|
||||||
"deletedAt", LocalDateTime.now().toString());
|
|
||||||
|
|
||||||
log.info("[JOB] Sending job_deleted to {}: {}", appUserId, payload);
|
log.info("[JOB] Sending job_deleted to {}: {}", appUserId, payload);
|
||||||
messagingPublisher.publishAsJson(appUserId, "job_deleted", payload);
|
messagingPublisher.publishAsJson(appUserId, "job_deleted", payload);
|
||||||
|
|||||||
@@ -225,7 +225,7 @@ public class StartView extends VerticalLayout implements BeforeEnterObserver {
|
|||||||
featuresGrid.setWidthFull();
|
featuresGrid.setWidthFull();
|
||||||
featuresGrid.setSpacing(true);
|
featuresGrid.setSpacing(true);
|
||||||
featuresGrid.setJustifyContentMode(FlexComponent.JustifyContentMode.CENTER);
|
featuresGrid.setJustifyContentMode(FlexComponent.JustifyContentMode.CENTER);
|
||||||
featuresGrid.setDefaultVerticalComponentAlignment(FlexComponent.Alignment.START);
|
featuresGrid.setDefaultVerticalComponentAlignment(FlexComponent.Alignment.STRETCH);
|
||||||
|
|
||||||
// Feature Cards
|
// Feature Cards
|
||||||
featuresGrid.add(createFeatureCard(VaadinIcon.COG, "Einrichtungsassistent",
|
featuresGrid.add(createFeatureCard(VaadinIcon.COG, "Einrichtungsassistent",
|
||||||
@@ -249,6 +249,7 @@ public class StartView extends VerticalLayout implements BeforeEnterObserver {
|
|||||||
card.getStyle().set("background-color", "var(--lumo-base-color)");
|
card.getStyle().set("background-color", "var(--lumo-base-color)");
|
||||||
card.getStyle().set("box-shadow", "var(--lumo-box-shadow-xs)");
|
card.getStyle().set("box-shadow", "var(--lumo-box-shadow-xs)");
|
||||||
card.setWidth("300px");
|
card.setWidth("300px");
|
||||||
|
card.setHeightFull();
|
||||||
|
|
||||||
Icon icon = iconType.create();
|
Icon icon = iconType.create();
|
||||||
icon.setSize("48px");
|
icon.setSize("48px");
|
||||||
|
|||||||
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("/frontend/**"), new AntPathRequestMatcher("/webjars/**"),
|
||||||
new AntPathRequestMatcher("/h2-console/**"),
|
new AntPathRequestMatcher("/h2-console/**"),
|
||||||
new AntPathRequestMatcher("/frontend-es5/**", "/frontend-es6/**"),
|
new AntPathRequestMatcher("/frontend-es5/**", "/frontend-es6/**"),
|
||||||
new AntPathRequestMatcher("/mcp/**"),
|
new AntPathRequestMatcher("/mcp/**"), new AntPathRequestMatcher("/ws/**"))
|
||||||
new AntPathRequestMatcher("/ws/**"))
|
|
||||||
.permitAll());
|
.permitAll());
|
||||||
|
|
||||||
// Standard-CSRF-Konfiguration
|
// Standard-CSRF-Konfiguration
|
||||||
|
|||||||
@@ -244,14 +244,15 @@ public class CustomerInvoiceService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate a PDF preview from canvas template data.
|
* Generate a PDF preview from canvas template data. Creates an HTML
|
||||||
* Creates an HTML representation of the canvas elements and converts it to PDF.
|
* representation of the canvas elements and converts it to PDF.
|
||||||
*/
|
*/
|
||||||
public byte[] generatePdfFromCanvasTemplate(String jsonTemplateData) throws Exception {
|
public byte[] generatePdfFromCanvasTemplate(String jsonTemplateData) throws Exception {
|
||||||
return generatePdfFromCanvasTemplate(jsonTemplateData, null);
|
return generatePdfFromCanvasTemplate(jsonTemplateData, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
public byte[] generatePdfFromCanvasTemplate(String jsonTemplateData, de.assecutor.votianlt.model.User user) throws Exception {
|
public byte[] generatePdfFromCanvasTemplate(String jsonTemplateData, de.assecutor.votianlt.model.User user)
|
||||||
|
throws Exception {
|
||||||
// Parse the JSON template data
|
// Parse the JSON template data
|
||||||
com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper();
|
com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper();
|
||||||
com.fasterxml.jackson.databind.JsonNode rootNode = mapper.readTree(jsonTemplateData);
|
com.fasterxml.jackson.databind.JsonNode rootNode = mapper.readTree(jsonTemplateData);
|
||||||
@@ -264,7 +265,8 @@ public class CustomerInvoiceService {
|
|||||||
htmlBuilder.append("<meta charset='UTF-8'>");
|
htmlBuilder.append("<meta charset='UTF-8'>");
|
||||||
htmlBuilder.append("<style>");
|
htmlBuilder.append("<style>");
|
||||||
htmlBuilder.append("@page { size: A4; margin: 0; }");
|
htmlBuilder.append("@page { size: A4; margin: 0; }");
|
||||||
htmlBuilder.append("body { margin: 0; padding: 0; width: 210mm; height: 297mm; position: relative; font-family: Arial, sans-serif; }");
|
htmlBuilder.append(
|
||||||
|
"body { margin: 0; padding: 0; width: 210mm; height: 297mm; position: relative; font-family: Arial, sans-serif; }");
|
||||||
htmlBuilder.append(".element { position: absolute; box-sizing: border-box; overflow: hidden; }");
|
htmlBuilder.append(".element { position: absolute; box-sizing: border-box; overflow: hidden; }");
|
||||||
htmlBuilder.append(".text { white-space: nowrap; overflow: visible; }");
|
htmlBuilder.append(".text { white-space: nowrap; overflow: visible; }");
|
||||||
htmlBuilder.append(".line { border-top: 1px solid #333; }");
|
htmlBuilder.append(".line { border-top: 1px solid #333; }");
|
||||||
@@ -316,7 +318,8 @@ public class CustomerInvoiceService {
|
|||||||
text = element.has("text") ? element.get("text").asText("") : "";
|
text = element.has("text") ? element.get("text").asText("") : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use percentage values if available, otherwise fall back to legacy pixel values
|
// Use percentage values if available, otherwise fall back to legacy pixel
|
||||||
|
// values
|
||||||
double xPercent, yPercent, widthPercent, heightPercent;
|
double xPercent, yPercent, widthPercent, heightPercent;
|
||||||
if (element.has("xPercent")) {
|
if (element.has("xPercent")) {
|
||||||
xPercent = element.get("xPercent").asDouble(0);
|
xPercent = element.get("xPercent").asDouble(0);
|
||||||
@@ -359,12 +362,15 @@ public class CustomerInvoiceService {
|
|||||||
htmlBuilder.append("left:").append(String.format(java.util.Locale.US, "%.2f", mmX)).append("mm;");
|
htmlBuilder.append("left:").append(String.format(java.util.Locale.US, "%.2f", mmX)).append("mm;");
|
||||||
htmlBuilder.append("top:").append(String.format(java.util.Locale.US, "%.2f", mmY)).append("mm;");
|
htmlBuilder.append("top:").append(String.format(java.util.Locale.US, "%.2f", mmY)).append("mm;");
|
||||||
htmlBuilder.append("width:").append(String.format(java.util.Locale.US, "%.2f", mmWidth)).append("mm;");
|
htmlBuilder.append("width:").append(String.format(java.util.Locale.US, "%.2f", mmWidth)).append("mm;");
|
||||||
htmlBuilder.append("height:").append(String.format(java.util.Locale.US, "%.2f", mmHeight)).append("mm;");
|
htmlBuilder.append("height:").append(String.format(java.util.Locale.US, "%.2f", mmHeight))
|
||||||
|
.append("mm;");
|
||||||
htmlBuilder.append("font-size:").append(fontSize).append("pt;");
|
htmlBuilder.append("font-size:").append(fontSize).append("pt;");
|
||||||
htmlBuilder.append("line-height:").append(String.format(java.util.Locale.US, "%.2f", fontSize * 1.2)).append("pt;");
|
htmlBuilder.append("line-height:").append(String.format(java.util.Locale.US, "%.2f", fontSize * 1.2))
|
||||||
|
.append("pt;");
|
||||||
htmlBuilder.append("color:").append(color).append(";");
|
htmlBuilder.append("color:").append(color).append(";");
|
||||||
if (!fontStyle.isEmpty()) {
|
if (!fontStyle.isEmpty()) {
|
||||||
if (fontStyle.contains("bold")) htmlBuilder.append("font-weight:bold;");
|
if (fontStyle.contains("bold"))
|
||||||
|
htmlBuilder.append("font-weight:bold;");
|
||||||
}
|
}
|
||||||
// Vertically center content
|
// Vertically center content
|
||||||
htmlBuilder.append("display:flex;align-items:center;");
|
htmlBuilder.append("display:flex;align-items:center;");
|
||||||
@@ -393,17 +399,15 @@ public class CustomerInvoiceService {
|
|||||||
|
|
||||||
// Escape HTML special characters in text AFTER variable replacement
|
// Escape HTML special characters in text AFTER variable replacement
|
||||||
if (text != null) {
|
if (text != null) {
|
||||||
text = text.replace("&", "&")
|
text = text.replace("&", "&").replace("<", "<").replace(">", ">").replace("\"", """)
|
||||||
.replace("<", "<")
|
.replace("'", "'");
|
||||||
.replace(">", ">")
|
|
||||||
.replace("\"", """)
|
|
||||||
.replace("'", "'");
|
|
||||||
} else {
|
} else {
|
||||||
text = "";
|
text = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
if ("line".equals(type)) {
|
if ("line".equals(type)) {
|
||||||
htmlBuilder.append("<hr style='margin:0;border:none;border-top:1px solid #333;height:0;width:100%;'/>");
|
htmlBuilder.append(
|
||||||
|
"<hr style='margin:0;border:none;border-top:1px solid #333;height:0;width:100%;'/>");
|
||||||
} else if ("image".equals(type)) {
|
} else if ("image".equals(type)) {
|
||||||
if (element.has("imageData") && !element.get("imageData").asText().isEmpty()) {
|
if (element.has("imageData") && !element.get("imageData").asText().isEmpty()) {
|
||||||
String imageData = element.get("imageData").asText();
|
String imageData = element.get("imageData").asText();
|
||||||
@@ -412,11 +416,14 @@ public class CustomerInvoiceService {
|
|||||||
imageData = "data:image/png;base64," + imageData;
|
imageData = "data:image/png;base64," + imageData;
|
||||||
}
|
}
|
||||||
// Use object-fit:contain to preserve aspect ratio
|
// Use object-fit:contain to preserve aspect ratio
|
||||||
htmlBuilder.append("<div style='width:100%;height:100%;display:flex;align-items:center;justify-content:center;overflow:hidden;'>");
|
htmlBuilder.append(
|
||||||
htmlBuilder.append("<img src=\"").append(imageData.replace("\"", "%22")).append("\" style='max-width:100%;max-height:100%;object-fit:contain;' alt='Bild' />");
|
"<div style='width:100%;height:100%;display:flex;align-items:center;justify-content:center;overflow:hidden;'>");
|
||||||
|
htmlBuilder.append("<img src=\"").append(imageData.replace("\"", "%22"))
|
||||||
|
.append("\" style='max-width:100%;max-height:100%;object-fit:contain;' alt='Bild' />");
|
||||||
htmlBuilder.append("</div>");
|
htmlBuilder.append("</div>");
|
||||||
} else {
|
} else {
|
||||||
htmlBuilder.append("<div style='width:100%;height:100%;background:#f0f0f0;display:flex;align-items:center;justify-content:center;font-size:10pt;color:#666;'>[Bild]</div>");
|
htmlBuilder.append(
|
||||||
|
"<div style='width:100%;height:100%;background:#f0f0f0;display:flex;align-items:center;justify-content:center;font-size:10pt;color:#666;'>[Bild]</div>");
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Wrap text in a span to prevent flexbox issues
|
// Wrap text in a span to prevent flexbox issues
|
||||||
|
|||||||
@@ -49,8 +49,8 @@ public class LocationService {
|
|||||||
Double heading = extractDouble(payload.get("heading"));
|
Double heading = extractDouble(payload.get("heading"));
|
||||||
Instant timestamp = extractInstant(payload.get("timestamp"));
|
Instant timestamp = extractInstant(payload.get("timestamp"));
|
||||||
|
|
||||||
LocationPosition position = new LocationPosition(
|
LocationPosition position = new LocationPosition(appUserId, latitude, longitude, accuracy, altitude, speed,
|
||||||
appUserId, latitude, longitude, accuracy, altitude, speed, heading, timestamp);
|
heading, timestamp);
|
||||||
|
|
||||||
locationPositionRepository.save(position);
|
locationPositionRepository.save(position);
|
||||||
log.debug("[Location] Saved position for {}: lat={}, lon={}", appUserId, latitude, longitude);
|
log.debug("[Location] Saved position for {}: lat={}, lon={}", appUserId, latitude, longitude);
|
||||||
@@ -68,7 +68,8 @@ public class LocationService {
|
|||||||
* @return The latest position or null if none found
|
* @return The latest position or null if none found
|
||||||
*/
|
*/
|
||||||
public LocationPosition getLatestPosition(String appUserId) {
|
public LocationPosition getLatestPosition(String appUserId) {
|
||||||
List<LocationPosition> positions = locationPositionRepository.findTop1ByAppUserIdOrderByTimestampDesc(appUserId);
|
List<LocationPosition> positions = locationPositionRepository
|
||||||
|
.findTop1ByAppUserIdOrderByTimestampDesc(appUserId);
|
||||||
return positions.isEmpty() ? null : positions.get(0);
|
return positions.isEmpty() ? null : positions.get(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,9 +88,9 @@ public class LocationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cleanup old positions. Runs every 5 minutes.
|
* Cleanup old positions. Runs every 5 minutes. Note: Positions also have a TTL
|
||||||
* Note: Positions also have a TTL index that auto-deletes after 60 minutes,
|
* index that auto-deletes after 60 minutes, but this scheduled cleanup ensures
|
||||||
* but this scheduled cleanup ensures immediate removal and logging.
|
* immediate removal and logging.
|
||||||
*/
|
*/
|
||||||
@Scheduled(fixedRate = 300000) // 5 minutes
|
@Scheduled(fixedRate = 300000) // 5 minutes
|
||||||
public void cleanupOldPositions() {
|
public void cleanupOldPositions() {
|
||||||
|
|||||||
@@ -115,8 +115,8 @@ public class MessageService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Publish message to topic for the receiver.
|
* Publish message to topic for the receiver. Only sends if client is connected,
|
||||||
* Only sends if client is connected, otherwise keeps NOTSEND status.
|
* otherwise keeps NOTSEND status.
|
||||||
*/
|
*/
|
||||||
private void publishMessage(Message message, String receiver) {
|
private void publishMessage(Message message, String receiver) {
|
||||||
try {
|
try {
|
||||||
@@ -132,20 +132,18 @@ public class MessageService {
|
|||||||
byte[] data = objectMapper.writeValueAsString(payload).getBytes(StandardCharsets.UTF_8);
|
byte[] data = objectMapper.writeValueAsString(payload).getBytes(StandardCharsets.UTF_8);
|
||||||
|
|
||||||
// Use WebSocketService directly to get CompletableFuture for delivery tracking
|
// Use WebSocketService directly to get CompletableFuture for delivery tracking
|
||||||
webSocketService.sendToClient(receiver, "message", data)
|
webSocketService.sendToClient(receiver, "message", data).thenRun(() -> {
|
||||||
.thenRun(() -> {
|
// Success: mark as sent
|
||||||
// Success: mark as sent
|
message.markAsSent();
|
||||||
message.markAsSent();
|
messageRepository.save(message);
|
||||||
messageRepository.save(message);
|
log.debug("[Messaging] Message {} delivered to client {}, marked as SEND", message.getIdAsString(),
|
||||||
log.debug("[Messaging] Message {} delivered to client {}, marked as SEND",
|
receiver);
|
||||||
message.getIdAsString(), receiver);
|
}).exceptionally(ex -> {
|
||||||
})
|
// Failed to deliver: keep NOTSEND status
|
||||||
.exceptionally(ex -> {
|
log.debug("[Messaging] Failed to deliver message {} to client {}: {}", message.getIdAsString(),
|
||||||
// Failed to deliver: keep NOTSEND status
|
receiver, ex.getMessage());
|
||||||
log.debug("[Messaging] Failed to deliver message {} to client {}: {}",
|
return null;
|
||||||
message.getIdAsString(), receiver, ex.getMessage());
|
});
|
||||||
return null;
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("[Messaging] Error publishing message: {}", e.getMessage());
|
log.error("[Messaging] Error publishing message: {}", e.getMessage());
|
||||||
@@ -160,8 +158,8 @@ public class MessageService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send pending messages to a client that just connected.
|
* Send pending messages to a client that just connected. Called after
|
||||||
* Called after successful authentication.
|
* successful authentication.
|
||||||
*
|
*
|
||||||
* @param receiver
|
* @param receiver
|
||||||
* AppUser ID (clientId)
|
* AppUser ID (clientId)
|
||||||
@@ -184,16 +182,14 @@ public class MessageService {
|
|||||||
ChatMessageOutboundPayload payload = ChatMessageOutboundPayload.fromMessage(message);
|
ChatMessageOutboundPayload payload = ChatMessageOutboundPayload.fromMessage(message);
|
||||||
byte[] data = objectMapper.writeValueAsString(payload).getBytes(StandardCharsets.UTF_8);
|
byte[] data = objectMapper.writeValueAsString(payload).getBytes(StandardCharsets.UTF_8);
|
||||||
|
|
||||||
webSocketService.sendToClient(receiver, "message", data)
|
webSocketService.sendToClient(receiver, "message", data).thenRun(() -> {
|
||||||
.thenRun(() -> {
|
message.markAsSent();
|
||||||
message.markAsSent();
|
messageRepository.save(message);
|
||||||
messageRepository.save(message);
|
}).exceptionally(ex -> {
|
||||||
})
|
log.error("[Messaging] Failed to send pending message {}: {}", message.getIdAsString(),
|
||||||
.exceptionally(ex -> {
|
ex.getMessage());
|
||||||
log.error("[Messaging] Failed to send pending message {}: {}",
|
return null;
|
||||||
message.getIdAsString(), ex.getMessage());
|
});
|
||||||
return null;
|
|
||||||
});
|
|
||||||
sentCount++;
|
sentCount++;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("[Messaging] Failed to send pending message {}: {}", message.getIdAsString(), e.getMessage());
|
log.error("[Messaging] Failed to send pending message {}: {}", message.getIdAsString(), e.getMessage());
|
||||||
|
|||||||
Reference in New Issue
Block a user