Erweiterungen

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

View File

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

View File

@@ -27,13 +27,14 @@ public class LocationApiController {
/**
* Gibt die aktuelle Position eines App-Nutzers zurück.
*
* @param appUserId die ID des App-Nutzers
* @param appUserId
* die ID des App-Nutzers
* @return die aktuelle Position oder 404 wenn keine vorhanden
*/
@GetMapping("/{appUserId}")
public ResponseEntity<LocationResponse> getCurrentPosition(@PathVariable String appUserId) {
LocationPosition position = locationService.getLatestPosition(appUserId);
if (position == null || position.getLatitude() == null || position.getLongitude() == null) {
return ResponseEntity.notFound().build();
}
@@ -44,7 +45,7 @@ public class LocationApiController {
response.setAccuracy(position.getAccuracy());
response.setSpeed(position.getSpeed());
response.setTimestamp(position.getTimestamp());
return ResponseEntity.ok(response);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -9,48 +9,48 @@ import org.springframework.data.mongodb.core.mapping.Document;
import java.time.LocalDateTime;
/**
* Stores invoice template data for a user.
* Contains the JSON representation of the canvas elements.
* Stores invoice template data for a user. Contains the JSON representation of
* the canvas elements.
*/
@Document(collection = "invoice_templates")
@Data
@NoArgsConstructor
@AllArgsConstructor
public class InvoiceTemplate {
@Id
private String id;
/**
* The user ID this template belongs to
*/
private String userId;
/**
* Template name (optional, for future use if multiple templates are supported)
*/
private String name;
/**
* JSON string containing the template data (canvas elements)
*/
private String templateData;
/**
* When the template was created
*/
private LocalDateTime createdAt;
/**
* When the template was last updated
*/
private LocalDateTime updatedAt;
/**
* Version for optimistic locking
*/
private Long version;
public InvoiceTemplate(String userId, String name, String templateData) {
this.userId = userId;
this.name = name;
@@ -58,7 +58,7 @@ public class InvoiceTemplate {
this.createdAt = LocalDateTime.now();
this.updatedAt = LocalDateTime.now();
}
public void updateTemplate(String templateData) {
this.templateData = templateData;
this.updatedAt = LocalDateTime.now();

View File

@@ -133,6 +133,14 @@ public class Job {
@Field("price")
private BigDecimal price;
// Gefahrene Kilometer für Rechnungsstellung
@Field("kilometers_driven")
private Integer kilometersDriven;
// Arbeitszeit in 15-Minuten-Einheiten für Rechnungsstellung
@Field("time_in_15min_units")
private Integer timeIn15MinUnits;
/**
* Returns the ObjectId as string for JSON serialization. This ensures that the
* job id is returned as a string when jobs are retrieved via API.
@@ -141,4 +149,4 @@ public class Job {
public String getIdAsString() {
return id != null ? id.toString() : null;
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -49,10 +49,7 @@ public class InvoiceGeneratorView extends VerticalLayout {
setMargin(false);
setWidth("100%");
setHeight("100%");
getStyle()
.set("overflow", "hidden")
.set("box-sizing", "border-box")
.set("display", "flex")
getStyle().set("overflow", "hidden").set("box-sizing", "border-box").set("display", "flex")
.set("flex-direction", "column");
// Hauptlayout: Links (Templates) | Mitte (Canvas) | Rechts (Eigenschaften)
@@ -66,27 +63,19 @@ public class InvoiceGeneratorView extends VerticalLayout {
VerticalLayout leftPanel = createTemplatesPanel();
leftPanel.setWidth("250px");
leftPanel.setHeightFull();
leftPanel.getStyle()
.set("flex-shrink", "0")
.set("min-width", "250px")
.set("overflow", "auto");
leftPanel.getStyle().set("flex-shrink", "0").set("min-width", "250px").set("overflow", "auto");
// Mitte: Canvas mit Konva.js
VerticalLayout centerPanel = createCanvasPanel();
centerPanel.setWidth("60%");
centerPanel.setHeightFull();
centerPanel.getStyle()
.set("flex-grow", "1")
.set("min-width", "0");
centerPanel.getStyle().set("flex-grow", "1").set("min-width", "0");
// Rechte Seite: Eigenschaften
propertiesPanel = createPropertiesPanel();
propertiesPanel.setWidth("300px");
propertiesPanel.setHeightFull();
propertiesPanel.getStyle()
.set("flex-shrink", "0")
.set("min-width", "300px")
.set("overflow", "auto");
propertiesPanel.getStyle().set("flex-shrink", "0").set("min-width", "300px").set("overflow", "auto");
mainLayout.add(leftPanel, centerPanel, propertiesPanel);
mainLayout.expand(centerPanel);
@@ -96,9 +85,7 @@ public class InvoiceGeneratorView extends VerticalLayout {
// Aktions-Buttons unter dem Canvas (fixe Höhe)
HorizontalLayout actionLayout = createActionButtons();
actionLayout.setHeight("60px");
actionLayout.getStyle()
.set("flex-shrink", "0")
.set("padding", "0 var(--lumo-space-m)");
actionLayout.getStyle().set("flex-shrink", "0").set("padding", "0 var(--lumo-space-m)");
add(actionLayout);
}
@@ -106,14 +93,9 @@ public class InvoiceGeneratorView extends VerticalLayout {
protected void onAttach(AttachEvent attachEvent) {
super.onAttach(attachEvent);
// Register this view instance and initialize the canvas
getElement().executeJs(
"window.invoiceGeneratorView = this;" +
"if (window.invoiceGenerator) {" +
" console.log('Initializing invoice generator...');" +
" window.invoiceGenerator.init();" +
"} else {" +
" console.error('Invoice generator not found');" +
"}");
getElement().executeJs("window.invoiceGeneratorView = this;" + "if (window.invoiceGenerator) {"
+ " console.log('Initializing invoice generator...');" + " window.invoiceGenerator.init();"
+ "} else {" + " console.error('Invoice generator not found');" + "}");
}
private VerticalLayout createTemplatesPanel() {
@@ -121,15 +103,11 @@ public class InvoiceGeneratorView extends VerticalLayout {
panel.setPadding(true);
panel.setSpacing(true);
panel.setHeightFull();
panel.getStyle()
.set("background-color", "var(--lumo-contrast-5pct)")
.set("border-radius", "var(--lumo-border-radius-m)")
.set("overflow", "auto");
panel.getStyle().set("background-color", "var(--lumo-contrast-5pct)")
.set("border-radius", "var(--lumo-border-radius-m)").set("overflow", "auto");
Span header = new Span("Textbausteine");
header.getStyle()
.set("font-weight", "bold")
.set("font-size", "var(--lumo-font-size-l)");
header.getStyle().set("font-weight", "bold").set("font-size", "var(--lumo-font-size-l)");
// Draggable Templates
Div textBlock = createDraggableTemplate("Textfeld", VaadinIcon.TEXT_LABEL, "text");
@@ -150,17 +128,10 @@ public class InvoiceGeneratorView extends VerticalLayout {
private Div createDraggableTemplate(String label, VaadinIcon icon, String type) {
Div template = new Div();
template.setText(label);
template.getStyle()
.set("padding", "var(--lumo-space-m)")
.set("margin", "var(--lumo-space-xs) 0")
.set("background-color", "var(--lumo-base-color)")
.set("border", "1px solid var(--lumo-contrast-20pct)")
.set("border-radius", "var(--lumo-border-radius-m)")
.set("cursor", "grab")
.set("display", "flex")
.set("align-items", "center")
.set("gap", "var(--lumo-space-s)")
.set("user-select", "none");
template.getStyle().set("padding", "var(--lumo-space-m)").set("margin", "var(--lumo-space-xs) 0")
.set("background-color", "var(--lumo-base-color)").set("border", "1px solid var(--lumo-contrast-20pct)")
.set("border-radius", "var(--lumo-border-radius-m)").set("cursor", "grab").set("display", "flex")
.set("align-items", "center").set("gap", "var(--lumo-space-s)").set("user-select", "none");
// Icon hinzufügen
Icon templateIcon = icon.create();
@@ -173,15 +144,12 @@ public class InvoiceGeneratorView extends VerticalLayout {
template.getElement().setAttribute("data-template-label", label);
// JavaScript Event Listener für Drag Start
template.getElement().executeJs(
"this.addEventListener('dragstart', function(e) {" +
" e.dataTransfer.setData('template-type', this.getAttribute('data-template-type'));" +
" e.dataTransfer.setData('template-label', this.getAttribute('data-template-label'));" +
" this.style.opacity = '0.5';" +
"});" +
"this.addEventListener('dragend', function(e) {" +
" this.style.opacity = '1';" +
"});");
template.getElement()
.executeJs("this.addEventListener('dragstart', function(e) {"
+ " e.dataTransfer.setData('template-type', this.getAttribute('data-template-type'));"
+ " e.dataTransfer.setData('template-label', this.getAttribute('data-template-label'));"
+ " this.style.opacity = '0.5';" + "});" + "this.addEventListener('dragend', function(e) {"
+ " this.style.opacity = '1';" + "});");
return template;
}
@@ -197,37 +165,26 @@ public class InvoiceGeneratorView extends VerticalLayout {
canvasContainer.setId("invoice-canvas-container");
canvasContainer.setWidth("100%");
canvasContainer.setHeight("100%");
canvasContainer.getStyle()
.set("background-color", "#e8e8e8")
canvasContainer.getStyle().set("background-color", "#e8e8e8")
.set("border", "2px dashed var(--lumo-contrast-30pct)")
.set("border-radius", "var(--lumo-border-radius-m)")
.set("position", "relative")
.set("overflow", "hidden")
.set("cursor", "default");
.set("border-radius", "var(--lumo-border-radius-m)").set("position", "relative")
.set("overflow", "hidden").set("cursor", "default");
// Drop Zone Event Listener
canvasContainer.getElement().executeJs(
"var container = this;" +
"container.addEventListener('dragover', function(e) {" +
" e.preventDefault();" +
" e.dataTransfer.dropEffect = 'copy';" +
" container.style.borderColor = 'var(--lumo-primary-color)';" +
"});" +
"container.addEventListener('dragleave', function(e) {" +
" container.style.borderColor = 'var(--lumo-contrast-30pct)';" +
"});" +
"container.addEventListener('drop', function(e) {" +
" e.preventDefault();" +
" container.style.borderColor = 'var(--lumo-contrast-30pct)';" +
" var templateType = e.dataTransfer.getData('template-type');" +
" var templateLabel = e.dataTransfer.getData('template-label');" +
" if (templateType && window.invoiceGenerator) {" +
" var rect = container.getBoundingClientRect();" +
" var x = e.clientX - rect.left;" +
" var y = e.clientY - rect.top;" +
" window.invoiceGenerator.addElement(templateType, templateLabel, x, y);" +
" }" +
"});");
canvasContainer.getElement()
.executeJs("var container = this;" + "container.addEventListener('dragover', function(e) {"
+ " e.preventDefault();" + " e.dataTransfer.dropEffect = 'copy';"
+ " container.style.borderColor = 'var(--lumo-primary-color)';" + "});"
+ "container.addEventListener('dragleave', function(e) {"
+ " container.style.borderColor = 'var(--lumo-contrast-30pct)';" + "});"
+ "container.addEventListener('drop', function(e) {" + " e.preventDefault();"
+ " container.style.borderColor = 'var(--lumo-contrast-30pct)';"
+ " var templateType = e.dataTransfer.getData('template-type');"
+ " var templateLabel = e.dataTransfer.getData('template-label');"
+ " if (templateType && window.invoiceGenerator) {"
+ " var rect = container.getBoundingClientRect();" + " var x = e.clientX - rect.left;"
+ " var y = e.clientY - rect.top;"
+ " window.invoiceGenerator.addElement(templateType, templateLabel, x, y);" + " }" + "});");
panel.add(canvasContainer);
panel.expand(canvasContainer);
@@ -240,22 +197,17 @@ public class InvoiceGeneratorView extends VerticalLayout {
panel.setPadding(true);
panel.setSpacing(true);
panel.setHeightFull();
panel.getStyle()
.set("background-color", "var(--lumo-contrast-5pct)")
.set("border-radius", "var(--lumo-border-radius-m)")
.set("overflow", "auto");
panel.getStyle().set("background-color", "var(--lumo-contrast-5pct)")
.set("border-radius", "var(--lumo-border-radius-m)").set("overflow", "auto");
Span header = new Span("Eigenschaften");
header.getStyle()
.set("font-weight", "bold")
.set("font-size", "var(--lumo-font-size-l)");
header.getStyle().set("font-weight", "bold").set("font-size", "var(--lumo-font-size-l)");
// Info-Text wenn kein Element ausgewählt
selectedElementInfo = new Div();
selectedElementInfo.setText("Klicken Sie auf ein Element im Canvas, um dessen Eigenschaften zu bearbeiten.");
selectedElementInfo.getStyle()
.set("color", "var(--lumo-secondary-text-color)")
.set("font-size", "var(--lumo-font-size-s)");
selectedElementInfo.getStyle().set("color", "var(--lumo-secondary-text-color)").set("font-size",
"var(--lumo-font-size-s)");
panel.add(header, selectedElementInfo);
@@ -362,36 +314,29 @@ public class InvoiceGeneratorView extends VerticalLayout {
Div pdfContainer = new Div();
pdfContainer.setWidth("100%");
pdfContainer.setHeight("100%");
pdfContainer.getStyle()
.set("display", "flex")
.set("flex-direction", "column")
.set("overflow", "hidden");
pdfContainer.getStyle().set("display", "flex").set("flex-direction", "column").set("overflow", "hidden");
// Use an iframe with data URL for PDF display
String base64Pdf = java.util.Base64.getEncoder().encodeToString(pdfBytes);
String dataUrl = "data:application/pdf;base64," + base64Pdf;
IFrame pdfFrame = new IFrame();
pdfFrame.setWidth("100%");
pdfFrame.setHeight("100%");
pdfFrame.getElement().setAttribute("src", dataUrl);
pdfFrame.getStyle()
.set("border", "none")
.set("flex-grow", "1");
pdfFrame.getStyle().set("border", "none").set("flex-grow", "1");
pdfContainer.add(pdfFrame);
// Close button
Button closeButton = new Button("Schließen", e -> pdfDialog.close());
closeButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
// Download button
Button downloadButton = new Button("Herunterladen", e -> {
getElement().executeJs(
"const link = document.createElement('a');" +
"link.href = 'data:application/pdf;base64," + base64Pdf + "';" +
"link.download = 'vorschau.pdf';" +
"link.click();");
getElement()
.executeJs("const link = document.createElement('a');" + "link.href = 'data:application/pdf;base64,"
+ base64Pdf + "';" + "link.download = 'vorschau.pdf';" + "link.click();");
});
downloadButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
@@ -412,9 +357,7 @@ public class InvoiceGeneratorView extends VerticalLayout {
propertiesPanel.removeAll();
Span header = new Span("Eigenschaften");
header.getStyle()
.set("font-weight", "bold")
.set("font-size", "var(--lumo-font-size-l)");
header.getStyle().set("font-weight", "bold").set("font-size", "var(--lumo-font-size-l)");
// Element Typ Anzeige
Span typeLabel = new Span("Typ: " + elementType);
@@ -430,7 +373,7 @@ public class InvoiceGeneratorView extends VerticalLayout {
upload.setMaxFileSize(5 * 1024 * 1024); // 5 MB
upload.setDropLabel(new Span("Bild hierher ziehen oder klicken"));
upload.setWidthFull();
upload.addSucceededListener(event -> {
try {
// Bild als Base64 kodieren
@@ -438,22 +381,21 @@ public class InvoiceGeneratorView extends VerticalLayout {
String base64 = java.util.Base64.getEncoder().encodeToString(bytes);
String mimeType = event.getMIMEType();
String dataUrl = "data:" + mimeType + ";base64," + base64;
// An JavaScript übergeben
getElement().executeJs(
"if (window.invoiceGenerator) { window.invoiceGenerator.updateElementImage('"
+ elementId + "', $0); }",
dataUrl);
getElement()
.executeJs("if (window.invoiceGenerator) { window.invoiceGenerator.updateElementImage('"
+ elementId + "', $0); }", dataUrl);
showNotification("Bild erfolgreich hochgeladen");
} catch (Exception ex) {
showNotification("Fehler beim Hochladen: " + ex.getMessage());
}
});
upload.addFileRejectedListener(event -> {
showNotification("Datei abgelehnt: " + event.getErrorMessage());
});
propertiesPanel.add(upload);
}
@@ -463,10 +405,8 @@ public class InvoiceGeneratorView extends VerticalLayout {
textField.setValue(text != null ? text : "");
textField.setWidthFull();
textField.addValueChangeListener(e -> {
getElement().executeJs(
"if (window.invoiceGenerator) { window.invoiceGenerator.updateElementText('" + elementId
+ "', $0); }",
e.getValue());
getElement().executeJs("if (window.invoiceGenerator) { window.invoiceGenerator.updateElementText('"
+ elementId + "', $0); }", e.getValue());
});
propertiesPanel.add(textField);
}
@@ -478,10 +418,9 @@ public class InvoiceGeneratorView extends VerticalLayout {
xField.addValueChangeListener(e -> {
try {
double newX = Double.parseDouble(e.getValue());
getElement().executeJs(
"if (window.invoiceGenerator) { window.invoiceGenerator.updateElementPosition('" + elementId
+ "', $0, null); }",
newX);
getElement()
.executeJs("if (window.invoiceGenerator) { window.invoiceGenerator.updateElementPosition('"
+ elementId + "', $0, null); }", newX);
} catch (NumberFormatException ignored) {
}
});
@@ -494,10 +433,9 @@ public class InvoiceGeneratorView extends VerticalLayout {
yField.addValueChangeListener(e -> {
try {
double newY = Double.parseDouble(e.getValue());
getElement().executeJs(
"if (window.invoiceGenerator) { window.invoiceGenerator.updateElementPosition('" + elementId
+ "', null, $0); }",
newY);
getElement()
.executeJs("if (window.invoiceGenerator) { window.invoiceGenerator.updateElementPosition('"
+ elementId + "', null, $0); }", newY);
} catch (NumberFormatException ignored) {
}
});
@@ -524,69 +462,61 @@ public class InvoiceGeneratorView extends VerticalLayout {
Span colorLabel = new Span("Schriftfarbe");
colorLabel.getStyle().set("font-size", "var(--lumo-font-size-s)");
propertiesPanel.add(colorLabel);
// Aktuelle Farbe anzeigen und klickbar machen
String currentColor = color != null ? color : "#333333";
HorizontalLayout colorPreviewLayout = new HorizontalLayout();
colorPreviewLayout.setSpacing(true);
colorPreviewLayout.setAlignItems(Alignment.CENTER);
colorPreviewLayout.setWidthFull();
colorPreviewLayout.getStyle().set("cursor", "pointer");
// Farbvorschau-Box
Div colorPreview = new Div();
colorPreview.getStyle()
.set("width", "40px")
.set("height", "30px")
.set("background-color", currentColor)
colorPreview.getStyle().set("width", "40px").set("height", "30px").set("background-color", currentColor)
.set("border", "1px solid var(--lumo-contrast-30pct)")
.set("border-radius", "var(--lumo-border-radius-m)");
Span colorHexLabel = new Span(currentColor);
colorHexLabel.getStyle()
.set("font-family", "monospace")
.set("font-size", "var(--lumo-font-size-s)");
colorHexLabel.getStyle().set("font-family", "monospace").set("font-size", "var(--lumo-font-size-s)");
colorPreviewLayout.add(colorPreview, colorHexLabel);
// Color Picker Dialog
Dialog colorDialog = new Dialog();
colorDialog.setHeaderTitle("Schriftfarbe wählen");
VerticalLayout dialogLayout = new VerticalLayout();
dialogLayout.setSpacing(true);
dialogLayout.setPadding(true);
// Color Picker im Dialog
Input dialogColorPicker = new Input();
dialogColorPicker.setType("color");
dialogColorPicker.setValue(currentColor);
dialogColorPicker.getStyle()
.set("width", "100%")
.set("height", "50px")
.set("padding", "0");
dialogColorPicker.getStyle().set("width", "100%").set("height", "50px").set("padding", "0");
// Hex-Eingabe im Dialog
TextField dialogHexField = new TextField("Hex-Farbwert");
dialogHexField.setValue(currentColor);
dialogHexField.setWidthFull();
// Sync zwischen Color Picker und Hex-Feld
dialogColorPicker.addValueChangeListener(e -> {
dialogHexField.setValue(e.getValue());
});
dialogHexField.addValueChangeListener(e -> {
String newColor = e.getValue();
if (newColor.matches("^#[0-9A-Fa-f]{6}$")) {
dialogColorPicker.setValue(newColor);
}
});
dialogLayout.add(dialogColorPicker, dialogHexField);
colorDialog.add(dialogLayout);
// Dialog Buttons
Button dialogCancelButton = new Button("Abbrechen", e -> {
colorDialog.close();
@@ -595,24 +525,22 @@ public class InvoiceGeneratorView extends VerticalLayout {
dialogHexField.setValue(currentColor);
});
dialogCancelButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
Button dialogApplyButton = new Button("Übernehmen", e -> {
String newColor = dialogColorPicker.getValue();
// Update preview
colorPreview.getStyle().set("background-color", newColor);
colorHexLabel.setText(newColor);
// Apply to element
getElement().executeJs(
"if (window.invoiceGenerator) { window.invoiceGenerator.updateElementColor('"
+ elementId + "', $0); }",
newColor);
getElement().executeJs("if (window.invoiceGenerator) { window.invoiceGenerator.updateElementColor('"
+ elementId + "', $0); }", newColor);
colorDialog.close();
showNotification("Farbe übernommen");
});
dialogApplyButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
colorDialog.getFooter().add(dialogCancelButton, dialogApplyButton);
// Öffne Dialog beim Klick auf die Vorschau
Runnable openColorDialog = () -> {
// Aktualisiere Dialog mit aktuellem Wert
@@ -621,11 +549,11 @@ public class InvoiceGeneratorView extends VerticalLayout {
dialogHexField.setValue(actualColor);
colorDialog.open();
};
colorPreviewLayout.addClickListener(e -> openColorDialog.run());
colorPreview.addClickListener(e -> openColorDialog.run());
colorHexLabel.addClickListener(e -> openColorDialog.run());
propertiesPanel.add(colorPreviewLayout);
}
@@ -635,8 +563,7 @@ public class InvoiceGeneratorView extends VerticalLayout {
deleteButton.setWidthFull();
deleteButton.addClickListener(e -> {
getElement().executeJs(
"if (window.invoiceGenerator) { window.invoiceGenerator.deleteElement('" + elementId
+ "'); }");
"if (window.invoiceGenerator) { window.invoiceGenerator.deleteElement('" + elementId + "'); }");
resetPropertiesPanel();
});
propertiesPanel.add(deleteButton);
@@ -652,15 +579,13 @@ public class InvoiceGeneratorView extends VerticalLayout {
propertiesPanel.removeAll();
Span header = new Span("Eigenschaften");
header.getStyle()
.set("font-weight", "bold")
.set("font-size", "var(--lumo-font-size-l)");
header.getStyle().set("font-weight", "bold").set("font-size", "var(--lumo-font-size-l)");
selectedElementInfo = new Div();
selectedElementInfo.setText("Klicken Sie auf ein Element im Canvas, um dessen Eigenschaften zu bearbeiten.");
selectedElementInfo.getStyle()
.set("color", "var(--lumo-secondary-text-color)")
.set("font-size", "var(--lumo-font-size-s)");
selectedElementInfo
.setText("Klicken Sie auf ein Element im Canvas, um dessen Eigenschaften zu bearbeiten.");
selectedElementInfo.getStyle().set("color", "var(--lumo-secondary-text-color)").set("font-size",
"var(--lumo-font-size-s)");
propertiesPanel.add(header, selectedElementInfo);
}));

View File

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

View File

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

View File

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

View File

@@ -8,17 +8,17 @@ import java.util.Optional;
@Repository
public interface InvoiceTemplateRepository extends MongoRepository<InvoiceTemplate, String> {
/**
* Find the invoice template for a specific user
*/
Optional<InvoiceTemplate> findByUserId(String userId);
/**
* Check if a template exists for a user
*/
boolean existsByUserId(String userId);
/**
* Delete the template for a user
*/

View File

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

View File

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

View File

@@ -244,19 +244,20 @@ public class CustomerInvoiceService {
}
/**
* Generate a PDF preview from canvas template data.
* Creates an HTML representation of the canvas elements and converts it to PDF.
* Generate a PDF preview from canvas template data. Creates an HTML
* representation of the canvas elements and converts it to PDF.
*/
public byte[] generatePdfFromCanvasTemplate(String jsonTemplateData) throws Exception {
return generatePdfFromCanvasTemplate(jsonTemplateData, null);
}
public byte[] generatePdfFromCanvasTemplate(String jsonTemplateData, de.assecutor.votianlt.model.User user) throws Exception {
public byte[] generatePdfFromCanvasTemplate(String jsonTemplateData, de.assecutor.votianlt.model.User user)
throws Exception {
// Parse the JSON template data
com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper();
com.fasterxml.jackson.databind.JsonNode rootNode = mapper.readTree(jsonTemplateData);
com.fasterxml.jackson.databind.JsonNode elements = rootNode.get("elements");
// Build HTML content from canvas elements
StringBuilder htmlBuilder = new StringBuilder();
htmlBuilder.append("<!DOCTYPE html>");
@@ -264,7 +265,8 @@ public class CustomerInvoiceService {
htmlBuilder.append("<meta charset='UTF-8'>");
htmlBuilder.append("<style>");
htmlBuilder.append("@page { size: A4; margin: 0; }");
htmlBuilder.append("body { margin: 0; padding: 0; width: 210mm; height: 297mm; position: relative; font-family: Arial, sans-serif; }");
htmlBuilder.append(
"body { margin: 0; padding: 0; width: 210mm; height: 297mm; position: relative; font-family: Arial, sans-serif; }");
htmlBuilder.append(".element { position: absolute; box-sizing: border-box; overflow: hidden; }");
htmlBuilder.append(".text { white-space: nowrap; overflow: visible; }");
htmlBuilder.append(".line { border-top: 1px solid #333; }");
@@ -272,10 +274,10 @@ public class CustomerInvoiceService {
htmlBuilder.append(".image img { width: 100%; height: 100%; object-fit: contain; display: block; }");
htmlBuilder.append("</style>");
htmlBuilder.append("</head><body>");
// Prepare variable substitution map
java.util.Map<String, String> variables = new java.util.HashMap<>();
if (user != null) {
// Use actual user data
String company = user.getCompany();
@@ -294,7 +296,7 @@ public class CustomerInvoiceService {
variables.put("masterdata.email", "kontakt@firma.de");
variables.put("masterdata.phone", "0123 456789");
}
// Customer data (placeholder for now - would come from job/customer selection)
variables.put("customer.company_name", "Kundenfirma GmbH");
variables.put("customer.contact_name", "Erika Mustermann");
@@ -302,7 +304,7 @@ public class CustomerInvoiceService {
variables.put("customer.city", "54321 Kundenstadt");
variables.put("customer.email", "kunde@beispiel.de");
variables.put("customer.phone", "0987 654321");
if (elements != null && elements.isArray()) {
for (com.fasterxml.jackson.databind.JsonNode element : elements) {
String type = element.has("type") ? element.get("type").asText("text") : "text";
@@ -315,8 +317,9 @@ public class CustomerInvoiceService {
} else {
text = element.has("text") ? element.get("text").asText("") : "";
}
// Use percentage values if available, otherwise fall back to legacy pixel values
// Use percentage values if available, otherwise fall back to legacy pixel
// values
double xPercent, yPercent, widthPercent, heightPercent;
if (element.has("xPercent")) {
xPercent = element.get("xPercent").asDouble(0);
@@ -343,34 +346,37 @@ public class CustomerInvoiceService {
double height = element.get("height").asDouble(30);
heightPercent = height / 842.0 * 100;
}
int fontSize = element.has("fontSize") ? element.get("fontSize").asInt(14) : 14;
String fontStyle = element.has("fontStyle") ? element.get("fontStyle").asText("") : "";
String color = element.has("color") ? element.get("color").asText("#333333") : "#333333";
// Convert percentages to mm (A4 is 210mm x 297mm)
double mmX = xPercent / 100.0 * 210.0;
double mmY = yPercent / 100.0 * 297.0;
double mmWidth = widthPercent / 100.0 * 210.0;
double mmHeight = heightPercent / 100.0 * 297.0;
htmlBuilder.append("<div class='element ").append(type).append("' ");
htmlBuilder.append("style='");
htmlBuilder.append("left:").append(String.format(java.util.Locale.US, "%.2f", mmX)).append("mm;");
htmlBuilder.append("top:").append(String.format(java.util.Locale.US, "%.2f", mmY)).append("mm;");
htmlBuilder.append("width:").append(String.format(java.util.Locale.US, "%.2f", mmWidth)).append("mm;");
htmlBuilder.append("height:").append(String.format(java.util.Locale.US, "%.2f", mmHeight)).append("mm;");
htmlBuilder.append("height:").append(String.format(java.util.Locale.US, "%.2f", mmHeight))
.append("mm;");
htmlBuilder.append("font-size:").append(fontSize).append("pt;");
htmlBuilder.append("line-height:").append(String.format(java.util.Locale.US, "%.2f", fontSize * 1.2)).append("pt;");
htmlBuilder.append("line-height:").append(String.format(java.util.Locale.US, "%.2f", fontSize * 1.2))
.append("pt;");
htmlBuilder.append("color:").append(color).append(";");
if (!fontStyle.isEmpty()) {
if (fontStyle.contains("bold")) htmlBuilder.append("font-weight:bold;");
if (fontStyle.contains("bold"))
htmlBuilder.append("font-weight:bold;");
}
// Vertically center content
htmlBuilder.append("display:flex;align-items:center;");
htmlBuilder.append("'");
htmlBuilder.append(">");
// Replace variables with actual values FIRST
if (variable != null && variables.containsKey(variable)) {
text = variables.get(variable);
@@ -390,20 +396,18 @@ public class CustomerInvoiceService {
}
}
}
// Escape HTML special characters in text AFTER variable replacement
if (text != null) {
text = text.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")
.replace("'", "&#x27;");
text = text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("\"", "&quot;")
.replace("'", "&#x27;");
} else {
text = "";
}
if ("line".equals(type)) {
htmlBuilder.append("<hr style='margin:0;border:none;border-top:1px solid #333;height:0;width:100%;'/>");
htmlBuilder.append(
"<hr style='margin:0;border:none;border-top:1px solid #333;height:0;width:100%;'/>");
} else if ("image".equals(type)) {
if (element.has("imageData") && !element.get("imageData").asText().isEmpty()) {
String imageData = element.get("imageData").asText();
@@ -412,27 +416,30 @@ public class CustomerInvoiceService {
imageData = "data:image/png;base64," + imageData;
}
// Use object-fit:contain to preserve aspect ratio
htmlBuilder.append("<div style='width:100%;height:100%;display:flex;align-items:center;justify-content:center;overflow:hidden;'>");
htmlBuilder.append("<img src=\"").append(imageData.replace("\"", "%22")).append("\" style='max-width:100%;max-height:100%;object-fit:contain;' alt='Bild' />");
htmlBuilder.append(
"<div style='width:100%;height:100%;display:flex;align-items:center;justify-content:center;overflow:hidden;'>");
htmlBuilder.append("<img src=\"").append(imageData.replace("\"", "%22"))
.append("\" style='max-width:100%;max-height:100%;object-fit:contain;' alt='Bild' />");
htmlBuilder.append("</div>");
} else {
htmlBuilder.append("<div style='width:100%;height:100%;background:#f0f0f0;display:flex;align-items:center;justify-content:center;font-size:10pt;color:#666;'>[Bild]</div>");
htmlBuilder.append(
"<div style='width:100%;height:100%;background:#f0f0f0;display:flex;align-items:center;justify-content:center;font-size:10pt;color:#666;'>[Bild]</div>");
}
} else {
// Wrap text in a span to prevent flexbox issues
htmlBuilder.append("<span style='white-space:nowrap;'>").append(text).append("</span>");
}
htmlBuilder.append("</div>");
}
}
htmlBuilder.append("</body></html>");
// Generate PDF from HTML
return generatePdfFromHtmlString(htmlBuilder.toString());
}
private String safe(String value) {
return value != null ? value : "";
}

View File

@@ -10,15 +10,15 @@ import java.util.Optional;
@Service
@RequiredArgsConstructor
public class InvoiceTemplateService {
private final InvoiceTemplateRepository invoiceTemplateRepository;
/**
* Save or update the invoice template for a user
*/
public InvoiceTemplate saveTemplate(String userId, String templateData) {
Optional<InvoiceTemplate> existing = invoiceTemplateRepository.findByUserId(userId);
if (existing.isPresent()) {
InvoiceTemplate template = existing.get();
template.updateTemplate(templateData);
@@ -28,21 +28,21 @@ public class InvoiceTemplateService {
return invoiceTemplateRepository.save(newTemplate);
}
}
/**
* Get the invoice template for a user
*/
public Optional<InvoiceTemplate> getTemplateByUserId(String userId) {
return invoiceTemplateRepository.findByUserId(userId);
}
/**
* Check if a template exists for a user
*/
public boolean hasTemplate(String userId) {
return invoiceTemplateRepository.existsByUserId(userId);
}
/**
* Delete the template for a user
*/

View File

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

View File

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