Erweiterungen

This commit is contained in:
2026-02-10 15:08:00 +01:00
parent edb39549bc
commit 49df32fa40
4 changed files with 1192 additions and 949 deletions

Binary file not shown.

View File

@@ -0,0 +1,59 @@
package de.assecutor.votianlt.controller;
import de.assecutor.votianlt.model.LocationPosition;
import de.assecutor.votianlt.service.LocationService;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.time.Instant;
/**
* REST-Controller für Location-bezogene API-Endpunkte.
*/
@RestController
@RequestMapping("/api/location")
@RequiredArgsConstructor
@Slf4j
public class LocationApiController {
private final LocationService locationService;
/**
* Gibt die aktuelle Position eines App-Nutzers zurück.
*
* @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();
}
LocationResponse response = new LocationResponse();
response.setLatitude(position.getLatitude());
response.setLongitude(position.getLongitude());
response.setAccuracy(position.getAccuracy());
response.setSpeed(position.getSpeed());
response.setTimestamp(position.getTimestamp());
return ResponseEntity.ok(response);
}
@Data
public static class LocationResponse {
private Double latitude;
private Double longitude;
private Double accuracy;
private Double speed;
private Instant timestamp;
}
}

View File

@@ -74,7 +74,7 @@ public class LocationPosition {
* Timestamp when the position was received by the server * Timestamp when the position was received by the server
*/ */
@Field("received_at") @Field("received_at")
@Indexed(expireAfterSeconds = 3600) // TTL index: auto-delete after 60 minutes @Indexed(expireAfter = "3600s") // TTL index: auto-delete after 60 minutes
private Instant receivedAt; private Instant receivedAt;
public LocationPosition(String appUserId, Double latitude, Double longitude, Double accuracy, public LocationPosition(String appUserId, Double latitude, Double longitude, Double accuracy,

View File

@@ -21,6 +21,7 @@ import com.vaadin.flow.router.Route;
import com.vaadin.flow.theme.lumo.LumoUtility; import com.vaadin.flow.theme.lumo.LumoUtility;
import de.assecutor.votianlt.model.CargoItem; import de.assecutor.votianlt.model.CargoItem;
import de.assecutor.votianlt.model.Job; import de.assecutor.votianlt.model.Job;
import de.assecutor.votianlt.model.LocationPosition;
import de.assecutor.votianlt.model.task.BaseTask; import de.assecutor.votianlt.model.task.BaseTask;
import de.assecutor.votianlt.model.task.TodoListTask; import de.assecutor.votianlt.model.task.TodoListTask;
import de.assecutor.votianlt.model.task.PhotoTask; import de.assecutor.votianlt.model.task.PhotoTask;
@@ -45,6 +46,7 @@ import de.assecutor.votianlt.model.Comment;
import de.assecutor.votianlt.model.JobStatus; import de.assecutor.votianlt.model.JobStatus;
import de.assecutor.votianlt.pages.service.AppUserService; import de.assecutor.votianlt.pages.service.AppUserService;
import de.assecutor.votianlt.service.JobHistoryService; import de.assecutor.votianlt.service.JobHistoryService;
import de.assecutor.votianlt.service.LocationService;
import de.assecutor.votianlt.service.MessageService; import de.assecutor.votianlt.service.MessageService;
import de.assecutor.votianlt.util.DateTimeFormatUtil; import de.assecutor.votianlt.util.DateTimeFormatUtil;
import com.vaadin.flow.component.confirmdialog.ConfirmDialog; import com.vaadin.flow.component.confirmdialog.ConfirmDialog;
@@ -73,6 +75,7 @@ public class JobSummaryView extends Main implements HasUrlParameter<String> {
private final CommentRepository commentRepository; private final CommentRepository commentRepository;
private final AppUserService appUserService; private final AppUserService appUserService;
private final JobHistoryService jobHistoryService; private final JobHistoryService jobHistoryService;
private final LocationService locationService;
@Value("${app.google.maps.api-key}") @Value("${app.google.maps.api-key}")
private String googleMapsApiKey; private String googleMapsApiKey;
@@ -83,7 +86,7 @@ public class JobSummaryView extends Main implements HasUrlParameter<String> {
public JobSummaryView(JobRepository jobRepository, CargoItemRepository cargoItemRepository, public JobSummaryView(JobRepository jobRepository, CargoItemRepository cargoItemRepository,
TaskRepository taskRepository, SignatureRepository signatureRepository, BarcodeRepository barcodeRepository, TaskRepository taskRepository, SignatureRepository signatureRepository, BarcodeRepository barcodeRepository,
PhotoRepository photoRepository, CommentRepository commentRepository, AppUserService appUserService, PhotoRepository photoRepository, CommentRepository commentRepository, AppUserService appUserService,
MessageService messageService, JobHistoryService jobHistoryService) { MessageService messageService, JobHistoryService jobHistoryService, LocationService locationService) {
this.jobRepository = jobRepository; this.jobRepository = jobRepository;
this.cargoItemRepository = cargoItemRepository; this.cargoItemRepository = cargoItemRepository;
this.taskRepository = taskRepository; this.taskRepository = taskRepository;
@@ -93,6 +96,7 @@ public class JobSummaryView extends Main implements HasUrlParameter<String> {
this.commentRepository = commentRepository; this.commentRepository = commentRepository;
this.appUserService = appUserService; this.appUserService = appUserService;
this.jobHistoryService = jobHistoryService; this.jobHistoryService = jobHistoryService;
this.locationService = locationService;
setSizeFull(); setSizeFull();
addClassNames(LumoUtility.BoxSizing.BORDER, LumoUtility.Display.FLEX, LumoUtility.FlexDirection.COLUMN, addClassNames(LumoUtility.BoxSizing.BORDER, LumoUtility.Display.FLEX, LumoUtility.FlexDirection.COLUMN,
@@ -421,17 +425,27 @@ public class JobSummaryView extends Main implements HasUrlParameter<String> {
+ concatZipCity(job.getDeliveryZip(), job.getDeliveryCity())).trim(); + concatZipCity(job.getDeliveryZip(), job.getDeliveryCity())).trim();
if (origin.isBlank() || destination.isBlank()) { if (origin.isBlank() || destination.isBlank()) {
// Wenn nicht genug Daten vorhanden sind, Karte nicht anzeigen
return; return;
} }
// Prüfe ob App-Tracking aktiviert ist und Job nicht erledigt/storniert
LocationPosition appUserPosition = null;
boolean showAppUserPosition = job.isDigitalProcessing()
&& job.getStatus() != JobStatus.COMPLETED
&& job.getStatus() != JobStatus.CANCELLED
&& job.getAppUser() != null
&& !job.getAppUser().isBlank();
if (showAppUserPosition) {
appUserPosition = locationService.getLatestPosition(job.getAppUser());
}
Div map = new Div(); Div map = new Div();
map.setWidthFull(); map.setWidthFull();
map.setHeight("520px"); map.setHeight("520px");
map.getStyle().set("border", "1px solid var(--lumo-contrast-20pct)"); map.getStyle().set("border", "1px solid var(--lumo-contrast-20pct)");
map.getStyle().set("border-radius", "var(--lumo-border-radius-m)"); map.getStyle().set("border-radius", "var(--lumo-border-radius-m)");
// Box für Fahrzeit/Alternativen
Div routeInfo = new Div(); Div routeInfo = new Div();
routeInfo.setWidthFull(); routeInfo.setWidthFull();
routeInfo.getStyle().set("border", "1px solid var(--lumo-contrast-20pct)"); routeInfo.getStyle().set("border", "1px solid var(--lumo-contrast-20pct)");
@@ -441,45 +455,218 @@ public class JobSummaryView extends Main implements HasUrlParameter<String> {
content.add(map, routeInfo); content.add(map, routeInfo);
String js = ("(function(){" + " var host = $0; var infoEl = $1;" + " function init(){" // Position für JavaScript vorbereiten
+ " var map = new google.maps.Map(host, {center: {lat: 51.163, lng: 10.447}, zoom: 6, mapTypeControl: false});" final LocationPosition position = appUserPosition;
+ " var trafficLayer = new google.maps.TrafficLayer(); trafficLayer.setMap(map);" final boolean hasPosition = position != null && position.getLatitude() != null && position.getLongitude() != null;
+ " var ds = new google.maps.DirectionsService();" + " ds.route({" + " origin: '" final String appUserId = showAppUserPosition ? job.getAppUser() : "";
+ escapeJs(origin) + "'," + " destination: '" + escapeJs(destination) + "'," final boolean shouldUpdate = showAppUserPosition;
+ " travelMode: google.maps.TravelMode.DRIVING," + " provideRouteAlternatives: true,"
+ " drivingOptions: { departureTime: new Date(), trafficModel: google.maps.TrafficModel.BEST_GUESS }" String js = buildMapJs(origin, destination, hasPosition, position, appUserId, shouldUpdate);
+ " }, function(res, status){ if(status==='OK'){ " + " infoEl.innerHTML='';"
+ " var bounds = new google.maps.LatLngBounds();"
+ " var renderers = []; var polylines = [];" + " res.routes.forEach(function(route, idx){"
+ " var dr = new google.maps.DirectionsRenderer({map: map, preserveViewport: idx>0, suppressMarkers:false, suppressPolylines:true});"
+ " dr.setRouteIndex(idx); dr.setDirections(res);" + " renderers.push(dr);"
+ " var path = route.overview_path || [];"
+ " var poly = new google.maps.Polyline({path: path, strokeColor: idx===0?'#1976d2':'#90caf9', strokeOpacity: 0.95, strokeWeight: idx===0?6:4});"
+ " 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);"
+ " if (path && path.length){ path.forEach(function(pt){ bounds.extend(pt); }); }"
+ " }" + " });" + " if (!bounds.isEmpty()) { map.fitBounds(bounds); }"
+ " }});" + " }" + " if (!(window.google && window.google.maps)) {"
+ " var s=document.createElement('script');"
+ " s.src='https://maps.googleapis.com/maps/api/js?key=" + getGoogleMapsApiKey()
+ "&libraries=places';" + " s.onload=init; document.head.appendChild(s);" + " } else { init(); }"
+ "})();");
map.getElement().executeJs(js, map.getElement(), routeInfo.getElement()); map.getElement().executeJs(js, map.getElement(), routeInfo.getElement());
} }
private String buildMapJs(String origin, String destination, boolean hasPosition, LocationPosition position, String appUserId, boolean shouldUpdate) {
String apiKey = getGoogleMapsApiKey();
// Explizit mit Punkt als Dezimaltrennzeichen formatieren
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";
return """
(function(){
var host = $0;
var infoEl = $1;
var origin = '%s';
var destination = '%s';
var apiKey = '%s';
var appUserLat = %s;
var appUserLng = %s;
var hasAppUserPos = %s;
var appUserId = '%s';
var shouldUpdate = %s;
var appUserMarker = null;
var updateInterval = null;
var map = null;
function init(){
map = new google.maps.Map(host, {
center: {lat: 51.163, lng: 10.447},
zoom: 6,
mapTypeControl: false
});
var trafficLayer = new google.maps.TrafficLayer();
trafficLayer.setMap(map);
var ds = new google.maps.DirectionsService();
ds.route({
origin: origin,
destination: destination,
travelMode: google.maps.TravelMode.DRIVING,
provideRouteAlternatives: true,
drivingOptions: {
departureTime: new Date(),
trafficModel: google.maps.TrafficModel.BEST_GUESS
}
}, function(res, status){
if(status === 'OK'){
infoEl.innerHTML = '';
var bounds = new google.maps.LatLngBounds();
var renderers = [];
var polylines = [];
res.routes.forEach(function(route, idx){
var dr = new google.maps.DirectionsRenderer({
map: map,
preserveViewport: idx > 0,
suppressMarkers: false,
suppressPolylines: true
});
dr.setRouteIndex(idx);
dr.setDirections(res);
renderers.push(dr);
var path = route.overview_path || [];
var poly = new google.maps.Polyline({
path: path,
strokeColor: idx === 0 ? '#1976d2' : '#90caf9',
strokeOpacity: 0.95,
strokeWeight: idx === 0 ? 6 : 4
});
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);
if(path && path.length){
path.forEach(function(pt){ bounds.extend(pt); });
}
}
});
// App-Nutzer Position Marker
if(hasAppUserPos){
createOrUpdateAppUserMarker(appUserLat, appUserLng);
bounds.extend({lat: appUserLat, lng: appUserLng});
}
if(!bounds.isEmpty()){
map.fitBounds(bounds);
}
// Alle 30 Sekunden aktualisieren
if(shouldUpdate && appUserId){
startPositionUpdates(appUserId);
}
}
});
}
function createOrUpdateAppUserMarker(lat, lng){
if(appUserMarker){
appUserMarker.setPosition({lat: lat, lng: lng});
} else {
appUserMarker = new google.maps.Marker({
position: {lat: lat, lng: lng},
map: map,
title: 'Position App-Nutzer',
icon: {
path: google.maps.SymbolPath.CIRCLE,
scale: 10,
fillColor: '#4caf50',
fillOpacity: 1,
strokeColor: '#ffffff',
strokeWeight: 2
}
});
var infoWindow = new google.maps.InfoWindow({
content: '<div style="font-weight:bold;">Position App-Nutzer</div>'
});
appUserMarker.addListener('click', function(){
infoWindow.open(map, appUserMarker);
});
}
}
function startPositionUpdates(userId){
updateInterval = setInterval(function(){
fetch('/api/location/' + encodeURIComponent(userId))
.then(function(response){
if(!response.ok) throw new Error('No position');
return response.json();
})
.then(function(data){
if(data && data.latitude && data.longitude){
createOrUpdateAppUserMarker(data.latitude, data.longitude);
}
})
.catch(function(err){
console.log('Location update failed:', err);
});
}, 30000);
}
if(!(window.google && window.google.maps)){
var s = document.createElement('script');
s.src = 'https://maps.googleapis.com/maps/api/js?key=' + apiKey + '&libraries=places';
s.onload = init;
document.head.appendChild(s);
} else {
init();
}
})();
""".formatted(
escapeJs(origin),
escapeJs(destination),
escapeJs(apiKey),
lat,
lng,
Boolean.toString(hasPosition),
escapeJs(appUserId),
Boolean.toString(shouldUpdate)
);
}
// Hilfsfunktion zum einfachen Escapen von JS-Zeichen in Strings // Hilfsfunktion zum einfachen Escapen von JS-Zeichen in Strings
private String escapeJs(String s) { private String escapeJs(String s) {
if (s == null) if (s == null)
@@ -999,17 +1186,14 @@ public class JobSummaryView extends Main implements HasUrlParameter<String> {
return null; return null;
} }
private String getGoogleMapsApiKey() {
return googleMapsApiKey;
}
private void resetAllTaskCardHoverStates() { private void resetAllTaskCardHoverStates() {
// Reset hover state for all task cards
for (Div taskCard : taskCards) { for (Div taskCard : taskCards) {
if (taskCard != null) {
taskCard.getStyle().set("transform", "translateY(0)").set("box-shadow", "0 1px 3px rgba(0, 0, 0, 0.1)") taskCard.getStyle().set("transform", "translateY(0)").set("box-shadow", "0 1px 3px rgba(0, 0, 0, 0.1)")
.set("border-color", "var(--lumo-contrast-20pct)"); .set("border-color", "var(--lumo-contrast-20pct)");
} }
} }
private String getGoogleMapsApiKey() {
return googleMapsApiKey != null ? googleMapsApiKey : "";
} }
} }