Erweiterungen

This commit is contained in:
2026-02-19 11:29:11 +01:00
parent b34f8a83cc
commit 8b7256232f
4 changed files with 184 additions and 92 deletions

View File

@@ -150,6 +150,10 @@ public class Job {
@Field("route_distance_km") @Field("route_distance_km")
private Double routeDistanceKm; private Double routeDistanceKm;
// Fahrtzeit in Sekunden für die Rechnung
@Field("route_duration_seconds")
private Integer routeDurationSeconds;
/** /**
* Returns the ObjectId as string for JSON serialization. This ensures that the * Returns the ObjectId as string for JSON serialization. This ensures that the
* job id is returned as a string when jobs are retrieved via API. * job id is returned as a string when jobs are retrieved via API.

View File

@@ -130,6 +130,11 @@ public class AddJobView extends Main {
private Span routeDistanceLabel; private Span routeDistanceLabel;
private Span routeDurationLabel; private Span routeDurationLabel;
// Manuelle Eingabe für Route (wenn keine Berechnung erfolgt)
private VerticalLayout manualRouteInputBox;
private com.vaadin.flow.component.textfield.NumberField manualDistanceInput;
private com.vaadin.flow.component.textfield.IntegerField manualDurationInput;
// Date picker fields for appointments // Date picker fields for appointments
private DatePicker pickupDate; private DatePicker pickupDate;
private DatePicker deliveryDate; private DatePicker deliveryDate;
@@ -652,6 +657,56 @@ public class AddJobView extends Main {
routeInfoBox.add(routeTitle, routeRow, durationRow); routeInfoBox.add(routeTitle, routeRow, durationRow);
content.add(routeInfoBox); content.add(routeInfoBox);
// Manuelle Streckeneingabe (wenn keine Route berechnet wurde)
manualRouteInputBox = new VerticalLayout();
manualRouteInputBox.setPadding(true);
manualRouteInputBox.setSpacing(true);
manualRouteInputBox.getStyle().set("border", "1px solid var(--lumo-contrast-30pct)");
manualRouteInputBox.getStyle().set("border-radius", "var(--lumo-border-radius-m)");
manualRouteInputBox.getStyle().set("background-color", "var(--lumo-contrast-5pct)");
manualRouteInputBox.setWidthFull();
manualRouteInputBox.setDefaultHorizontalComponentAlignment(FlexComponent.Alignment.STRETCH);
manualRouteInputBox.setVisible(true); // Initial sichtbar
H3 manualRouteTitle = new H3("Streckeninformation (manuelle Eingabe)");
manualRouteTitle.getStyle().set("margin", "0");
manualRouteTitle.getStyle().set("color", "var(--lumo-secondary-text-color)");
HorizontalLayout manualInputRow = new HorizontalLayout();
manualInputRow.setWidthFull();
manualInputRow.setSpacing(true);
manualDistanceInput = new com.vaadin.flow.component.textfield.NumberField("Entfernung (km)");
manualDistanceInput.setWidthFull();
manualDistanceInput.setPlaceholder("z.B. 125,5");
manualDistanceInput.setMin(0);
manualDistanceInput.setStep(0.1);
manualDistanceInput.setClearButtonVisible(true);
manualDistanceInput.addValueChangeListener(e -> {
// Preise aktualisieren wenn sich die Distanz ändert
updatePriceSummary();
if (servicesGrid != null) {
servicesGrid.getDataProvider().refreshAll();
}
});
manualDurationInput = new com.vaadin.flow.component.textfield.IntegerField("Fahrtzeit (Minuten)");
manualDurationInput.setWidthFull();
manualDurationInput.setPlaceholder("z.B. 90");
manualDurationInput.setMin(0);
manualDurationInput.setStep(1);
manualDurationInput.setClearButtonVisible(true);
manualInputRow.add(manualDistanceInput, manualDurationInput);
Span manualRouteHint = new Span("Bitte geben Sie die Entfernung und Fahrtzeit ein, da keine automatische Routenberechnung durchgeführt wurde.");
manualRouteHint.getStyle().set("font-size", "var(--lumo-font-size-s)");
manualRouteHint.getStyle().set("color", "var(--lumo-secondary-text-color)");
manualRouteHint.getStyle().set("font-style", "italic");
manualRouteInputBox.add(manualRouteTitle, manualInputRow, manualRouteHint);
content.add(manualRouteInputBox);
// Title // Title
H3 servicesTitle = new H3("Leistungen"); H3 servicesTitle = new H3("Leistungen");
servicesTitle.getStyle().set("margin", "0"); servicesTitle.getStyle().set("margin", "0");
@@ -675,10 +730,8 @@ public class AddJobView extends Main {
return ""; return "";
}).setHeader("Berechnung").setSortable(true); }).setHeader("Berechnung").setSortable(true);
servicesGrid.addColumn(service -> { servicesGrid.addColumn(service -> {
// Get route distance for distance-based calculations // Get route distance for distance-based calculations (berechnet oder manuell)
Double routeDistance = (routeCalculationResult != null && routeCalculationResult.isValid()) Double routeDistance = getEffectiveRouteDistance();
? routeCalculationResult.getDistanceKm()
: null;
BigDecimal price = calculateServicePrice(service, routeDistance); BigDecimal price = calculateServicePrice(service, routeDistance);
if (price.compareTo(BigDecimal.ZERO) > 0) { if (price.compareTo(BigDecimal.ZERO) > 0) {
return price.setScale(2, RoundingMode.HALF_UP) + ""; return price.setScale(2, RoundingMode.HALF_UP) + "";
@@ -837,10 +890,8 @@ public class AddJobView extends Main {
BigDecimal vatTotal = BigDecimal.ZERO; BigDecimal vatTotal = BigDecimal.ZERO;
BigDecimal grossTotal = BigDecimal.ZERO; BigDecimal grossTotal = BigDecimal.ZERO;
// Get route distance for distance-based calculations // Get route distance for distance-based calculations (berechnet oder manuell)
Double routeDistance = (routeCalculationResult != null && routeCalculationResult.isValid()) Double routeDistance = getEffectiveRouteDistance();
? routeCalculationResult.getDistanceKm()
: null;
for (Service service : selectedServices) { for (Service service : selectedServices) {
BigDecimal price = calculateServicePrice(service, routeDistance); BigDecimal price = calculateServicePrice(service, routeDistance);
@@ -1459,9 +1510,17 @@ public class AddJobView extends Main {
// Store selected service IDs in job for invoice creation // Store selected service IDs in job for invoice creation
job.setServiceIds(selectedServices.stream().map(Service::getId).toList()); job.setServiceIds(selectedServices.stream().map(Service::getId).toList());
// Store route distance in job for invoice creation // Store route distance and duration in job for invoice creation
if (routeCalculationResult != null && routeCalculationResult.isValid()) { if (routeCalculationResult != null && routeCalculationResult.isValid()) {
// Berechnete Route verwenden
job.setRouteDistanceKm(routeCalculationResult.getDistanceKm()); job.setRouteDistanceKm(routeCalculationResult.getDistanceKm());
job.setRouteDurationSeconds(routeCalculationResult.getDurationSeconds());
} else if (manualDistanceInput != null && manualDistanceInput.getValue() != null) {
// Manuelle Eingabe verwenden
job.setRouteDistanceKm(manualDistanceInput.getValue());
if (manualDurationInput != null && manualDurationInput.getValue() != null) {
job.setRouteDurationSeconds(manualDurationInput.getValue() * 60); // Minuten in Sekunden umrechnen
}
} }
// Validate all required fields using the binder // Validate all required fields using the binder
@@ -3074,16 +3133,19 @@ public class AddJobView extends Main {
/** /**
* Aktualisiert die Route-Info-Box im Preis-Tab mit den aktuellen Routendaten. * Aktualisiert die Route-Info-Box im Preis-Tab mit den aktuellen Routendaten.
* Zeigt entweder die berechnete Route oder die manuelle Eingabe an.
*/ */
private void updateRouteInfoBox() { private void updateRouteInfoBox() {
if (routeInfoBox == null || routeCalculationResult == null) { if (routeInfoBox == null || manualRouteInputBox == null) {
return; return;
} }
if (routeCalculationResult.isValid()) { if (routeCalculationResult != null && routeCalculationResult.isValid()) {
// Berechnete Route anzeigen
routeDistanceLabel.setText(String.format("%.1f km", routeCalculationResult.getDistanceKm())); routeDistanceLabel.setText(String.format("%.1f km", routeCalculationResult.getDistanceKm()));
routeDurationLabel.setText(routeCalculationResult.getFormattedDurationLong()); routeDurationLabel.setText(routeCalculationResult.getFormattedDurationLong());
routeInfoBox.setVisible(true); routeInfoBox.setVisible(true);
manualRouteInputBox.setVisible(false);
// Update price summary and grid with new route distance // Update price summary and grid with new route distance
updatePriceSummary(); updatePriceSummary();
@@ -3091,7 +3153,9 @@ public class AddJobView extends Main {
servicesGrid.getDataProvider().refreshAll(); servicesGrid.getDataProvider().refreshAll();
} }
} else { } else {
// Manuelle Eingabe anzeigen
routeInfoBox.setVisible(false); routeInfoBox.setVisible(false);
manualRouteInputBox.setVisible(true);
} }
} }
@@ -3169,14 +3233,34 @@ public class AddJobView extends Main {
} }
/** /**
* Gibt die Entfernung zwischen Abhol- und Lieferadresse in Kilometern zurück. * Gibt die berechnete oder manuell eingegebene Entfernung zurück (für Preisberechnungen).
* *
* @return Entfernung in km oder 0.0, wenn keine Berechnung durchgeführt wurde * @return Entfernung in km oder 0.0, wenn keine Berechnung oder Eingabe vorhanden ist
*/ */
public double getRouteDistanceKm() { public double getRouteDistanceKm() {
return routeCalculationResult != null && routeCalculationResult.isValid() if (routeCalculationResult != null && routeCalculationResult.isValid()) {
? routeCalculationResult.getDistanceKm() return routeCalculationResult.getDistanceKm();
: 0.0; }
// Manuelle Eingabe verwenden, wenn vorhanden
if (manualDistanceInput != null && manualDistanceInput.getValue() != null) {
return manualDistanceInput.getValue();
}
return 0.0;
}
/**
* Gibt die effektive Routendistanz zurück (berechnet oder manuell eingegeben).
*
* @return Distanz als Double oder null, wenn keine Distanz verfügbar
*/
private Double getEffectiveRouteDistance() {
if (routeCalculationResult != null && routeCalculationResult.isValid()) {
return routeCalculationResult.getDistanceKm();
}
if (manualDistanceInput != null && manualDistanceInput.getValue() != null) {
return manualDistanceInput.getValue();
}
return null;
} }
/** /**
@@ -3253,10 +3337,13 @@ public class AddJobView extends Main {
// Validierungsergebnisse zurücksetzen // Validierungsergebnisse zurücksetzen
addressValidationResults.clear(); addressValidationResults.clear();
// Route-Info-Box im Preis-Tab verstecken // Route-Info-Box im Preis-Tab verstecken und manuelle Eingabe anzeigen
if (routeInfoBox != null) { if (routeInfoBox != null) {
routeInfoBox.setVisible(false); routeInfoBox.setVisible(false);
} }
if (manualRouteInputBox != null) {
manualRouteInputBox.setVisible(true);
}
log.debug("Streckeninformationen zurückgesetzt aufgrund von Adressänderungen"); log.debug("Streckeninformationen zurückgesetzt aufgrund von Adressänderungen");
} }

View File

@@ -220,6 +220,13 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
new Span(String.format("%.1f km", distance)))); new Span(String.format("%.1f km", distance))));
} }
Integer durationSeconds = currentJob.getRouteDurationSeconds();
if (durationSeconds != null && durationSeconds > 0) {
String formattedDuration = formatDuration(durationSeconds);
routeInfo.add(new HorizontalLayout(new Span("Geschätzte Fahrtzeit:"),
new Span(formattedDuration)));
}
section.add(routeInfo); section.add(routeInfo);
return section; return section;
} }
@@ -450,11 +457,6 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
BigDecimal vatAmount = netAmount.multiply(vatRate); BigDecimal vatAmount = netAmount.multiply(vatRate);
BigDecimal totalAmount = netAmount.add(vatAmount); BigDecimal totalAmount = netAmount.add(vatAmount);
// Parse the template and replace variables
com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper();
com.fasterxml.jackson.databind.JsonNode rootNode = mapper.readTree(templateData);
com.fasterxml.jackson.databind.JsonNode elements = rootNode.get("elements");
// Build variable substitution map // Build variable substitution map
Map<String, String> variables = new HashMap<>(); Map<String, String> variables = new HashMap<>();
@@ -570,4 +572,15 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
pdfDialog.getFooter().add(downloadButton, closeButton); pdfDialog.getFooter().add(downloadButton, closeButton);
pdfDialog.open(); pdfDialog.open();
} }
private String formatDuration(int durationSeconds) {
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

@@ -459,17 +459,32 @@ public class JobSummaryView extends Main implements HasUrlParameter<String> {
final String appUserId = showAppUserPosition ? job.getAppUser() : ""; final String appUserId = showAppUserPosition ? job.getAppUser() : "";
final boolean shouldUpdate = showAppUserPosition; final boolean shouldUpdate = showAppUserPosition;
String js = buildMapJs(origin, destination, hasPosition, position, appUserId, shouldUpdate); // Gespeicherte Route-Daten aus dem Job holen
Double savedDistance = job.getRouteDistanceKm();
Integer savedDuration = job.getRouteDurationSeconds();
boolean hasSavedRouteData = savedDistance != null && savedDuration != null;
String js = buildMapJs(origin, destination, hasPosition, position, appUserId, shouldUpdate,
hasSavedRouteData, savedDistance != null ? savedDistance : 0.0, savedDuration != null ? savedDuration : 0);
map.getElement().executeJs(js, map.getElement(), routeInfo.getElement()); map.getElement().executeJs(js, map.getElement(), routeInfo.getElement());
} }
private String buildMapJs(String origin, String destination, boolean hasPosition, LocationPosition position, private String buildMapJs(String origin, String destination, boolean hasPosition, LocationPosition position,
String appUserId, boolean shouldUpdate) { String appUserId, boolean shouldUpdate, boolean hasSavedRouteData, double savedDistance, int savedDuration) {
String apiKey = getGoogleMapsApiKey(); String apiKey = getGoogleMapsApiKey();
// Explizit mit Punkt als Dezimaltrennzeichen formatieren // Explizit mit Punkt als Dezimaltrennzeichen formatieren
String lat = hasPosition ? String.format(java.util.Locale.US, "%.6f", position.getLatitude()) : "0"; String lat = hasPosition ? String.format(java.util.Locale.US, "%.6f", position.getLatitude()) : "0";
String lng = hasPosition ? String.format(java.util.Locale.US, "%.6f", position.getLongitude()) : "0"; String lng = hasPosition ? String.format(java.util.Locale.US, "%.6f", position.getLongitude()) : "0";
// Distanz mit Punkt als Dezimaltrennzeichen für JavaScript
String savedDistanceStr = String.format(java.util.Locale.US, "%.1f", savedDistance);
// Gespeicherte Dauer formatieren
int hours = savedDuration / 3600;
int minutes = (savedDuration % 3600) / 60;
String savedDurationText = hours > 0
? String.format("%d Std. %d Min.", hours, minutes)
: String.format("%d Min.", minutes);
return """ return """
(function(){ (function(){
@@ -483,6 +498,9 @@ public class JobSummaryView extends Main implements HasUrlParameter<String> {
var hasAppUserPos = %s; var hasAppUserPos = %s;
var appUserId = '%s'; var appUserId = '%s';
var shouldUpdate = %s; var shouldUpdate = %s;
var hasSavedRouteData = %s;
var savedDistance = %s;
var savedDurationText = '%s';
var appUserMarker = null; var appUserMarker = null;
var updateInterval = null; var updateInterval = null;
@@ -503,7 +521,7 @@ public class JobSummaryView extends Main implements HasUrlParameter<String> {
origin: origin, origin: origin,
destination: destination, destination: destination,
travelMode: google.maps.TravelMode.DRIVING, travelMode: google.maps.TravelMode.DRIVING,
provideRouteAlternatives: true, provideRouteAlternatives: false,
drivingOptions: { drivingOptions: {
departureTime: new Date(), departureTime: new Date(),
trafficModel: google.maps.TrafficModel.BEST_GUESS trafficModel: google.maps.TrafficModel.BEST_GUESS
@@ -511,75 +529,44 @@ public class JobSummaryView extends Main implements HasUrlParameter<String> {
}, function(res, status){ }, function(res, status){
if(status === 'OK'){ if(status === 'OK'){
infoEl.innerHTML = ''; infoEl.innerHTML = '';
var bounds = new google.maps.LatLngBounds();
var renderers = [];
var polylines = [];
res.routes.forEach(function(route, idx){ // Gespeicherte Route-Daten anzeigen (wenn vorhanden)
if(hasSavedRouteData){
var savedRouteDiv = document.createElement('div');
savedRouteDiv.style.margin = '4px 0 12px 0';
savedRouteDiv.style.padding = '8px';
savedRouteDiv.style.backgroundColor = '#e3f2fd';
savedRouteDiv.style.borderRadius = '4px';
savedRouteDiv.style.fontWeight = 'bold';
savedRouteDiv.textContent = '📍 Geplante Route: ' + savedDistance.toFixed(1) + ' km • Fahrtzeit: ' + savedDurationText;
infoEl.appendChild(savedRouteDiv);
}
var bounds = new google.maps.LatLngBounds();
// Nur die erste (beste) Route anzeigen
var route = res.routes[0];
var dr = new google.maps.DirectionsRenderer({ var dr = new google.maps.DirectionsRenderer({
map: map, map: map,
preserveViewport: idx > 0, preserveViewport: false,
suppressMarkers: false, suppressMarkers: false,
suppressPolylines: true suppressPolylines: true
}); });
dr.setRouteIndex(idx);
dr.setDirections(res); dr.setDirections(res);
renderers.push(dr);
var path = route.overview_path || []; var path = route.overview_path || [];
var poly = new google.maps.Polyline({ var poly = new google.maps.Polyline({
path: path, path: path,
strokeColor: idx === 0 ? '#1976d2' : '#90caf9', strokeColor: '#1976d2',
strokeOpacity: 0.95, strokeOpacity: 0.95,
strokeWeight: idx === 0 ? 6 : 4 strokeWeight: 6
}); });
poly.setMap(map); poly.setMap(map);
polylines.push(poly);
var leg = route.legs && route.legs[0];
if(leg){
var dur = leg.duration ? leg.duration.text : '';
var durT = leg.duration_in_traffic ? leg.duration_in_traffic.text : '';
var dist = leg.distance ? leg.distance.text : '';
var alt = (idx === 0 ? 'Schnellste Route' : 'Alternative ' + idx);
var row = document.createElement('div');
row.style.margin = '4px 0';
row.style.cursor = 'pointer';
row.textContent = alt + ': ' + dist + ' • Dauer: ' + dur + (durT ? ' (mit Verkehr: ' + durT + ')' : '');
row.onmouseenter = function(){
polylines.forEach(function(p, i){
p.setOptions({
strokeColor: i === 0 ? '#90caf9' : '#e3f2fd',
strokeOpacity: 0.6,
strokeWeight: 3
});
});
poly.setOptions({
strokeColor: '#0d47a1',
strokeOpacity: 1,
strokeWeight: 7
});
};
row.onmouseleave = function(){
polylines.forEach(function(p, i){
p.setOptions({
strokeColor: i === 0 ? '#1976d2' : '#90caf9',
strokeOpacity: 0.95,
strokeWeight: i === 0 ? 6 : 4
});
});
};
infoEl.appendChild(row);
// Bounds aus der Route erstellen
if(path && path.length){ if(path && path.length){
path.forEach(function(pt){ bounds.extend(pt); }); path.forEach(function(pt){ bounds.extend(pt); });
} }
}
});
// App-Nutzer Position Marker // App-Nutzer Position Marker
if(hasAppUserPos){ if(hasAppUserPos){
@@ -656,7 +643,8 @@ public class JobSummaryView extends Main implements HasUrlParameter<String> {
})(); })();
""" """
.formatted(escapeJs(origin), escapeJs(destination), escapeJs(apiKey), lat, lng, .formatted(escapeJs(origin), escapeJs(destination), escapeJs(apiKey), lat, lng,
Boolean.toString(hasPosition), escapeJs(appUserId), Boolean.toString(shouldUpdate)); Boolean.toString(hasPosition), escapeJs(appUserId), Boolean.toString(shouldUpdate),
Boolean.toString(hasSavedRouteData), savedDistanceStr, escapeJs(savedDurationText));
} }
// Hilfsfunktion zum einfachen Escapen von JS-Zeichen in Strings // Hilfsfunktion zum einfachen Escapen von JS-Zeichen in Strings