diff --git a/src/main/bundles/prod.bundle b/src/main/bundles/prod.bundle index 9921ad6..125a1a7 100644 Binary files a/src/main/bundles/prod.bundle and b/src/main/bundles/prod.bundle differ diff --git a/src/main/java/de/assecutor/votianlt/controller/LocationApiController.java b/src/main/java/de/assecutor/votianlt/controller/LocationApiController.java new file mode 100644 index 0000000..62d780a --- /dev/null +++ b/src/main/java/de/assecutor/votianlt/controller/LocationApiController.java @@ -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 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; + } +} diff --git a/src/main/java/de/assecutor/votianlt/model/LocationPosition.java b/src/main/java/de/assecutor/votianlt/model/LocationPosition.java index 03e8803..834e580 100644 --- a/src/main/java/de/assecutor/votianlt/model/LocationPosition.java +++ b/src/main/java/de/assecutor/votianlt/model/LocationPosition.java @@ -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, diff --git a/src/main/java/de/assecutor/votianlt/pages/view/JobSummaryView.java b/src/main/java/de/assecutor/votianlt/pages/view/JobSummaryView.java index 202db8c..7fb6a1c 100644 --- a/src/main/java/de/assecutor/votianlt/pages/view/JobSummaryView.java +++ b/src/main/java/de/assecutor/votianlt/pages/view/JobSummaryView.java @@ -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; @@ -64,952 +66,1134 @@ import java.util.Locale; @Slf4j public class JobSummaryView extends Main implements HasUrlParameter { - private final JobRepository jobRepository; - private final CargoItemRepository cargoItemRepository; - private final TaskRepository taskRepository; - private final SignatureRepository signatureRepository; - private final BarcodeRepository barcodeRepository; - private final PhotoRepository photoRepository; - private final CommentRepository commentRepository; - private final AppUserService appUserService; - private final JobHistoryService jobHistoryService; - - @Value("${app.google.maps.api-key}") - private String googleMapsApiKey; - - private final VerticalLayout content; - private final List
taskCards = new ArrayList<>(); - - public JobSummaryView(JobRepository jobRepository, CargoItemRepository cargoItemRepository, - TaskRepository taskRepository, SignatureRepository signatureRepository, BarcodeRepository barcodeRepository, - PhotoRepository photoRepository, CommentRepository commentRepository, AppUserService appUserService, - MessageService messageService, JobHistoryService jobHistoryService) { - this.jobRepository = jobRepository; - this.cargoItemRepository = cargoItemRepository; - this.taskRepository = taskRepository; - this.signatureRepository = signatureRepository; - this.barcodeRepository = barcodeRepository; - this.photoRepository = photoRepository; - this.commentRepository = commentRepository; - this.appUserService = appUserService; - this.jobHistoryService = jobHistoryService; - - setSizeFull(); - addClassNames(LumoUtility.BoxSizing.BORDER, LumoUtility.Display.FLEX, LumoUtility.FlexDirection.COLUMN, - LumoUtility.Padding.MEDIUM, LumoUtility.Gap.SMALL); - - content = new VerticalLayout(); - content.setSpacing(true); - content.setPadding(true); - content.setWidthFull(); - } - - @Override - public void setParameter(BeforeEvent event, String parameter) { - content.removeAll(); - removeAll(); // Remove existing toolbar - - if (parameter == null || parameter.isBlank()) { - add(new ViewToolbar("Zusammenfassung")); - content.add(new Span("Fehler: Keine Job-ID angegeben")); - add(content); - return; - } - - ObjectId jobId; - try { - jobId = new ObjectId(parameter); - } catch (Exception e) { - add(new ViewToolbar("Zusammenfassung")); - content.add(new Span("Fehler: Ungültige Job-ID Format: " + parameter)); - add(content); - return; - } - - Job job = jobRepository.findById(jobId).orElse(null); - if (job == null) { - add(new ViewToolbar("Zusammenfassung")); - content.add(new Span("Fehler: Job mit ID " + parameter + " nicht gefunden")); - add(content); - return; - } - - // Create Send Message Button for toolbar - Button sendMessageButton = new Button("Nachricht senden"); - sendMessageButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY); - sendMessageButton.addClickListener(e -> { - // Check if job has an app user assigned - if (job.getAppUser() == null || job.getAppUser().isBlank()) { - Notification.show("Diesem Auftrag ist kein App-Nutzer zugeordnet", 3000, Notification.Position.MIDDLE) - .addThemeVariants(NotificationVariant.LUMO_ERROR); - return; - } - - String appUserId = job.getAppUser(); - String jobNumber = job.getJobNumber() != null ? job.getJobNumber() : job.getId().toHexString(); - - // Navigate to message details view with job conversation - // Format: message-details/{clientId}/job-{jobNumber} - String conversationId = "job-" + jobNumber; - getUI().ifPresent(ui -> ui.navigate("message-details/" + appUserId + "/" + conversationId)); - }); - - // Create Job History Button for toolbar - Button jobHistoryButton = new Button("Job Historie"); - jobHistoryButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY); - jobHistoryButton.addClickListener(e -> { - getUI().ifPresent(ui -> ui.navigate("job_history/" + job.getId().toHexString())); - }); - - // Add toolbar with both buttons in top right (Send Message button on the left) - add(new ViewToolbar("Zusammenfassung", sendMessageButton, jobHistoryButton)); - - List cargo = cargoItemRepository.findByJobId(jobId); - List tasks = taskRepository.findByJobIdOrderByTaskOrderAsc(jobId); - - render(job, cargo, tasks); - add(content); - } - - private void render(Job job, List cargoItems, List tasks) { - content.removeAll(); - - // Kopfzeile: Abholung/Lieferung - HorizontalLayout topRow = new HorizontalLayout(); - topRow.setWidthFull(); - topRow.setSpacing(true); - - VerticalLayout pickupBox = borderedBox(); - pickupBox.add(new H3("Abholung " + formatDateWithTime(job.getPickupDate(), job.getPickupTime()))); - pickupBox.add(new Span(valueOrEmpty(job.getPickupCompany()))); - pickupBox.add(new Span(valueOrEmpty(job.getPickupSalutation()) + (job.getPickupSalutation() != null ? " " : "") - + valueOrEmpty(job.getPickupFirstName()) + (job.getPickupFirstName() != null ? " " : "") - + valueOrEmpty(job.getPickupLastName()))); - pickupBox.add(new Span(concatAddress(job.getPickupStreet(), job.getPickupHouseNumber()))); - pickupBox.add(new Span(concatZipCity(job.getPickupZip(), job.getPickupCity()))); - - VerticalLayout deliveryBox = borderedBox(); - deliveryBox.add(new H3("Lieferung " + formatDateWithTime(job.getDeliveryDate(), job.getDeliveryTime()))); - deliveryBox.add(new Span(valueOrEmpty(job.getDeliveryCompany()))); - deliveryBox.add(new Span(valueOrEmpty(job.getDeliverySalutation()) - + (job.getDeliverySalutation() != null ? " " : "") + valueOrEmpty(job.getDeliveryFirstName()) - + (job.getDeliveryFirstName() != null ? " " : "") + valueOrEmpty(job.getDeliveryLastName()))); - deliveryBox.add(new Span(concatAddress(job.getDeliveryStreet(), job.getDeliveryHouseNumber()))); - deliveryBox.add(new Span(concatZipCity(job.getDeliveryZip(), job.getDeliveryCity()))); - - pickupBox.setWidth("50%"); - deliveryBox.setWidth("50%"); - topRow.add(pickupBox, deliveryBox); - content.add(topRow); - - // Aufgaben - VerticalLayout tasksBox = borderedBox(); - tasksBox.add(new H3("Zu quittierende Aufgaben")); - - // Ensure consistent spacing and width for task cards - tasksBox.setSpacing(false); - tasksBox.getStyle().set("gap", "var(--lumo-space-xs)"); - - // Clear previous task cards - taskCards.clear(); - - if (tasks == null || tasks.isEmpty()) { - tasksBox.add(new Span("Keine Aufgaben")); - } else { - for (BaseTask task : tasks) { - if (task != null) { - // Use getDisplayName() instead of getText() for task display - String displayName = task.getDisplayName(); - if (displayName != null && !displayName.isBlank()) { - Div taskCard = createTaskCard(task, displayName); - taskCards.add(taskCard); // Keep reference for hover reset - tasksBox.add(taskCard); - } - } - } - } - content.add(tasksBox); - - // Fracht und weitere Infos - HorizontalLayout midRow = new HorizontalLayout(); - midRow.setWidthFull(); - midRow.setSpacing(true); - - VerticalLayout cargoBox = borderedBox(); - cargoBox.add(new H3("Zu transportierende Fracht")); - if (cargoItems == null || cargoItems.isEmpty()) { - cargoBox.add(new Span("Keine Frachtangaben")); - } else { - for (CargoItem ci : cargoItems) { - if (ci == null) - continue; - String desc = ci.getDescription(); - Integer qty = ci.getQuantity(); - String dims = dimString(ci); - String weight = ci.getWeightKg() != null ? ci.getWeightKg() + " kg" : ""; - String line = (qty != null ? qty + " x " : "") + (desc != null ? desc : "") - + (dims.isBlank() ? "" : " " + dims) + (weight.isBlank() ? "" : " " + weight); - if (!line.isBlank()) - cargoBox.add(new Span(line)); - } - } - - VerticalLayout infoBox = borderedBox(); - infoBox.add(new H3("Weitere Informationen")); - infoBox.add(new Span("Preis: " + (job.getPrice() != null ? formatPrice(job.getPrice()) : "-"))); - if (job.getRemark() != null && !job.getRemark().isBlank()) { - infoBox.add(new Span("Bemerkung: " + job.getRemark())); - } - if (job.isDigitalProcessing()) { - infoBox.add(new Span("Digitale Abwicklung per App: aktiviert")); - } - if (job.getAppUser() != null && !job.getAppUser().isBlank()) { - infoBox.add(new Span("App-Nutzer: " + resolveAppUserName(job.getAppUser()))); - } - - cargoBox.setWidth("50%"); - infoBox.setWidth("50%"); - midRow.add(cargoBox, infoBox); - content.add(midRow); - - // Google Maps Karte mit Route - addRouteMap(job); - - // Manual completion button for jobs without digital processing - if (!job.isDigitalProcessing() && job.getStatus() != JobStatus.COMPLETED - && job.getStatus() != JobStatus.CANCELLED) { - HorizontalLayout buttonRow = new HorizontalLayout(); - buttonRow.setWidthFull(); - buttonRow.setJustifyContentMode(HorizontalLayout.JustifyContentMode.CENTER); - buttonRow.getStyle().set("margin-top", "var(--lumo-space-l)"); - - Button completeButton = new Button("Auftrag manuell abschließen", new Icon(VaadinIcon.CHECK_CIRCLE)); - completeButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY, ButtonVariant.LUMO_SUCCESS); - completeButton.addClickListener(e -> { - ConfirmDialog dialog = new ConfirmDialog(); - dialog.setHeader("Auftrag abschließen"); - dialog.setText("Möchten Sie den Auftrag " + job.getJobNumber() + " manuell abschließen?"); - dialog.setCancelable(true); - dialog.setCancelText("Abbrechen"); - dialog.setConfirmText("Abschließen"); - dialog.setConfirmButtonTheme("primary"); - dialog.addConfirmListener(ev -> { - try { - JobStatus oldStatus = job.getStatus(); - job.setStatus(JobStatus.COMPLETED); - job.setUpdatedAt(LocalDateTime.now()); - jobRepository.save(job); - jobHistoryService.logStatusChange(job, oldStatus, JobStatus.COMPLETED, "Manuell"); - Notification - .show("Auftrag " + job.getJobNumber() + " wurde abgeschlossen.", 3000, - Notification.Position.BOTTOM_END) - .addThemeVariants(NotificationVariant.LUMO_SUCCESS); - // Re-render the page - getUI().ifPresent(ui -> ui.navigate("job_summary/" + job.getId().toHexString())); - } catch (Exception ex) { - Notification - .show("Fehler beim Abschließen: " + ex.getMessage(), 5000, - Notification.Position.BOTTOM_END) - .addThemeVariants(NotificationVariant.LUMO_ERROR); - } - }); - dialog.open(); - }); - - buttonRow.add(completeButton); - content.add(buttonRow); - } - } - - private VerticalLayout borderedBox() { - VerticalLayout box = new VerticalLayout(); - box.getStyle().set("border", "1px solid var(--lumo-contrast-20pct)"); - box.getStyle().set("border-radius", "var(--lumo-border-radius-m)"); - box.getStyle().set("background-color", "var(--lumo-base-color)"); - box.setPadding(true); - box.setSpacing(false); - return box; - } - - private String formatLocalDate(java.time.LocalDate date) { - try { - return DateTimeFormatUtil.formatDate(date); - } catch (Exception e) { - return ""; - } - } - - private String formatLocalTime(java.time.LocalTime time) { - try { - return DateTimeFormatUtil.formatTime(time); - } catch (Exception e) { - return ""; - } - } - - private String formatDateWithTime(java.time.LocalDate date, java.time.LocalTime time) { - StringBuilder sb = new StringBuilder(); - if (date != null) { - sb.append(formatLocalDate(date)); - if (time != null) { - sb.append(", ").append(formatLocalTime(time)); - } - } - return sb.toString(); - } - - private String valueOrEmpty(String v) { - return v == null ? "" : v; - } - - private String concatAddress(String street, String house) { - String s = valueOrEmpty(street); - String h = valueOrEmpty(house); - return (s + (h.isBlank() ? "" : " " + h)).trim(); - } - - private String concatZipCity(String zip, String city) { - String z = valueOrEmpty(zip); - String c = valueOrEmpty(city); - if (!z.isBlank() && !c.isBlank()) - return z + " " + c; - return (z + " " + c).trim(); - } - - private String dimString(CargoItem ci) { - // Values are stored in cm (not mm), so display directly without division - String len = ci.getLengthMm() != null ? ci.getLengthMm().intValue() + " cm" : ""; - String wid = ci.getWidthMm() != null ? ci.getWidthMm().intValue() + " cm" : ""; - String hei = ci.getHeightMm() != null ? ci.getHeightMm().intValue() + " cm" : ""; - String combined = String.join(" x ", - java.util.stream.Stream.of(len, wid, hei).filter(s -> !s.isBlank()).toList()); - return combined.isBlank() ? "" : combined; - } - - private String formatPrice(java.math.BigDecimal price) { - java.text.NumberFormat nf = java.text.NumberFormat.getCurrencyInstance(Locale.GERMANY); - return nf.format(price); - } - - private String resolveAppUserName(String appUserIdString) { - try { - ObjectId id = new ObjectId(appUserIdString); - AppUser au = appUserService.findById(id); - if (au != null) { - String fn = au.getVorname(); - String ln = au.getNachname(); - String name = (fn != null ? fn : "").trim() + (fn != null && ln != null ? " " : "") - + (ln != null ? ln : ""); - if (!name.isBlank()) - return name; - if (au.getBezeichnung() != null && !au.getBezeichnung().isBlank()) - return au.getBezeichnung(); - if (au.getEmail() != null && !au.getEmail().isBlank()) - return au.getEmail(); - } - } catch (Exception e) { - log.debug("Failed to resolve AppUser name for ID {}: {}", appUserIdString, e.getMessage()); - } - return appUserIdString; // Fallback: show raw string if lookup fails - } - - private void addRouteMap(Job job) { - // Baue Adress-Strings - String origin = (concatAddress(job.getPickupStreet(), job.getPickupHouseNumber()) + ", " - + concatZipCity(job.getPickupZip(), job.getPickupCity())).trim(); - String destination = (concatAddress(job.getDeliveryStreet(), job.getDeliveryHouseNumber()) + ", " - + concatZipCity(job.getDeliveryZip(), job.getDeliveryCity())).trim(); - - if (origin.isBlank() || destination.isBlank()) { - // Wenn nicht genug Daten vorhanden sind, Karte nicht anzeigen - return; - } - - 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)"); - routeInfo.getStyle().set("border-radius", "var(--lumo-border-radius-m)"); - routeInfo.getStyle().set("padding", "var(--lumo-space-m)"); - routeInfo.getStyle().set("background-color", "var(--lumo-base-color)"); - - 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(); }" - + "})();"); - - map.getElement().executeJs(js, map.getElement(), routeInfo.getElement()); - } - - // Hilfsfunktion zum einfachen Escapen von JS-Zeichen in Strings - private String escapeJs(String s) { - if (s == null) - return ""; - return s.replace("\\", "\\\\").replace("'", "\\'").replace("\n", " ").replace("\r", " "); - } - - private void showTaskDetailsDialog(BaseTask task) { - Dialog dialog = new Dialog(); - dialog.setWidth("500px"); - dialog.setResizable(true); - dialog.setDraggable(true); - - // Reset all task card hover states when dialog closes - dialog.addDialogCloseActionListener(e -> resetAllTaskCardHoverStates()); - - VerticalLayout dialogContent = new VerticalLayout(); - dialogContent.setPadding(true); - dialogContent.setSpacing(true); - - // Header - H4 header = new H4("Aufgaben-Details"); - dialogContent.add(header); - - // Task type and status - Span typeSpan = new Span("Typ: " + task.getDisplayName()); - typeSpan.getStyle().set("font-weight", "bold"); - dialogContent.add(typeSpan); - - Span statusSpan = new Span("Status: " + (task.isCompleted() ? "Abgeschlossen" : "Offen")); - if (task.isCompleted()) { - statusSpan.getStyle().set("color", "var(--lumo-success-text-color)"); - } else { - statusSpan.getStyle().set("color", "var(--lumo-error-text-color)"); - } - dialogContent.add(statusSpan); - - // Task-specific details - addTaskSpecificDetails(dialogContent, task); - - // Completion details if completed - if (task.isCompleted()) { - dialogContent.add(new Span("")); // Spacer - if (task.getCompletedAt() != null) { - dialogContent.add(new Span("Abgeschlossen am: " + formatDateTime(task.getCompletedAt()))); - } - if (task.getCompletedBy() != null && !task.getCompletedBy().isBlank()) { - dialogContent.add(new Span("Abgeschlossen von: " + task.getCompletedBy())); - } - } - - // Close button - Button closeButton = new Button("Schließen", e -> { - dialog.close(); - resetAllTaskCardHoverStates(); - }); - closeButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY); - - HorizontalLayout buttonLayout = new HorizontalLayout(closeButton); - buttonLayout.setJustifyContentMode(HorizontalLayout.JustifyContentMode.END); - dialogContent.add(buttonLayout); - - dialog.add(dialogContent); - dialog.open(); - } - - private void addTaskSpecificDetails(VerticalLayout content, BaseTask task) { - if (task instanceof TodoListTask todoTask) { - content.add(new Span("To-Do Items:")); - if (todoTask.getTodoItems() != null && !todoTask.getTodoItems().isEmpty()) { - for (String item : todoTask.getTodoItems()) { - if (item != null && !item.isBlank()) { - Span itemSpan = new Span(" • " + item); - itemSpan.getStyle().set("margin-left", "20px"); - content.add(itemSpan); - } - } - } else { - content.add(new Span(" Keine Items definiert")); - } - } else if (task instanceof PhotoTask photoTask) { - if (photoTask.getMinPhotoCount() != null || photoTask.getMaxPhotoCount() != null) { - String photoInfo = "Fotos: "; - if (photoTask.getMinPhotoCount() != null && photoTask.getMaxPhotoCount() != null) { - photoInfo += photoTask.getMinPhotoCount() + " - " + photoTask.getMaxPhotoCount() - + " Fotos erforderlich"; - } else if (photoTask.getMinPhotoCount() != null) { - photoInfo += "Mindestens " + photoTask.getMinPhotoCount() + " Fotos erforderlich"; - } else if (photoTask.getMaxPhotoCount() != null) { - photoInfo += "Maximal " + photoTask.getMaxPhotoCount() + " Fotos erlaubt"; - } - content.add(new Span(photoInfo)); - } - - // Show photos if task is completed - if (task.isCompleted()) { - try { - ObjectId taskId = new ObjectId(task.getIdAsString()); - List photos = photoRepository.findByTaskId(taskId); - - if (!photos.isEmpty()) { - content.add(new Span("")); // Spacer - - // Collect all photos from all Photo entries - List allPhotos = new ArrayList<>(); - for (Photo photo : photos) { - if (photo.getPhoto() != null && !photo.getPhoto().isBlank()) { - allPhotos.add(photo.getPhoto()); - } - } - - if (!allPhotos.isEmpty()) { - content.add(new Span("Aufgenommene Fotos (" + allPhotos.size() + "):")); - - // Create photo gallery container - Div photoGallery = createPhotoGallery(allPhotos); - content.add(photoGallery); - } - } - } catch (Exception e) { - log.debug("Failed to load photos for task {}: {}", task.getId(), e.getMessage()); - } - } - } else if (task instanceof ConfirmationTask confirmationTask) { - if (confirmationTask.getButtonText() != null && !confirmationTask.getButtonText().isBlank()) { - content.add(new Span("Button-Text: " + confirmationTask.getButtonText())); - } - } else if (task instanceof SignatureTask) { - content.add(new Span("Unterschrift erforderlich")); - - // Show signature if task is completed - if (task.isCompleted()) { - try { - ObjectId taskId = new ObjectId(task.getIdAsString()); - List signatures = signatureRepository.findByTaskId(taskId); - - if (!signatures.isEmpty()) { - content.add(new Span("")); // Spacer - content.add(new Span("Gespeicherte Unterschrift:")); - - // Display the latest signature (assuming one signature per task) - Signature signature = signatures.get(signatures.size() - 1); - String svgContent = signature.getSignatureSvg(); - - if (svgContent != null && !svgContent.isBlank()) { - // Create a div to hold the SVG - Div svgContainer = new Div(); - svgContainer.getStyle().set("border", "1px solid var(--lumo-contrast-20pct)") - .set("border-radius", "var(--lumo-border-radius-m)") - .set("padding", "var(--lumo-space-s)").set("background-color", "white") - .set("width", "100%").set("max-width", "450px").set("overflow", "hidden") - .set("display", "flex").set("align-items", "center") - .set("justify-content", "center"); - - // Process SVG to make it responsive - String responsiveSvg = makeResponsiveSvg(svgContent); - svgContainer.getElement().setProperty("innerHTML", responsiveSvg); - content.add(svgContainer); - } - } - } catch (Exception e) { - log.debug("Failed to load signature for task {}: {}", task.getId(), e.getMessage()); - } - } - } else if (task instanceof BarcodeTask) { - content.add(new Span("Barcode-Scan erforderlich")); - - // Show barcodes if task is completed - if (task.isCompleted()) { - try { - ObjectId taskId = new ObjectId(task.getIdAsString()); - List barcodes = barcodeRepository.findByTaskId(taskId); - - if (!barcodes.isEmpty()) { - content.add(new Span("")); // Spacer - content.add(new Span("Gescannte Barcodes (" + barcodes.size() + "):")); - - // Display all scanned barcodes - for (int i = 0; i < barcodes.size(); i++) { - Barcode barcode = barcodes.get(i); - String barcodeValue = barcode.getBarcode(); - - if (barcodeValue != null && !barcodeValue.isBlank()) { - // Create a styled container for each barcode - Div barcodeContainer = new Div(); - barcodeContainer.getStyle().set("border", "1px solid var(--lumo-contrast-20pct)") - .set("border-radius", "var(--lumo-border-radius-s)") - .set("padding", "var(--lumo-space-s)").set("margin", "var(--lumo-space-xs) 0") - .set("background-color", "var(--lumo-contrast-5pct)") - .set("font-family", "monospace").set("font-size", "var(--lumo-font-size-s)") - .set("word-break", "break-all"); - - Span barcodeSpan = new Span((i + 1) + ". " + barcodeValue); - barcodeContainer.add(barcodeSpan); - content.add(barcodeContainer); - } - } - } - } catch (Exception e) { - log.debug("Failed to load barcodes for task {}: {}", task.getId(), e.getMessage()); - } - } - } else if (task instanceof CommentTask commentTask) { - content.add(new Span("Kommentar erforderlich")); - - if (commentTask.getCommentText() != null && !commentTask.getCommentText().isBlank()) { - content.add(new Span("Hinweis: " + commentTask.getCommentText())); - } - - if (commentTask.isRequired()) { - content.add(new Span("Pflichtfeld")); - } - - // Show comments if task is completed - if (task.isCompleted()) { - try { - ObjectId taskId = new ObjectId(task.getIdAsString()); - List comments = commentRepository.findByTaskIdOrderByCreatedAtDesc(taskId); - - if (!comments.isEmpty()) { - content.add(new Span("Abgegebene Kommentare (" + comments.size() + "):")); - - for (Comment comment : comments) { - Div commentContainer = new Div(); - commentContainer.getStyle().set("background-color", "#f5f5f5") - .set("border", "1px solid #ddd").set("border-radius", "4px").set("padding", "8px") - .set("margin", "4px 0").set("font-family", "monospace") - .set("white-space", "pre-wrap"); - - Span commentText = new Span(comment.getCommentText()); - commentContainer.add(commentText); - content.add(commentContainer); - } - } - } catch (Exception e) { - log.debug("Failed to load comments for task {}: {}", task.getId(), e.getMessage()); - } - } - } - } - - private String formatDateTime(java.time.LocalDateTime dateTime) { - return DateTimeFormatUtil.formatDateTime(dateTime); - } - - private Div createTaskCard(BaseTask task, String displayName) { - Div taskCard = new Div(); - - // Card styling with fixed width - taskCard.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", "var(--lumo-space-xs) 0").set("background-color", "var(--lumo-base-color)") - .set("cursor", "pointer").set("transition", "all 0.2s ease") - .set("box-shadow", "0 1px 3px rgba(0, 0, 0, 0.1)").set("display", "flex").set("align-items", "center") - .set("gap", "var(--lumo-space-m)").set("width", "100%").set("box-sizing", "border-box"); - - // Hover effects - taskCard.getElement().addEventListener("mouseenter", e -> { - taskCard.getStyle().set("transform", "translateY(-2px)").set("box-shadow", "0 4px 12px rgba(0, 0, 0, 0.15)") - .set("border-color", "var(--lumo-primary-color-50pct)"); - }); - - taskCard.getElement().addEventListener("mouseleave", e -> { - 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)"); - }); - - // Task icon based on type - Icon taskIcon = getTaskIcon(task); - taskIcon.getStyle().set("color", - task.isCompleted() ? "var(--lumo-success-color)" : "var(--lumo-primary-color)"); - - // Task content - VerticalLayout taskContent = new VerticalLayout(); - taskContent.setPadding(false); - taskContent.setSpacing(false); - taskContent.getStyle().set("flex-grow", "1"); - - // Task name with order number (display as 1-based instead of 0-based) - String taskNameWithOrder = (task.getTaskOrder() != null ? (task.getTaskOrder() + 1) + ". " : "") + displayName; - Span taskName = new Span(taskNameWithOrder); - taskName.getStyle().set("font-weight", "500").set("font-size", "var(--lumo-font-size-m)").set("color", - task.isCompleted() ? "var(--lumo-success-text-color)" : "var(--lumo-body-text-color)"); - - // Task status/description - Span taskDescription = new Span(getTaskDescription(task)); - taskDescription.getStyle().set("font-size", "var(--lumo-font-size-s)") - .set("color", "var(--lumo-secondary-text-color)").set("margin-top", "var(--lumo-space-xs)"); - - taskContent.add(taskName, taskDescription); - - // Status indicator - Div statusIndicator = new Div(); - statusIndicator.getStyle().set("width", "8px").set("height", "8px").set("border-radius", "50%") - .set("background-color", task.isCompleted() ? "var(--lumo-success-color)" : "var(--lumo-error-color)"); - - taskCard.add(taskIcon, taskContent, statusIndicator); - - // Click handler with hover state reset - taskCard.addClickListener(event -> { - showTaskDetailsDialog(task); - // Reset hover state after dialog interaction - 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)"); - }); - - return taskCard; - } - - private Icon getTaskIcon(BaseTask task) { - if (task instanceof TodoListTask) { - return new Icon(VaadinIcon.LIST); - } else if (task instanceof PhotoTask) { - return new Icon(VaadinIcon.CAMERA); - } else if (task instanceof SignatureTask) { - return new Icon(VaadinIcon.EDIT); - } else if (task instanceof ConfirmationTask) { - return new Icon(VaadinIcon.CHECK_CIRCLE); - } else if (task instanceof BarcodeTask) { - return new Icon(VaadinIcon.BARCODE); - } else if (task instanceof CommentTask) { - return new Icon(VaadinIcon.COMMENT); - } else { - return new Icon(VaadinIcon.TASKS); - } - } - - private String getTaskDescription(BaseTask task) { - if (task.isCompleted()) { - return "Abgeschlossen" - + (task.getCompletedAt() != null ? " am " + formatLocalDate(task.getCompletedAt().toLocalDate()) - : ""); - } - - if (task instanceof TodoListTask todoTask) { - int itemCount = todoTask.getTodoItems() != null ? todoTask.getTodoItems().size() : 0; - return itemCount + " Aufgabe" + (itemCount != 1 ? "n" : "") + " zu erledigen"; - } else if (task instanceof PhotoTask photoTask) { - if (photoTask.getMinPhotoCount() != null && photoTask.getMaxPhotoCount() != null) { - return photoTask.getMinPhotoCount() + "-" + photoTask.getMaxPhotoCount() + " Fotos erforderlich"; - } else if (photoTask.getMinPhotoCount() != null) { - return "Mind. " + photoTask.getMinPhotoCount() + " Foto" - + (photoTask.getMinPhotoCount() != 1 ? "s" : ""); - } else if (photoTask.getMaxPhotoCount() != null) { - return "Max. " + photoTask.getMaxPhotoCount() + " Foto" - + (photoTask.getMaxPhotoCount() != 1 ? "s" : ""); - } else { - return "Foto erforderlich"; - } - } else if (task instanceof SignatureTask) { - return "Unterschrift erforderlich"; - } else if (task instanceof ConfirmationTask confirmationTask) { - if (confirmationTask.getButtonText() != null && !confirmationTask.getButtonText().isBlank()) { - return "Bestätigung: " + confirmationTask.getButtonText(); - } else { - return "Bestätigung erforderlich"; - } - } else if (task instanceof BarcodeTask) { - return "Barcode-Scan erforderlich"; - } else if (task instanceof CommentTask commentTask) { - if (commentTask.getCommentText() != null && !commentTask.getCommentText().isBlank()) { - return "Kommentar: " + commentTask.getCommentText(); - } else { - return "Kommentar erforderlich"; - } - } - - return "Aufgabe offen"; - } - - private Div createPhotoGallery(List photos) { - Div galleryContainer = new Div(); - galleryContainer.getStyle().set("border", "1px solid var(--lumo-contrast-20pct)") - .set("border-radius", "var(--lumo-border-radius-m)").set("padding", "var(--lumo-space-m)") - .set("background-color", "white").set("max-width", "600px").set("min-height", "500px") - .set("height", "500px").set("position", "relative").set("display", "flex").set("align-items", "center") - .set("justify-content", "center"); - - if (photos.size() == 1) { - // Single photo - no navigation needed - Div photoContainer = createPhotoContainer(photos.get(0)); - photoContainer.getStyle().set("flex", "1").set("display", "flex").set("align-items", "center") - .set("justify-content", "center"); - galleryContainer.add(photoContainer); - } else { - // Multiple photos - add navigation - final int[] currentIndex = { 0 }; // Use array to make it effectively final - - // Photo counter - Span photoCounter = new Span((currentIndex[0] + 1) + " / " + photos.size()); - photoCounter.getStyle().set("position", "absolute").set("top", "var(--lumo-space-s)") - .set("right", "var(--lumo-space-s)").set("background-color", "rgba(0, 0, 0, 0.6)") - .set("color", "white").set("padding", "var(--lumo-space-xs) var(--lumo-space-s)") - .set("border-radius", "var(--lumo-border-radius-s)").set("font-size", "var(--lumo-font-size-s)") - .set("z-index", "10"); - - // Photo container - Div photoContainer = createPhotoContainer(photos.get(0)); - photoContainer.getStyle().set("margin", "0 40px") // Space for buttons - .set("flex", "1").set("display", "flex").set("align-items", "center") - .set("justify-content", "center"); - - // Previous button - Button prevButton = new Button(new Icon(VaadinIcon.CHEVRON_LEFT)); - prevButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY, ButtonVariant.LUMO_ICON); - prevButton.getStyle().set("position", "absolute").set("left", "var(--lumo-space-s)").set("top", "50%") - .set("transform", "translateY(-50%)").set("background-color", "rgba(255, 255, 255, 0.8)") - .set("border-radius", "50%").set("z-index", "10"); - - // Next button - Button nextButton = new Button(new Icon(VaadinIcon.CHEVRON_RIGHT)); - nextButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY, ButtonVariant.LUMO_ICON); - nextButton.getStyle().set("position", "absolute").set("right", "var(--lumo-space-s)").set("top", "50%") - .set("transform", "translateY(-50%)").set("background-color", "rgba(255, 255, 255, 0.8)") - .set("border-radius", "50%").set("z-index", "10"); - - // Navigation logic - prevButton.addClickListener(e -> { - if (currentIndex[0] > 0) { - currentIndex[0]--; - updatePhotoDisplay(photoContainer, photos.get(currentIndex[0]), photoCounter, currentIndex[0] + 1, - photos.size()); - } - prevButton.setEnabled(currentIndex[0] > 0); - nextButton.setEnabled(currentIndex[0] < photos.size() - 1); - }); - - nextButton.addClickListener(e -> { - if (currentIndex[0] < photos.size() - 1) { - currentIndex[0]++; - updatePhotoDisplay(photoContainer, photos.get(currentIndex[0]), photoCounter, currentIndex[0] + 1, - photos.size()); - } - prevButton.setEnabled(currentIndex[0] > 0); - nextButton.setEnabled(currentIndex[0] < photos.size() - 1); - }); - - // Initial button states - prevButton.setEnabled(false); - nextButton.setEnabled(photos.size() > 1); - - galleryContainer.add(photoCounter, photoContainer, prevButton, nextButton); - } - - return galleryContainer; - } - - private Div createPhotoContainer(String base64Photo) { - Div photoContainer = new Div(); - photoContainer.getStyle().set("width", "100%").set("height", "100%").set("display", "flex") - .set("align-items", "center").set("justify-content", "center").set("overflow", "hidden"); - - // Create image element - String imgSrc = base64Photo.startsWith("data:") ? base64Photo : "data:image/jpeg;base64," + base64Photo; - - photoContainer.getElement().setProperty("innerHTML", ""); - - return photoContainer; - } - - private void updatePhotoDisplay(Div photoContainer, String base64Photo, Span counter, int current, int total) { - String imgSrc = base64Photo.startsWith("data:") ? base64Photo : "data:image/jpeg;base64," + base64Photo; - - photoContainer.getElement().setProperty("innerHTML", ""); - - counter.setText(current + " / " + total); - } - - private String makeResponsiveSvg(String svgContent) { - if (svgContent == null || svgContent.isBlank()) { - return svgContent; - } - - // Remove any existing width and height attributes and add responsive styling - String responsiveSvg = svgContent.replaceAll("width\\s*=\\s*[\"'][^\"']*[\"']", "") - .replaceAll("height\\s*=\\s*[\"'][^\"']*[\"']", "").replaceAll("style\\s*=\\s*[\"'][^\"']*[\"']", ""); - - // Add responsive styling - preserve viewBox if it exists, otherwise try to - // extract from width/height - if (!responsiveSvg.contains("viewBox")) { - // Try to extract original dimensions for viewBox - String widthMatch = extractAttribute(svgContent, "width"); - String heightMatch = extractAttribute(svgContent, "height"); - - if (widthMatch != null && heightMatch != null) { - try { - // Clean numbers (remove px, pt, etc.) - String cleanWidth = widthMatch.replaceAll("[^0-9.]", ""); - String cleanHeight = heightMatch.replaceAll("[^0-9.]", ""); - - if (!cleanWidth.isEmpty() && !cleanHeight.isEmpty()) { - responsiveSvg = responsiveSvg.replaceFirst(" taskCards = new ArrayList<>(); + + public JobSummaryView(JobRepository jobRepository, CargoItemRepository cargoItemRepository, + TaskRepository taskRepository, SignatureRepository signatureRepository, BarcodeRepository barcodeRepository, + PhotoRepository photoRepository, CommentRepository commentRepository, AppUserService appUserService, + MessageService messageService, JobHistoryService jobHistoryService, LocationService locationService) { + this.jobRepository = jobRepository; + this.cargoItemRepository = cargoItemRepository; + this.taskRepository = taskRepository; + this.signatureRepository = signatureRepository; + this.barcodeRepository = barcodeRepository; + this.photoRepository = photoRepository; + this.commentRepository = commentRepository; + this.appUserService = appUserService; + this.jobHistoryService = jobHistoryService; + this.locationService = locationService; + + setSizeFull(); + addClassNames(LumoUtility.BoxSizing.BORDER, LumoUtility.Display.FLEX, LumoUtility.FlexDirection.COLUMN, + LumoUtility.Padding.MEDIUM, LumoUtility.Gap.SMALL); + + content = new VerticalLayout(); + content.setSpacing(true); + content.setPadding(true); + content.setWidthFull(); + } + + @Override + public void setParameter(BeforeEvent event, String parameter) { + content.removeAll(); + removeAll(); // Remove existing toolbar + + if (parameter == null || parameter.isBlank()) { + add(new ViewToolbar("Zusammenfassung")); + content.add(new Span("Fehler: Keine Job-ID angegeben")); + add(content); + return; + } + + ObjectId jobId; + try { + jobId = new ObjectId(parameter); + } catch (Exception e) { + add(new ViewToolbar("Zusammenfassung")); + content.add(new Span("Fehler: Ungültige Job-ID Format: " + parameter)); + add(content); + return; + } + + Job job = jobRepository.findById(jobId).orElse(null); + if (job == null) { + add(new ViewToolbar("Zusammenfassung")); + content.add(new Span("Fehler: Job mit ID " + parameter + " nicht gefunden")); + add(content); + return; + } + + // Create Send Message Button for toolbar + Button sendMessageButton = new Button("Nachricht senden"); + sendMessageButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY); + sendMessageButton.addClickListener(e -> { + // Check if job has an app user assigned + if (job.getAppUser() == null || job.getAppUser().isBlank()) { + Notification.show("Diesem Auftrag ist kein App-Nutzer zugeordnet", 3000, Notification.Position.MIDDLE) + .addThemeVariants(NotificationVariant.LUMO_ERROR); + return; + } + + String appUserId = job.getAppUser(); + String jobNumber = job.getJobNumber() != null ? job.getJobNumber() : job.getId().toHexString(); + + // Navigate to message details view with job conversation + // Format: message-details/{clientId}/job-{jobNumber} + String conversationId = "job-" + jobNumber; + getUI().ifPresent(ui -> ui.navigate("message-details/" + appUserId + "/" + conversationId)); + }); + + // Create Job History Button for toolbar + Button jobHistoryButton = new Button("Job Historie"); + jobHistoryButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY); + jobHistoryButton.addClickListener(e -> { + getUI().ifPresent(ui -> ui.navigate("job_history/" + job.getId().toHexString())); + }); + + // Add toolbar with both buttons in top right (Send Message button on the left) + add(new ViewToolbar("Zusammenfassung", sendMessageButton, jobHistoryButton)); + + List cargo = cargoItemRepository.findByJobId(jobId); + List tasks = taskRepository.findByJobIdOrderByTaskOrderAsc(jobId); + + render(job, cargo, tasks); + add(content); + } + + private void render(Job job, List cargoItems, List tasks) { + content.removeAll(); + + // Kopfzeile: Abholung/Lieferung + HorizontalLayout topRow = new HorizontalLayout(); + topRow.setWidthFull(); + topRow.setSpacing(true); + + VerticalLayout pickupBox = borderedBox(); + pickupBox.add(new H3("Abholung " + formatDateWithTime(job.getPickupDate(), job.getPickupTime()))); + pickupBox.add(new Span(valueOrEmpty(job.getPickupCompany()))); + pickupBox.add(new Span(valueOrEmpty(job.getPickupSalutation()) + (job.getPickupSalutation() != null ? " " : "") + + valueOrEmpty(job.getPickupFirstName()) + (job.getPickupFirstName() != null ? " " : "") + + valueOrEmpty(job.getPickupLastName()))); + pickupBox.add(new Span(concatAddress(job.getPickupStreet(), job.getPickupHouseNumber()))); + pickupBox.add(new Span(concatZipCity(job.getPickupZip(), job.getPickupCity()))); + + VerticalLayout deliveryBox = borderedBox(); + deliveryBox.add(new H3("Lieferung " + formatDateWithTime(job.getDeliveryDate(), job.getDeliveryTime()))); + deliveryBox.add(new Span(valueOrEmpty(job.getDeliveryCompany()))); + deliveryBox.add(new Span(valueOrEmpty(job.getDeliverySalutation()) + + (job.getDeliverySalutation() != null ? " " : "") + valueOrEmpty(job.getDeliveryFirstName()) + + (job.getDeliveryFirstName() != null ? " " : "") + valueOrEmpty(job.getDeliveryLastName()))); + deliveryBox.add(new Span(concatAddress(job.getDeliveryStreet(), job.getDeliveryHouseNumber()))); + deliveryBox.add(new Span(concatZipCity(job.getDeliveryZip(), job.getDeliveryCity()))); + + pickupBox.setWidth("50%"); + deliveryBox.setWidth("50%"); + topRow.add(pickupBox, deliveryBox); + content.add(topRow); + + // Aufgaben + VerticalLayout tasksBox = borderedBox(); + tasksBox.add(new H3("Zu quittierende Aufgaben")); + + // Ensure consistent spacing and width for task cards + tasksBox.setSpacing(false); + tasksBox.getStyle().set("gap", "var(--lumo-space-xs)"); + + // Clear previous task cards + taskCards.clear(); + + if (tasks == null || tasks.isEmpty()) { + tasksBox.add(new Span("Keine Aufgaben")); + } else { + for (BaseTask task : tasks) { + if (task != null) { + // Use getDisplayName() instead of getText() for task display + String displayName = task.getDisplayName(); + if (displayName != null && !displayName.isBlank()) { + Div taskCard = createTaskCard(task, displayName); + taskCards.add(taskCard); // Keep reference for hover reset + tasksBox.add(taskCard); + } + } + } + } + content.add(tasksBox); + + // Fracht und weitere Infos + HorizontalLayout midRow = new HorizontalLayout(); + midRow.setWidthFull(); + midRow.setSpacing(true); + + VerticalLayout cargoBox = borderedBox(); + cargoBox.add(new H3("Zu transportierende Fracht")); + if (cargoItems == null || cargoItems.isEmpty()) { + cargoBox.add(new Span("Keine Frachtangaben")); + } else { + for (CargoItem ci : cargoItems) { + if (ci == null) + continue; + String desc = ci.getDescription(); + Integer qty = ci.getQuantity(); + String dims = dimString(ci); + String weight = ci.getWeightKg() != null ? ci.getWeightKg() + " kg" : ""; + String line = (qty != null ? qty + " x " : "") + (desc != null ? desc : "") + + (dims.isBlank() ? "" : " " + dims) + (weight.isBlank() ? "" : " " + weight); + if (!line.isBlank()) + cargoBox.add(new Span(line)); + } + } + + VerticalLayout infoBox = borderedBox(); + infoBox.add(new H3("Weitere Informationen")); + infoBox.add(new Span("Preis: " + (job.getPrice() != null ? formatPrice(job.getPrice()) : "-"))); + if (job.getRemark() != null && !job.getRemark().isBlank()) { + infoBox.add(new Span("Bemerkung: " + job.getRemark())); + } + if (job.isDigitalProcessing()) { + infoBox.add(new Span("Digitale Abwicklung per App: aktiviert")); + } + if (job.getAppUser() != null && !job.getAppUser().isBlank()) { + infoBox.add(new Span("App-Nutzer: " + resolveAppUserName(job.getAppUser()))); + } + + cargoBox.setWidth("50%"); + infoBox.setWidth("50%"); + midRow.add(cargoBox, infoBox); + content.add(midRow); + + // Google Maps Karte mit Route + addRouteMap(job); + + // Manual completion button for jobs without digital processing + if (!job.isDigitalProcessing() && job.getStatus() != JobStatus.COMPLETED + && job.getStatus() != JobStatus.CANCELLED) { + HorizontalLayout buttonRow = new HorizontalLayout(); + buttonRow.setWidthFull(); + buttonRow.setJustifyContentMode(HorizontalLayout.JustifyContentMode.CENTER); + buttonRow.getStyle().set("margin-top", "var(--lumo-space-l)"); + + Button completeButton = new Button("Auftrag manuell abschließen", new Icon(VaadinIcon.CHECK_CIRCLE)); + completeButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY, ButtonVariant.LUMO_SUCCESS); + completeButton.addClickListener(e -> { + ConfirmDialog dialog = new ConfirmDialog(); + dialog.setHeader("Auftrag abschließen"); + dialog.setText("Möchten Sie den Auftrag " + job.getJobNumber() + " manuell abschließen?"); + dialog.setCancelable(true); + dialog.setCancelText("Abbrechen"); + dialog.setConfirmText("Abschließen"); + dialog.setConfirmButtonTheme("primary"); + dialog.addConfirmListener(ev -> { + try { + JobStatus oldStatus = job.getStatus(); + job.setStatus(JobStatus.COMPLETED); + job.setUpdatedAt(LocalDateTime.now()); + jobRepository.save(job); + jobHistoryService.logStatusChange(job, oldStatus, JobStatus.COMPLETED, "Manuell"); + Notification + .show("Auftrag " + job.getJobNumber() + " wurde abgeschlossen.", 3000, + Notification.Position.BOTTOM_END) + .addThemeVariants(NotificationVariant.LUMO_SUCCESS); + // Re-render the page + getUI().ifPresent(ui -> ui.navigate("job_summary/" + job.getId().toHexString())); + } catch (Exception ex) { + Notification + .show("Fehler beim Abschließen: " + ex.getMessage(), 5000, + Notification.Position.BOTTOM_END) + .addThemeVariants(NotificationVariant.LUMO_ERROR); + } + }); + dialog.open(); + }); + + buttonRow.add(completeButton); + content.add(buttonRow); + } + } + + private VerticalLayout borderedBox() { + VerticalLayout box = new VerticalLayout(); + box.getStyle().set("border", "1px solid var(--lumo-contrast-20pct)"); + box.getStyle().set("border-radius", "var(--lumo-border-radius-m)"); + box.getStyle().set("background-color", "var(--lumo-base-color)"); + box.setPadding(true); + box.setSpacing(false); + return box; + } + + private String formatLocalDate(java.time.LocalDate date) { + try { + return DateTimeFormatUtil.formatDate(date); + } catch (Exception e) { + return ""; + } + } + + private String formatLocalTime(java.time.LocalTime time) { + try { + return DateTimeFormatUtil.formatTime(time); + } catch (Exception e) { + return ""; + } + } + + private String formatDateWithTime(java.time.LocalDate date, java.time.LocalTime time) { + StringBuilder sb = new StringBuilder(); + if (date != null) { + sb.append(formatLocalDate(date)); + if (time != null) { + sb.append(", ").append(formatLocalTime(time)); + } + } + return sb.toString(); + } + + private String valueOrEmpty(String v) { + return v == null ? "" : v; + } + + private String concatAddress(String street, String house) { + String s = valueOrEmpty(street); + String h = valueOrEmpty(house); + return (s + (h.isBlank() ? "" : " " + h)).trim(); + } + + private String concatZipCity(String zip, String city) { + String z = valueOrEmpty(zip); + String c = valueOrEmpty(city); + if (!z.isBlank() && !c.isBlank()) + return z + " " + c; + return (z + " " + c).trim(); + } + + private String dimString(CargoItem ci) { + // Values are stored in cm (not mm), so display directly without division + String len = ci.getLengthMm() != null ? ci.getLengthMm().intValue() + " cm" : ""; + String wid = ci.getWidthMm() != null ? ci.getWidthMm().intValue() + " cm" : ""; + String hei = ci.getHeightMm() != null ? ci.getHeightMm().intValue() + " cm" : ""; + String combined = String.join(" x ", + java.util.stream.Stream.of(len, wid, hei).filter(s -> !s.isBlank()).toList()); + return combined.isBlank() ? "" : combined; + } + + private String formatPrice(java.math.BigDecimal price) { + java.text.NumberFormat nf = java.text.NumberFormat.getCurrencyInstance(Locale.GERMANY); + return nf.format(price); + } + + private String resolveAppUserName(String appUserIdString) { + try { + ObjectId id = new ObjectId(appUserIdString); + AppUser au = appUserService.findById(id); + if (au != null) { + String fn = au.getVorname(); + String ln = au.getNachname(); + String name = (fn != null ? fn : "").trim() + (fn != null && ln != null ? " " : "") + + (ln != null ? ln : ""); + if (!name.isBlank()) + return name; + if (au.getBezeichnung() != null && !au.getBezeichnung().isBlank()) + return au.getBezeichnung(); + if (au.getEmail() != null && !au.getEmail().isBlank()) + return au.getEmail(); + } + } catch (Exception e) { + log.debug("Failed to resolve AppUser name for ID {}: {}", appUserIdString, e.getMessage()); + } + return appUserIdString; // Fallback: show raw string if lookup fails + } + + private void addRouteMap(Job job) { + // Baue Adress-Strings + String origin = (concatAddress(job.getPickupStreet(), job.getPickupHouseNumber()) + ", " + + concatZipCity(job.getPickupZip(), job.getPickupCity())).trim(); + String destination = (concatAddress(job.getDeliveryStreet(), job.getDeliveryHouseNumber()) + ", " + + concatZipCity(job.getDeliveryZip(), job.getDeliveryCity())).trim(); + + if (origin.isBlank() || destination.isBlank()) { + 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)"); + + Div routeInfo = new Div(); + routeInfo.setWidthFull(); + routeInfo.getStyle().set("border", "1px solid var(--lumo-contrast-20pct)"); + routeInfo.getStyle().set("border-radius", "var(--lumo-border-radius-m)"); + routeInfo.getStyle().set("padding", "var(--lumo-space-m)"); + routeInfo.getStyle().set("background-color", "var(--lumo-base-color)"); + + content.add(map, routeInfo); + + // 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: '
Position App-Nutzer
' + }); + + 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) + return ""; + return s.replace("\\", "\\\\").replace("'", "\\'").replace("\n", " ").replace("\r", " "); + } + + private void showTaskDetailsDialog(BaseTask task) { + Dialog dialog = new Dialog(); + dialog.setWidth("500px"); + dialog.setResizable(true); + dialog.setDraggable(true); + + // Reset all task card hover states when dialog closes + dialog.addDialogCloseActionListener(e -> resetAllTaskCardHoverStates()); + + VerticalLayout dialogContent = new VerticalLayout(); + dialogContent.setPadding(true); + dialogContent.setSpacing(true); + + // Header + H4 header = new H4("Aufgaben-Details"); + dialogContent.add(header); + + // Task type and status + Span typeSpan = new Span("Typ: " + task.getDisplayName()); + typeSpan.getStyle().set("font-weight", "bold"); + dialogContent.add(typeSpan); + + Span statusSpan = new Span("Status: " + (task.isCompleted() ? "Abgeschlossen" : "Offen")); + if (task.isCompleted()) { + statusSpan.getStyle().set("color", "var(--lumo-success-text-color)"); + } else { + statusSpan.getStyle().set("color", "var(--lumo-error-text-color)"); + } + dialogContent.add(statusSpan); + + // Task-specific details + addTaskSpecificDetails(dialogContent, task); + + // Completion details if completed + if (task.isCompleted()) { + dialogContent.add(new Span("")); // Spacer + if (task.getCompletedAt() != null) { + dialogContent.add(new Span("Abgeschlossen am: " + formatDateTime(task.getCompletedAt()))); + } + if (task.getCompletedBy() != null && !task.getCompletedBy().isBlank()) { + dialogContent.add(new Span("Abgeschlossen von: " + task.getCompletedBy())); + } + } + + // Close button + Button closeButton = new Button("Schließen", e -> { + dialog.close(); + resetAllTaskCardHoverStates(); + }); + closeButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY); + + HorizontalLayout buttonLayout = new HorizontalLayout(closeButton); + buttonLayout.setJustifyContentMode(HorizontalLayout.JustifyContentMode.END); + dialogContent.add(buttonLayout); + + dialog.add(dialogContent); + dialog.open(); + } + + private void addTaskSpecificDetails(VerticalLayout content, BaseTask task) { + if (task instanceof TodoListTask todoTask) { + content.add(new Span("To-Do Items:")); + if (todoTask.getTodoItems() != null && !todoTask.getTodoItems().isEmpty()) { + for (String item : todoTask.getTodoItems()) { + if (item != null && !item.isBlank()) { + Span itemSpan = new Span(" • " + item); + itemSpan.getStyle().set("margin-left", "20px"); + content.add(itemSpan); + } + } + } else { + content.add(new Span(" Keine Items definiert")); + } + } else if (task instanceof PhotoTask photoTask) { + if (photoTask.getMinPhotoCount() != null || photoTask.getMaxPhotoCount() != null) { + String photoInfo = "Fotos: "; + if (photoTask.getMinPhotoCount() != null && photoTask.getMaxPhotoCount() != null) { + photoInfo += photoTask.getMinPhotoCount() + " - " + photoTask.getMaxPhotoCount() + + " Fotos erforderlich"; + } else if (photoTask.getMinPhotoCount() != null) { + photoInfo += "Mindestens " + photoTask.getMinPhotoCount() + " Fotos erforderlich"; + } else if (photoTask.getMaxPhotoCount() != null) { + photoInfo += "Maximal " + photoTask.getMaxPhotoCount() + " Fotos erlaubt"; + } + content.add(new Span(photoInfo)); + } + + // Show photos if task is completed + if (task.isCompleted()) { + try { + ObjectId taskId = new ObjectId(task.getIdAsString()); + List photos = photoRepository.findByTaskId(taskId); + + if (!photos.isEmpty()) { + content.add(new Span("")); // Spacer + + // Collect all photos from all Photo entries + List allPhotos = new ArrayList<>(); + for (Photo photo : photos) { + if (photo.getPhoto() != null && !photo.getPhoto().isBlank()) { + allPhotos.add(photo.getPhoto()); + } + } + + if (!allPhotos.isEmpty()) { + content.add(new Span("Aufgenommene Fotos (" + allPhotos.size() + "):")); + + // Create photo gallery container + Div photoGallery = createPhotoGallery(allPhotos); + content.add(photoGallery); + } + } + } catch (Exception e) { + log.debug("Failed to load photos for task {}: {}", task.getId(), e.getMessage()); + } + } + } else if (task instanceof ConfirmationTask confirmationTask) { + if (confirmationTask.getButtonText() != null && !confirmationTask.getButtonText().isBlank()) { + content.add(new Span("Button-Text: " + confirmationTask.getButtonText())); + } + } else if (task instanceof SignatureTask) { + content.add(new Span("Unterschrift erforderlich")); + + // Show signature if task is completed + if (task.isCompleted()) { + try { + ObjectId taskId = new ObjectId(task.getIdAsString()); + List signatures = signatureRepository.findByTaskId(taskId); + + if (!signatures.isEmpty()) { + content.add(new Span("")); // Spacer + content.add(new Span("Gespeicherte Unterschrift:")); + + // Display the latest signature (assuming one signature per task) + Signature signature = signatures.get(signatures.size() - 1); + String svgContent = signature.getSignatureSvg(); + + if (svgContent != null && !svgContent.isBlank()) { + // Create a div to hold the SVG + Div svgContainer = new Div(); + svgContainer.getStyle().set("border", "1px solid var(--lumo-contrast-20pct)") + .set("border-radius", "var(--lumo-border-radius-m)") + .set("padding", "var(--lumo-space-s)").set("background-color", "white") + .set("width", "100%").set("max-width", "450px").set("overflow", "hidden") + .set("display", "flex").set("align-items", "center") + .set("justify-content", "center"); + + // Process SVG to make it responsive + String responsiveSvg = makeResponsiveSvg(svgContent); + svgContainer.getElement().setProperty("innerHTML", responsiveSvg); + content.add(svgContainer); + } + } + } catch (Exception e) { + log.debug("Failed to load signature for task {}: {}", task.getId(), e.getMessage()); + } + } + } else if (task instanceof BarcodeTask) { + content.add(new Span("Barcode-Scan erforderlich")); + + // Show barcodes if task is completed + if (task.isCompleted()) { + try { + ObjectId taskId = new ObjectId(task.getIdAsString()); + List barcodes = barcodeRepository.findByTaskId(taskId); + + if (!barcodes.isEmpty()) { + content.add(new Span("")); // Spacer + content.add(new Span("Gescannte Barcodes (" + barcodes.size() + "):")); + + // Display all scanned barcodes + for (int i = 0; i < barcodes.size(); i++) { + Barcode barcode = barcodes.get(i); + String barcodeValue = barcode.getBarcode(); + + if (barcodeValue != null && !barcodeValue.isBlank()) { + // Create a styled container for each barcode + Div barcodeContainer = new Div(); + barcodeContainer.getStyle().set("border", "1px solid var(--lumo-contrast-20pct)") + .set("border-radius", "var(--lumo-border-radius-s)") + .set("padding", "var(--lumo-space-s)").set("margin", "var(--lumo-space-xs) 0") + .set("background-color", "var(--lumo-contrast-5pct)") + .set("font-family", "monospace").set("font-size", "var(--lumo-font-size-s)") + .set("word-break", "break-all"); + + Span barcodeSpan = new Span((i + 1) + ". " + barcodeValue); + barcodeContainer.add(barcodeSpan); + content.add(barcodeContainer); + } + } + } + } catch (Exception e) { + log.debug("Failed to load barcodes for task {}: {}", task.getId(), e.getMessage()); + } + } + } else if (task instanceof CommentTask commentTask) { + content.add(new Span("Kommentar erforderlich")); + + if (commentTask.getCommentText() != null && !commentTask.getCommentText().isBlank()) { + content.add(new Span("Hinweis: " + commentTask.getCommentText())); + } + + if (commentTask.isRequired()) { + content.add(new Span("Pflichtfeld")); + } + + // Show comments if task is completed + if (task.isCompleted()) { + try { + ObjectId taskId = new ObjectId(task.getIdAsString()); + List comments = commentRepository.findByTaskIdOrderByCreatedAtDesc(taskId); + + if (!comments.isEmpty()) { + content.add(new Span("Abgegebene Kommentare (" + comments.size() + "):")); + + for (Comment comment : comments) { + Div commentContainer = new Div(); + commentContainer.getStyle().set("background-color", "#f5f5f5") + .set("border", "1px solid #ddd").set("border-radius", "4px").set("padding", "8px") + .set("margin", "4px 0").set("font-family", "monospace") + .set("white-space", "pre-wrap"); + + Span commentText = new Span(comment.getCommentText()); + commentContainer.add(commentText); + content.add(commentContainer); + } + } + } catch (Exception e) { + log.debug("Failed to load comments for task {}: {}", task.getId(), e.getMessage()); + } + } + } + } + + private String formatDateTime(java.time.LocalDateTime dateTime) { + return DateTimeFormatUtil.formatDateTime(dateTime); + } + + private Div createTaskCard(BaseTask task, String displayName) { + Div taskCard = new Div(); + + // Card styling with fixed width + taskCard.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", "var(--lumo-space-xs) 0").set("background-color", "var(--lumo-base-color)") + .set("cursor", "pointer").set("transition", "all 0.2s ease") + .set("box-shadow", "0 1px 3px rgba(0, 0, 0, 0.1)").set("display", "flex").set("align-items", "center") + .set("gap", "var(--lumo-space-m)").set("width", "100%").set("box-sizing", "border-box"); + + // Hover effects + taskCard.getElement().addEventListener("mouseenter", e -> { + taskCard.getStyle().set("transform", "translateY(-2px)").set("box-shadow", "0 4px 12px rgba(0, 0, 0, 0.15)") + .set("border-color", "var(--lumo-primary-color-50pct)"); + }); + + taskCard.getElement().addEventListener("mouseleave", e -> { + 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)"); + }); + + // Task icon based on type + Icon taskIcon = getTaskIcon(task); + taskIcon.getStyle().set("color", + task.isCompleted() ? "var(--lumo-success-color)" : "var(--lumo-primary-color)"); + + // Task content + VerticalLayout taskContent = new VerticalLayout(); + taskContent.setPadding(false); + taskContent.setSpacing(false); + taskContent.getStyle().set("flex-grow", "1"); + + // Task name with order number (display as 1-based instead of 0-based) + String taskNameWithOrder = (task.getTaskOrder() != null ? (task.getTaskOrder() + 1) + ". " : "") + displayName; + Span taskName = new Span(taskNameWithOrder); + taskName.getStyle().set("font-weight", "500").set("font-size", "var(--lumo-font-size-m)").set("color", + task.isCompleted() ? "var(--lumo-success-text-color)" : "var(--lumo-body-text-color)"); + + // Task status/description + Span taskDescription = new Span(getTaskDescription(task)); + taskDescription.getStyle().set("font-size", "var(--lumo-font-size-s)") + .set("color", "var(--lumo-secondary-text-color)").set("margin-top", "var(--lumo-space-xs)"); + + taskContent.add(taskName, taskDescription); + + // Status indicator + Div statusIndicator = new Div(); + statusIndicator.getStyle().set("width", "8px").set("height", "8px").set("border-radius", "50%") + .set("background-color", task.isCompleted() ? "var(--lumo-success-color)" : "var(--lumo-error-color)"); + + taskCard.add(taskIcon, taskContent, statusIndicator); + + // Click handler with hover state reset + taskCard.addClickListener(event -> { + showTaskDetailsDialog(task); + // Reset hover state after dialog interaction + 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)"); + }); + + return taskCard; + } + + private Icon getTaskIcon(BaseTask task) { + if (task instanceof TodoListTask) { + return new Icon(VaadinIcon.LIST); + } else if (task instanceof PhotoTask) { + return new Icon(VaadinIcon.CAMERA); + } else if (task instanceof SignatureTask) { + return new Icon(VaadinIcon.EDIT); + } else if (task instanceof ConfirmationTask) { + return new Icon(VaadinIcon.CHECK_CIRCLE); + } else if (task instanceof BarcodeTask) { + return new Icon(VaadinIcon.BARCODE); + } else if (task instanceof CommentTask) { + return new Icon(VaadinIcon.COMMENT); + } else { + return new Icon(VaadinIcon.TASKS); + } + } + + private String getTaskDescription(BaseTask task) { + if (task.isCompleted()) { + return "Abgeschlossen" + + (task.getCompletedAt() != null ? " am " + formatLocalDate(task.getCompletedAt().toLocalDate()) + : ""); + } + + if (task instanceof TodoListTask todoTask) { + int itemCount = todoTask.getTodoItems() != null ? todoTask.getTodoItems().size() : 0; + return itemCount + " Aufgabe" + (itemCount != 1 ? "n" : "") + " zu erledigen"; + } else if (task instanceof PhotoTask photoTask) { + if (photoTask.getMinPhotoCount() != null && photoTask.getMaxPhotoCount() != null) { + return photoTask.getMinPhotoCount() + "-" + photoTask.getMaxPhotoCount() + " Fotos erforderlich"; + } else if (photoTask.getMinPhotoCount() != null) { + return "Mind. " + photoTask.getMinPhotoCount() + " Foto" + + (photoTask.getMinPhotoCount() != 1 ? "s" : ""); + } else if (photoTask.getMaxPhotoCount() != null) { + return "Max. " + photoTask.getMaxPhotoCount() + " Foto" + + (photoTask.getMaxPhotoCount() != 1 ? "s" : ""); + } else { + return "Foto erforderlich"; + } + } else if (task instanceof SignatureTask) { + return "Unterschrift erforderlich"; + } else if (task instanceof ConfirmationTask confirmationTask) { + if (confirmationTask.getButtonText() != null && !confirmationTask.getButtonText().isBlank()) { + return "Bestätigung: " + confirmationTask.getButtonText(); + } else { + return "Bestätigung erforderlich"; + } + } else if (task instanceof BarcodeTask) { + return "Barcode-Scan erforderlich"; + } else if (task instanceof CommentTask commentTask) { + if (commentTask.getCommentText() != null && !commentTask.getCommentText().isBlank()) { + return "Kommentar: " + commentTask.getCommentText(); + } else { + return "Kommentar erforderlich"; + } + } + + return "Aufgabe offen"; + } + + private Div createPhotoGallery(List photos) { + Div galleryContainer = new Div(); + galleryContainer.getStyle().set("border", "1px solid var(--lumo-contrast-20pct)") + .set("border-radius", "var(--lumo-border-radius-m)").set("padding", "var(--lumo-space-m)") + .set("background-color", "white").set("max-width", "600px").set("min-height", "500px") + .set("height", "500px").set("position", "relative").set("display", "flex").set("align-items", "center") + .set("justify-content", "center"); + + if (photos.size() == 1) { + // Single photo - no navigation needed + Div photoContainer = createPhotoContainer(photos.get(0)); + photoContainer.getStyle().set("flex", "1").set("display", "flex").set("align-items", "center") + .set("justify-content", "center"); + galleryContainer.add(photoContainer); + } else { + // Multiple photos - add navigation + final int[] currentIndex = { 0 }; // Use array to make it effectively final + + // Photo counter + Span photoCounter = new Span((currentIndex[0] + 1) + " / " + photos.size()); + photoCounter.getStyle().set("position", "absolute").set("top", "var(--lumo-space-s)") + .set("right", "var(--lumo-space-s)").set("background-color", "rgba(0, 0, 0, 0.6)") + .set("color", "white").set("padding", "var(--lumo-space-xs) var(--lumo-space-s)") + .set("border-radius", "var(--lumo-border-radius-s)").set("font-size", "var(--lumo-font-size-s)") + .set("z-index", "10"); + + // Photo container + Div photoContainer = createPhotoContainer(photos.get(0)); + photoContainer.getStyle().set("margin", "0 40px") // Space for buttons + .set("flex", "1").set("display", "flex").set("align-items", "center") + .set("justify-content", "center"); + + // Previous button + Button prevButton = new Button(new Icon(VaadinIcon.CHEVRON_LEFT)); + prevButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY, ButtonVariant.LUMO_ICON); + prevButton.getStyle().set("position", "absolute").set("left", "var(--lumo-space-s)").set("top", "50%") + .set("transform", "translateY(-50%)").set("background-color", "rgba(255, 255, 255, 0.8)") + .set("border-radius", "50%").set("z-index", "10"); + + // Next button + Button nextButton = new Button(new Icon(VaadinIcon.CHEVRON_RIGHT)); + nextButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY, ButtonVariant.LUMO_ICON); + nextButton.getStyle().set("position", "absolute").set("right", "var(--lumo-space-s)").set("top", "50%") + .set("transform", "translateY(-50%)").set("background-color", "rgba(255, 255, 255, 0.8)") + .set("border-radius", "50%").set("z-index", "10"); + + // Navigation logic + prevButton.addClickListener(e -> { + if (currentIndex[0] > 0) { + currentIndex[0]--; + updatePhotoDisplay(photoContainer, photos.get(currentIndex[0]), photoCounter, currentIndex[0] + 1, + photos.size()); + } + prevButton.setEnabled(currentIndex[0] > 0); + nextButton.setEnabled(currentIndex[0] < photos.size() - 1); + }); + + nextButton.addClickListener(e -> { + if (currentIndex[0] < photos.size() - 1) { + currentIndex[0]++; + updatePhotoDisplay(photoContainer, photos.get(currentIndex[0]), photoCounter, currentIndex[0] + 1, + photos.size()); + } + prevButton.setEnabled(currentIndex[0] > 0); + nextButton.setEnabled(currentIndex[0] < photos.size() - 1); + }); + + // Initial button states + prevButton.setEnabled(false); + nextButton.setEnabled(photos.size() > 1); + + galleryContainer.add(photoCounter, photoContainer, prevButton, nextButton); + } + + return galleryContainer; + } + + private Div createPhotoContainer(String base64Photo) { + Div photoContainer = new Div(); + photoContainer.getStyle().set("width", "100%").set("height", "100%").set("display", "flex") + .set("align-items", "center").set("justify-content", "center").set("overflow", "hidden"); + + // Create image element + String imgSrc = base64Photo.startsWith("data:") ? base64Photo : "data:image/jpeg;base64," + base64Photo; + + photoContainer.getElement().setProperty("innerHTML", ""); + + return photoContainer; + } + + private void updatePhotoDisplay(Div photoContainer, String base64Photo, Span counter, int current, int total) { + String imgSrc = base64Photo.startsWith("data:") ? base64Photo : "data:image/jpeg;base64," + base64Photo; + + photoContainer.getElement().setProperty("innerHTML", ""); + + counter.setText(current + " / " + total); + } + + private String makeResponsiveSvg(String svgContent) { + if (svgContent == null || svgContent.isBlank()) { + return svgContent; + } + + // Remove any existing width and height attributes and add responsive styling + String responsiveSvg = svgContent.replaceAll("width\\s*=\\s*[\"'][^\"']*[\"']", "") + .replaceAll("height\\s*=\\s*[\"'][^\"']*[\"']", "").replaceAll("style\\s*=\\s*[\"'][^\"']*[\"']", ""); + + // Add responsive styling - preserve viewBox if it exists, otherwise try to + // extract from width/height + if (!responsiveSvg.contains("viewBox")) { + // Try to extract original dimensions for viewBox + String widthMatch = extractAttribute(svgContent, "width"); + String heightMatch = extractAttribute(svgContent, "height"); + + if (widthMatch != null && heightMatch != null) { + try { + // Clean numbers (remove px, pt, etc.) + String cleanWidth = widthMatch.replaceAll("[^0-9.]", ""); + String cleanHeight = heightMatch.replaceAll("[^0-9.]", ""); + + if (!cleanWidth.isEmpty() && !cleanHeight.isEmpty()) { + responsiveSvg = responsiveSvg.replaceFirst("