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
*/
@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;
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 de.assecutor.votianlt.model.CargoItem;
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.TodoListTask;
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.pages.service.AppUserService;
import de.assecutor.votianlt.service.JobHistoryService;
import de.assecutor.votianlt.service.LocationService;
import de.assecutor.votianlt.service.MessageService;
import de.assecutor.votianlt.util.DateTimeFormatUtil;
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 AppUserService appUserService;
private final JobHistoryService jobHistoryService;
private final LocationService locationService;
@Value("${app.google.maps.api-key}")
private String googleMapsApiKey;
@@ -83,7 +86,7 @@ public class JobSummaryView extends Main implements HasUrlParameter<String> {
public JobSummaryView(JobRepository jobRepository, CargoItemRepository cargoItemRepository,
TaskRepository taskRepository, SignatureRepository signatureRepository, BarcodeRepository barcodeRepository,
PhotoRepository photoRepository, CommentRepository commentRepository, AppUserService appUserService,
MessageService messageService, JobHistoryService jobHistoryService) {
MessageService messageService, JobHistoryService jobHistoryService, LocationService locationService) {
this.jobRepository = jobRepository;
this.cargoItemRepository = cargoItemRepository;
this.taskRepository = taskRepository;
@@ -93,6 +96,7 @@ public class JobSummaryView extends Main implements HasUrlParameter<String> {
this.commentRepository = commentRepository;
this.appUserService = appUserService;
this.jobHistoryService = jobHistoryService;
this.locationService = locationService;
setSizeFull();
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();
if (origin.isBlank() || destination.isBlank()) {
// Wenn nicht genug Daten vorhanden sind, Karte nicht anzeigen
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();
map.setWidthFull();
map.setHeight("520px");
map.getStyle().set("border", "1px solid var(--lumo-contrast-20pct)");
map.getStyle().set("border-radius", "var(--lumo-border-radius-m)");
// Box für Fahrzeit/Alternativen
Div routeInfo = new Div();
routeInfo.setWidthFull();
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);
String js = ("(function(){" + " var host = $0; var infoEl = $1;" + " function init(){"
+ " var 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: '"
+ escapeJs(origin) + "'," + " destination: '" + escapeJs(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); }); }"
+ " }" + " });" + " 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(); }"
+ "})();");
// Position für JavaScript vorbereiten
final LocationPosition position = appUserPosition;
final boolean hasPosition = position != null && position.getLatitude() != null && position.getLongitude() != null;
final String appUserId = showAppUserPosition ? job.getAppUser() : "";
final boolean shouldUpdate = showAppUserPosition;
String js = buildMapJs(origin, destination, hasPosition, position, appUserId, shouldUpdate);
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
private String escapeJs(String s) {
if (s == null)
@@ -999,17 +1186,14 @@ public class JobSummaryView extends Main implements HasUrlParameter<String> {
return null;
}
private String getGoogleMapsApiKey() {
return googleMapsApiKey;
}
private void resetAllTaskCardHoverStates() {
// Reset hover state for all task cards
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)")
.set("border-color", "var(--lumo-contrast-20pct)");
}
}
private String getGoogleMapsApiKey() {
return googleMapsApiKey != null ? googleMapsApiKey : "";
}
}