From c694222224f33bc9ce51fe8e147f15d445fb9a8e Mon Sep 17 00:00:00 2001 From: Sven Carstensen Date: Mon, 15 Sep 2025 11:27:41 +0200 Subject: [PATCH] Erweiterungen --- .../controller/MessageController.java | 60 +- .../assecutor/votianlt/model/JobHistory.java | 98 +++ .../votianlt/model/JobHistoryType.java | 56 ++ .../votianlt/pages/service/AddJobService.java | 26 + .../votianlt/pages/view/JobHistoryView.java | 612 ++++++++++++++++++ .../votianlt/pages/view/JobSummaryView.java | 12 + .../votianlt/pages/view/ShowJobsView.java | 61 +- .../repository/JobHistoryRepository.java | 64 ++ .../votianlt/repository/JobRepository.java | 16 + .../votianlt/service/JobHistoryService.java | 274 ++++++++ 10 files changed, 1253 insertions(+), 26 deletions(-) create mode 100644 src/main/java/de/assecutor/votianlt/model/JobHistory.java create mode 100644 src/main/java/de/assecutor/votianlt/model/JobHistoryType.java create mode 100644 src/main/java/de/assecutor/votianlt/pages/view/JobHistoryView.java create mode 100644 src/main/java/de/assecutor/votianlt/repository/JobHistoryRepository.java create mode 100644 src/main/java/de/assecutor/votianlt/service/JobHistoryService.java diff --git a/src/main/java/de/assecutor/votianlt/controller/MessageController.java b/src/main/java/de/assecutor/votianlt/controller/MessageController.java index dc4b5e0..d766b10 100644 --- a/src/main/java/de/assecutor/votianlt/controller/MessageController.java +++ b/src/main/java/de/assecutor/votianlt/controller/MessageController.java @@ -18,6 +18,7 @@ import de.assecutor.votianlt.repository.SignatureRepository; import de.assecutor.votianlt.model.Photo; import de.assecutor.votianlt.model.Barcode; import de.assecutor.votianlt.model.Signature; +import de.assecutor.votianlt.service.JobHistoryService; import lombok.extern.slf4j.Slf4j; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; @@ -55,8 +56,9 @@ public class MessageController { private final PhotoRepository photoRepository; private final BarcodeRepository barcodeRepository; private final SignatureRepository signatureRepository; + private final JobHistoryService jobHistoryService; - public MessageController(MqttPublisher mqttPublisher, AppUserRepository appUserRepository, AppUserService appUserService, JobRepository jobRepository, CargoItemRepository cargoItemRepository, TaskRepository taskRepository, PhotoRepository photoRepository, BarcodeRepository barcodeRepository, SignatureRepository signatureRepository) { + public MessageController(MqttPublisher mqttPublisher, AppUserRepository appUserRepository, AppUserService appUserService, JobRepository jobRepository, CargoItemRepository cargoItemRepository, TaskRepository taskRepository, PhotoRepository photoRepository, BarcodeRepository barcodeRepository, SignatureRepository signatureRepository, JobHistoryService jobHistoryService) { this.mqttPublisher = mqttPublisher; this.appUserRepository = appUserRepository; this.appUserService = appUserService; @@ -66,6 +68,7 @@ public class MessageController { this.photoRepository = photoRepository; this.barcodeRepository = barcodeRepository; this.signatureRepository = signatureRepository; + this.jobHistoryService = jobHistoryService; } /** @@ -232,14 +235,12 @@ public class MessageController { private void processConfirmationTaskCompletion(Map payload) { Object taskId = payload.get("taskId"); - - completeTask(taskId); + completeTaskWithHistory(taskId, "Bestätigung durchgeführt"); } private void processTodoListTaskCompletion(Map payload) { Object taskId = payload.get("taskId"); - - completeTask(taskId); + completeTaskWithHistory(taskId, "Alle To-Do-Elemente abgehakt"); } private void processBarcodeTaskCompletion(Map payload) { @@ -252,6 +253,7 @@ public class MessageController { } BaseTask task = opt.get(); + String extraDataSummary = null; Object extra = payload.get("extraData"); if (extra instanceof Map extraData) { Object barcodesObj = extraData.get("barcodes"); @@ -270,19 +272,23 @@ public class MessageController { barcodeRepository.save(barcodeEntry); } + extraDataSummary = barcodes.size() + " Barcode(s) gescannt: " + String.join(", ", barcodes.subList(0, Math.min(3, barcodes.size()))) + (barcodes.size() > 3 ? "..." : ""); log.info("Saved {} barcodes for taskId={}", barcodes.size(), taskId); } else { + extraDataSummary = "Keine Barcodes gescannt"; log.info("No barcodes found in extraData for taskId={}", taskId); } } else { + extraDataSummary = "Barcode-Daten fehlerhaft"; log.warn("extraData.barcodes is not a List for taskId={}", taskId); } } else { + extraDataSummary = "Keine Extra-Daten"; log.warn("extraData is not a Map for taskId={}", taskId); } - // Finally, mark the task as completed - completeTask(taskId); + // Finally, mark the task as completed with history logging + completeTaskWithHistory(taskId, extraDataSummary); } catch (IllegalArgumentException ex) { log.error("Invalid taskId format for barcode completion: {}", taskId); } catch (Exception ex) { @@ -300,6 +306,7 @@ public class MessageController { } BaseTask task = opt.get(); + String extraDataSummary = null; Object extra = payload.get("extraData"); if (extra instanceof Map extraData) { Object signatureSvgObj = extraData.get("signatureSvg"); @@ -312,19 +319,23 @@ public class MessageController { ); signatureRepository.save(signatureEntry); + extraDataSummary = "Unterschrift erfasst (SVG, " + signatureSvg.length() + " Zeichen)"; log.info("Saved signature for taskId={}", taskId); } else { + extraDataSummary = "Leere Unterschrift"; log.info("Empty signature SVG found for taskId={}", taskId); } } else { + extraDataSummary = "Unterschrift-Daten fehlerhaft"; log.warn("extraData.signatureSvg is not a String for taskId={}", taskId); } } else { + extraDataSummary = "Keine Extra-Daten"; log.warn("extraData is not a Map for taskId={}", taskId); } - // Finally, mark the task as completed - completeTask(taskId); + // Finally, mark the task as completed with history logging + completeTaskWithHistory(taskId, extraDataSummary); } catch (IllegalArgumentException ex) { log.error("Invalid taskId format for signature completion: {}", taskId); } catch (Exception ex) { @@ -343,6 +354,7 @@ public class MessageController { BaseTask task = opt.get(); ObjectId jobId = new ObjectId(task.getJobIdAsString()); + String extraDataSummary = null; Object extra = payload.get("extraData"); if (extra instanceof Map extraData) { Object photosObj = extraData.get("photos"); @@ -361,19 +373,23 @@ public class MessageController { photoRepository.save(photoEntry); } + extraDataSummary = photos.size() + " Foto(s) aufgenommen"; log.info("Saved {} photos for taskId={}, jobId={}", photos.size(), taskId, jobId); } else { + extraDataSummary = "Keine Fotos aufgenommen"; log.info("No photos found in extraData for taskId={}", taskId); } } else { + extraDataSummary = "Foto-Daten fehlerhaft"; log.warn("extraData.photos is not a List for taskId={}", taskId); } } else { + extraDataSummary = "Keine Extra-Daten"; log.warn("extraData is not a Map for taskId={}", taskId); } - // Finally, mark the task as completed - completeTask(taskId); + // Finally, mark the task as completed with history logging + completeTaskWithHistory(taskId, extraDataSummary); } catch (IllegalArgumentException ex) { log.error("Invalid taskId format for photo completion: {}", taskId); } catch (Exception ex) { @@ -382,19 +398,37 @@ public class MessageController { } private void completeTask(Object tid) { + completeTaskWithHistory(tid, null); + } + + private void completeTaskWithHistory(Object tid, String extraDataSummary) { String taskIdStr = tid.toString(); try { ObjectId taskId = new ObjectId(taskIdStr); var opt = taskRepository.findById(taskId); if (opt.isEmpty()) { - log.warn("Task not found for confirmation completion. taskId={}", taskIdStr); + log.warn("Task not found for completion. taskId={}", taskIdStr); return; } BaseTask task = opt.get(); task.setCompleted(true); task.setCompletedAt(LocalDateTime.now()); taskRepository.save(task); - log.info("Task marked completed. taskId={}, completedBy={}", taskIdStr, task.getCompletedBy()); + + // Log detailed task completion in job history + try { + ObjectId jobId = new ObjectId(task.getJobIdAsString()); + String taskType = task.getTaskType() != null ? task.getTaskType().toString() : "Unknown"; + String taskDisplayName = task.getDisplayName() != null ? task.getDisplayName() : taskType; + + jobHistoryService.logTaskCompletion(jobId, taskType, taskIdStr, task.getCompletedBy(), + taskDisplayName, extraDataSummary); + } catch (Exception e) { + log.warn("Failed to log task completion history for task {}: {}", taskIdStr, e.getMessage()); + } + + log.info("Task marked completed. taskId={}, completedBy={}, extraData={}", + taskIdStr, task.getCompletedBy(), extraDataSummary); } catch (IllegalArgumentException ex) { log.error("Invalid taskId format for completion: {}", taskIdStr); } catch (Exception ex) { diff --git a/src/main/java/de/assecutor/votianlt/model/JobHistory.java b/src/main/java/de/assecutor/votianlt/model/JobHistory.java new file mode 100644 index 0000000..593bcf5 --- /dev/null +++ b/src/main/java/de/assecutor/votianlt/model/JobHistory.java @@ -0,0 +1,98 @@ +package de.assecutor.votianlt.model; + +import lombok.Data; +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.mapping.Document; +import org.bson.types.ObjectId; + +import java.time.LocalDateTime; + +/** + * Job History entity for tracking all changes made to a job. + * Each entry represents a single change or action performed on a job. + */ +@Data +@Document(collection = "job_history") +public class JobHistory { + + @Id + private ObjectId id; + + /** + * Reference to the job this history entry belongs to + */ + private ObjectId jobId; + + /** + * Timestamp when the change occurred + */ + private LocalDateTime timestamp; + + /** + * Reason for the change (e.g., "Status Update", "User Edit", "System Update") + */ + private String reason; + + /** + * Description of what was changed (e.g., "Status changed from CREATED to IN_PROGRESS") + */ + private String description; + + /** + * User who made the change (can be null for system changes) + */ + private String changedBy; + + /** + * Additional details about the change (optional) + */ + private String details; + + /** + * Type of change (CREATE, UPDATE, STATUS_CHANGE, DELETE, etc.) + */ + private JobHistoryType changeType; + + /** + * Old value (for comparison, stored as JSON string if complex) + */ + private String oldValue; + + /** + * New value (for comparison, stored as JSON string if complex) + */ + private String newValue; + + // Default constructor + public JobHistory() { + this.timestamp = LocalDateTime.now(); + } + + // Constructor for basic history entry + public JobHistory(ObjectId jobId, String reason, String description, String changedBy) { + this(); + this.jobId = jobId; + this.reason = reason; + this.description = description; + this.changedBy = changedBy; + } + + // Constructor for detailed history entry + public JobHistory(ObjectId jobId, String reason, String description, String changedBy, + JobHistoryType changeType, String oldValue, String newValue) { + this(jobId, reason, description, changedBy); + this.changeType = changeType; + this.oldValue = oldValue; + this.newValue = newValue; + } + + // Getter for ID as String + public String getIdAsString() { + return id != null ? id.toHexString() : null; + } + + // Getter for Job ID as String + public String getJobIdAsString() { + return jobId != null ? jobId.toHexString() : null; + } +} \ No newline at end of file diff --git a/src/main/java/de/assecutor/votianlt/model/JobHistoryType.java b/src/main/java/de/assecutor/votianlt/model/JobHistoryType.java new file mode 100644 index 0000000..6718f62 --- /dev/null +++ b/src/main/java/de/assecutor/votianlt/model/JobHistoryType.java @@ -0,0 +1,56 @@ +package de.assecutor.votianlt.model; + +/** + * Enumeration of different types of job history changes + */ +public enum JobHistoryType { + /** + * Job was created + */ + CREATE, + + /** + * Job data was updated + */ + UPDATE, + + /** + * Job status was changed + */ + STATUS_CHANGE, + + /** + * Job was assigned to a user + */ + ASSIGNMENT, + + /** + * Task was completed within the job + */ + TASK_COMPLETED, + + /** + * Job was exported or shared + */ + EXPORT, + + /** + * Job was deleted or archived + */ + DELETE, + + /** + * System-generated change + */ + SYSTEM, + + /** + * Comment or note was added + */ + COMMENT, + + /** + * Other type of change + */ + OTHER +} \ No newline at end of file diff --git a/src/main/java/de/assecutor/votianlt/pages/service/AddJobService.java b/src/main/java/de/assecutor/votianlt/pages/service/AddJobService.java index 0324b26..c81f9a0 100644 --- a/src/main/java/de/assecutor/votianlt/pages/service/AddJobService.java +++ b/src/main/java/de/assecutor/votianlt/pages/service/AddJobService.java @@ -8,6 +8,7 @@ import de.assecutor.votianlt.repository.JobRepository; import de.assecutor.votianlt.repository.TaskRepository; import de.assecutor.votianlt.security.SecurityService; import de.assecutor.votianlt.repository.CargoItemRepository; +import de.assecutor.votianlt.service.JobHistoryService; import java.util.Objects; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -28,6 +29,7 @@ public class AddJobService { private final JobRepository jobRepository; private final TaskRepository taskRepository; private final SecurityService securityService; + private final JobHistoryService jobHistoryService; /** * Speichert einen neuen Auftrag samt CargoItems und Tasks * @param job der Auftrag @@ -97,6 +99,14 @@ public class AddJobService { savedJob = jobRepository.save(savedJob); } + // History-Eintrag für Job-Erstellung + try { + String currentUserName = getCurrentUserName(); + jobHistoryService.logJobCreation(savedJob, currentUserName); + } catch (Exception e) { + log.warn("Failed to log job creation history for job {}: {}", savedJob.getIdAsString(), e.getMessage()); + } + log.info("Auftrag erfolgreich gespeichert: {}", savedJob.getJobNumber()); return savedJob; @@ -136,4 +146,20 @@ public class AddJobService { List drafts = jobRepository.findByCreatedByAndIsDraftTrue(username); return drafts.isEmpty() ? Optional.empty() : Optional.of(drafts.getFirst()); } + + /** + * Hilfsmethode um den aktuellen Benutzernamen zu ermitteln + */ + private String getCurrentUserName() { + try { + var authenticatedUserOpt = securityService.getAuthenticatedUser(); + if (authenticatedUserOpt.isPresent()) { + var user = authenticatedUserOpt.get(); + return user.getUsername() != null ? user.getUsername() : "Unknown User"; + } + } catch (Exception e) { + log.debug("Could not get authenticated user: {}", e.getMessage()); + } + return "System"; // Fallback wenn kein authentifizierter Benutzer gefunden wird + } } diff --git a/src/main/java/de/assecutor/votianlt/pages/view/JobHistoryView.java b/src/main/java/de/assecutor/votianlt/pages/view/JobHistoryView.java new file mode 100644 index 0000000..06ad5c7 --- /dev/null +++ b/src/main/java/de/assecutor/votianlt/pages/view/JobHistoryView.java @@ -0,0 +1,612 @@ +package de.assecutor.votianlt.pages.view; + +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.button.ButtonVariant; +import com.vaadin.flow.component.dialog.Dialog; +import com.vaadin.flow.component.html.H2; +import com.vaadin.flow.component.html.Main; +import com.vaadin.flow.component.html.Span; +import com.vaadin.flow.component.html.Div; +import com.vaadin.flow.component.icon.Icon; +import com.vaadin.flow.component.icon.VaadinIcon; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; +import com.vaadin.flow.component.orderedlayout.VerticalLayout; +import com.vaadin.flow.router.BeforeEvent; +import com.vaadin.flow.router.HasUrlParameter; +import com.vaadin.flow.router.PageTitle; +import com.vaadin.flow.router.Route; +import com.vaadin.flow.theme.lumo.LumoUtility; +import de.assecutor.votianlt.model.Job; +import de.assecutor.votianlt.model.JobHistory; +import de.assecutor.votianlt.model.JobHistoryType; +import de.assecutor.votianlt.model.Barcode; +import de.assecutor.votianlt.model.Photo; +import de.assecutor.votianlt.model.Signature; +import de.assecutor.votianlt.pages.base.ui.component.ViewToolbar; +import de.assecutor.votianlt.repository.BarcodeRepository; +import de.assecutor.votianlt.repository.JobRepository; +import de.assecutor.votianlt.repository.PhotoRepository; +import de.assecutor.votianlt.repository.SignatureRepository; +import de.assecutor.votianlt.service.JobHistoryService; +import jakarta.annotation.security.RolesAllowed; +import lombok.extern.slf4j.Slf4j; +import org.bson.types.ObjectId; + +@Route(value = "job_history", layout = de.assecutor.votianlt.pages.base.ui.view.MainLayout.class) +@PageTitle("Job History") +@RolesAllowed("USER") +@Slf4j +public class JobHistoryView extends Main implements HasUrlParameter { + + private final JobRepository jobRepository; + private final JobHistoryService jobHistoryService; + private final PhotoRepository photoRepository; + private final BarcodeRepository barcodeRepository; + private final SignatureRepository signatureRepository; + private final VerticalLayout content; + + public JobHistoryView(JobRepository jobRepository, JobHistoryService jobHistoryService, PhotoRepository photoRepository, BarcodeRepository barcodeRepository, SignatureRepository signatureRepository) { + this.jobRepository = jobRepository; + this.jobHistoryService = jobHistoryService; + this.photoRepository = photoRepository; + this.barcodeRepository = barcodeRepository; + this.signatureRepository = signatureRepository; + + setSizeFull(); + addClassNames(LumoUtility.BoxSizing.BORDER, LumoUtility.Display.FLEX, + LumoUtility.FlexDirection.COLUMN, LumoUtility.Padding.MEDIUM, + LumoUtility.Gap.SMALL); + + add(new ViewToolbar("Job History")); + + content = new VerticalLayout(); + content.setSpacing(true); + content.setPadding(true); + content.setWidthFull(); + add(content); + } + + @Override + public void setParameter(BeforeEvent event, String parameter) { + content.removeAll(); + + if (parameter == null || parameter.isBlank()) { + content.add(new Span("Fehler: Keine Job-ID angegeben")); + return; + } + + ObjectId jobId; + try { + jobId = new ObjectId(parameter); + } catch (Exception e) { + content.add(new Span("Fehler: Ungültige Job-ID Format: " + parameter)); + return; + } + + Job job = jobRepository.findById(jobId).orElse(null); + if (job == null) { + content.add(new Span("Fehler: Job mit ID " + parameter + " nicht gefunden")); + return; + } + + render(job); + } + + private void render(Job job) { + content.removeAll(); + + // Header mit Job-Informationen + H2 header = new H2("Job History - " + (job.getJobNumber() != null ? job.getJobNumber() : "Unbekannte Auftragsnummer")); + content.add(header); + + // Job basic info for context + Div jobInfoBox = createJobInfoBox(job); + content.add(jobInfoBox); + + // Load and display history entries + try { + var historyEntries = jobHistoryService.getJobHistory(job.getId()); + long historyCount = jobHistoryService.getJobHistoryCount(job.getId()); + + if (historyEntries.isEmpty()) { + Span noHistory = new Span("Noch keine History-Einträge für diesen Job vorhanden."); + noHistory.getStyle().set("color", "var(--lumo-secondary-text-color)"); + content.add(noHistory); + } else { + // History section header + H2 historyHeader = new H2("Verlauf (" + historyCount + " Einträge)"); + historyHeader.getStyle().set("margin-top", "var(--lumo-space-l)"); + content.add(historyHeader); + + // History timeline + VerticalLayout timeline = createHistoryTimeline(historyEntries); + content.add(timeline); + } + } catch (Exception e) { + Span errorMessage = new Span("Fehler beim Laden der Job History: " + e.getMessage()); + errorMessage.getStyle().set("color", "var(--lumo-error-text-color)"); + content.add(errorMessage); + } + } + + private Div createJobInfoBox(Job job) { + Div infoBox = new Div(); + infoBox.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", "var(--lumo-base-color)") + .set("margin-bottom", "var(--lumo-space-m)"); + + VerticalLayout infoContent = new VerticalLayout(); + infoContent.setPadding(false); + infoContent.setSpacing(false); + + if (job.getDeliveryCompany() != null) { + infoContent.add(new Span("Kunde: " + job.getDeliveryCompany())); + } + if (job.getCreatedAt() != null) { + infoContent.add(new Span("Erstellt am: " + formatDateTime(job.getCreatedAt()))); + } + if (job.getStatus() != null) { + infoContent.add(new Span("Status: " + formatStatus(job.getStatus()))); + } + + infoBox.add(infoContent); + return infoBox; + } + + private VerticalLayout createHistoryTimeline(java.util.List historyEntries) { + VerticalLayout timeline = new VerticalLayout(); + timeline.setPadding(false); + timeline.setSpacing(false); + timeline.setWidthFull(); + + for (JobHistory entry : historyEntries) { + Div entryCard = createHistoryEntryCard(entry); + timeline.add(entryCard); + } + + return timeline; + } + + private Div createHistoryEntryCard(JobHistory entry) { + Div card = new Div(); + card.getStyle() + .set("border", "1px solid var(--lumo-contrast-10pct)") + .set("border-left", "4px solid " + getTypeColor(entry.getChangeType())) + .set("border-radius", "var(--lumo-border-radius-s)") + .set("padding", "var(--lumo-space-m)") + .set("margin-bottom", "var(--lumo-space-s)") + .set("background-color", "var(--lumo-base-color)") + .set("width", "100%") + .set("box-sizing", "border-box"); + + // Header row with icon, reason and timestamp + HorizontalLayout headerRow = new HorizontalLayout(); + headerRow.setAlignItems(HorizontalLayout.Alignment.CENTER); + headerRow.setWidthFull(); + + Icon typeIcon = getTypeIcon(entry.getChangeType()); + typeIcon.getStyle().set("color", getTypeColor(entry.getChangeType())); + + Span reason = new Span(entry.getReason() != null ? entry.getReason() : "Unbekannt"); + reason.getStyle().set("font-weight", "500"); + + Span timestamp = new Span(formatDateTime(entry.getTimestamp())); + timestamp.getStyle() + .set("color", "var(--lumo-secondary-text-color)") + .set("font-size", "var(--lumo-font-size-s)"); + + HorizontalLayout leftSide = new HorizontalLayout(typeIcon, reason); + leftSide.setAlignItems(HorizontalLayout.Alignment.CENTER); + leftSide.setSpacing(true); + + headerRow.add(leftSide, timestamp); + headerRow.setJustifyContentMode(HorizontalLayout.JustifyContentMode.BETWEEN); + + VerticalLayout cardContent = new VerticalLayout(); + cardContent.setPadding(false); + cardContent.setSpacing(false); + cardContent.add(headerRow); + + // Description + if (entry.getDescription() != null && !entry.getDescription().isBlank()) { + Span description = new Span(entry.getDescription()); + description.getStyle() + .set("color", "var(--lumo-body-text-color)") + .set("margin-top", "var(--lumo-space-xs)") + .set("display", "block"); + cardContent.add(description); + } + + // Photo preview for photo tasks + if (entry.getChangeType() == JobHistoryType.TASK_COMPLETED && + entry.getDetails() != null && + entry.getDetails().contains("Task-Typ: PHOTO")) { + + HorizontalLayout photoPreview = createPhotoPreview(entry); + if (photoPreview != null) { + cardContent.add(photoPreview); + } + } + + // Barcode preview for barcode tasks + if (entry.getChangeType() == JobHistoryType.TASK_COMPLETED && + entry.getDetails() != null && + entry.getDetails().contains("Task-Typ: BARCODE")) { + + VerticalLayout barcodePreview = createBarcodePreview(entry); + if (barcodePreview != null) { + cardContent.add(barcodePreview); + } + } + + // Signature preview for signature tasks + if (entry.getChangeType() == JobHistoryType.TASK_COMPLETED && + entry.getDetails() != null && + entry.getDetails().contains("Task-Typ: SIGNATURE")) { + + Div signaturePreview = createSignaturePreview(entry); + if (signaturePreview != null) { + cardContent.add(signaturePreview); + } + } + + // Changed by (if available) + if (entry.getChangedBy() != null && !entry.getChangedBy().isBlank()) { + Span changedBy = new Span("von: " + entry.getChangedBy()); + changedBy.getStyle() + .set("color", "var(--lumo-secondary-text-color)") + .set("font-size", "var(--lumo-font-size-xs)") + .set("margin-top", "var(--lumo-space-xs)") + .set("display", "block"); + cardContent.add(changedBy); + } + + card.add(cardContent); + + return card; + } + + private Icon getTypeIcon(JobHistoryType type) { + if (type == null) return new Icon(VaadinIcon.INFO_CIRCLE); + + return switch (type) { + case CREATE -> new Icon(VaadinIcon.PLUS_CIRCLE); + case UPDATE -> new Icon(VaadinIcon.EDIT); + case STATUS_CHANGE -> new Icon(VaadinIcon.ARROW_RIGHT); + case TASK_COMPLETED -> new Icon(VaadinIcon.CHECK); + case ASSIGNMENT -> new Icon(VaadinIcon.USER); + case EXPORT -> new Icon(VaadinIcon.DOWNLOAD); + case DELETE -> new Icon(VaadinIcon.TRASH); + case SYSTEM -> new Icon(VaadinIcon.COG); + case COMMENT -> new Icon(VaadinIcon.COMMENT); + default -> new Icon(VaadinIcon.INFO_CIRCLE); + }; + } + + private String getTypeColor(JobHistoryType type) { + if (type == null) return "var(--lumo-contrast-60pct)"; + + return switch (type) { + case CREATE -> "var(--lumo-success-color)"; + case UPDATE -> "var(--lumo-primary-color)"; + case STATUS_CHANGE -> "var(--lumo-contrast-color)"; + case TASK_COMPLETED -> "var(--lumo-success-color)"; + case ASSIGNMENT -> "var(--lumo-primary-color)"; + case EXPORT -> "var(--lumo-contrast-color)"; + case DELETE -> "var(--lumo-error-color)"; + case SYSTEM -> "var(--lumo-contrast-60pct)"; + case COMMENT -> "var(--lumo-primary-color)"; + default -> "var(--lumo-contrast-60pct)"; + }; + } + + private String formatDateTime(java.time.LocalDateTime dateTime) { + if (dateTime == null) return ""; + try { + java.time.format.DateTimeFormatter formatter = + java.time.format.DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm"); + return dateTime.format(formatter); + } catch (Exception e) { + return dateTime.toString(); + } + } + + private String formatStatus(de.assecutor.votianlt.model.JobStatus status) { + if (status == null) return "Unbekannt"; + + return switch (status) { + case CREATED -> "Erstellt"; + case IN_PROGRESS -> "In Bearbeitung"; + case PICKUP_SCHEDULED -> "Abholung geplant"; + case PICKED_UP -> "Abgeholt"; + case IN_TRANSIT -> "Unterwegs"; + case DELIVERED -> "Zugestellt"; + case COMPLETED -> "Abgeschlossen"; + case CANCELLED -> "Storniert"; + default -> status.toString(); + }; + } + + private HorizontalLayout createPhotoPreview(JobHistory entry) { + try { + // Extract task ID from details + String details = entry.getDetails(); + if (details == null || !details.contains("Task-ID: ")) { + return null; + } + + String taskId = extractTaskIdFromDetails(details); + if (taskId == null) { + return null; + } + + // Load photos for this task + var photos = photoRepository.findByTaskId(taskId); + if (photos.isEmpty()) { + return null; + } + + HorizontalLayout photoLayout = new HorizontalLayout(); + photoLayout.setSpacing(true); + photoLayout.getStyle() + .set("margin-top", "var(--lumo-space-s)") + .set("flex-wrap", "wrap"); + + for (Photo photo : photos) { + if (photo.getPhoto() != null && !photo.getPhoto().isBlank()) { + com.vaadin.flow.component.html.Image thumbnail = createPhotoThumbnail(photo.getPhoto()); + if (thumbnail != null) { + thumbnail.addClickListener(e -> showEnlargedPhoto(photo.getPhoto())); + photoLayout.add(thumbnail); + } + } + } + + return photoLayout.getComponentCount() > 0 ? photoLayout : null; + + } catch (Exception e) { + log.error("Error creating photo preview for history entry: {}", e.getMessage()); + return null; + } + } + + private String extractTaskIdFromDetails(String details) { + try { + String prefix = "Task-ID: "; + int startIndex = details.indexOf(prefix); + if (startIndex == -1) { + return null; + } + + startIndex += prefix.length(); + int endIndex = details.indexOf(",", startIndex); + if (endIndex == -1) { + endIndex = details.length(); + } + + return details.substring(startIndex, endIndex).trim(); + } catch (Exception e) { + return null; + } + } + + private com.vaadin.flow.component.html.Image createPhotoThumbnail(String base64Photo) { + try { + String imageData = base64Photo.startsWith("data:") + ? base64Photo + : "data:image/jpeg;base64," + base64Photo; + + com.vaadin.flow.component.html.Image thumbnail = new com.vaadin.flow.component.html.Image(imageData, "Foto"); + thumbnail.getStyle() + .set("width", "100px") + .set("height", "100px") + .set("object-fit", "cover") + .set("border-radius", "var(--lumo-border-radius-s)") + .set("border", "1px solid var(--lumo-contrast-20pct)") + .set("cursor", "pointer"); + + return thumbnail; + } catch (Exception e) { + log.error("Error creating photo thumbnail: {}", e.getMessage()); + return null; + } + } + + private void showEnlargedPhoto(String base64Photo) { + Dialog photoDialog = new Dialog(); + photoDialog.setWidth("80vw"); + photoDialog.setHeight("80vh"); + photoDialog.setModal(true); + photoDialog.setCloseOnOutsideClick(true); + photoDialog.setCloseOnEsc(true); + + try { + String imageData = base64Photo.startsWith("data:") + ? base64Photo + : "data:image/jpeg;base64," + base64Photo; + + com.vaadin.flow.component.html.Image enlargedImage = new com.vaadin.flow.component.html.Image(imageData, "Vergrößertes Foto"); + enlargedImage.getStyle() + .set("max-width", "100%") + .set("max-height", "100%") + .set("object-fit", "contain"); + + VerticalLayout dialogContent = new VerticalLayout(enlargedImage); + dialogContent.setAlignItems(VerticalLayout.Alignment.CENTER); + dialogContent.setJustifyContentMode(VerticalLayout.JustifyContentMode.CENTER); + dialogContent.setSizeFull(); + + photoDialog.add(dialogContent); + photoDialog.open(); + + } catch (Exception e) { + log.error("Error showing enlarged photo: {}", e.getMessage()); + } + } + + private VerticalLayout createBarcodePreview(JobHistory entry) { + try { + // Extract task ID from details + String details = entry.getDetails(); + if (details == null || !details.contains("Task-ID: ")) { + return null; + } + + String taskId = extractTaskIdFromDetails(details); + if (taskId == null) { + return null; + } + + // Load barcodes for this task + ObjectId taskObjectId = new ObjectId(taskId); + var barcodes = barcodeRepository.findByTaskId(taskObjectId); + if (barcodes.isEmpty()) { + return null; + } + + VerticalLayout barcodeLayout = new VerticalLayout(); + barcodeLayout.setPadding(false); + barcodeLayout.setSpacing(true); + barcodeLayout.getStyle() + .set("margin-top", "var(--lumo-space-s)"); + + for (Barcode barcode : barcodes) { + if (barcode.getBarcode() != null && !barcode.getBarcode().isBlank()) { + Div barcodeBox = createBarcodeBox(barcode.getBarcode()); + barcodeLayout.add(barcodeBox); + } + } + + return barcodeLayout.getComponentCount() > 0 ? barcodeLayout : null; + + } catch (Exception e) { + log.error("Error creating barcode preview for history entry: {}", e.getMessage()); + return null; + } + } + + private Div createBarcodeBox(String barcodeValue) { + Div barcodeBox = new Div(); + barcodeBox.getStyle() + .set("border", "1px solid var(--lumo-contrast-20pct)") + .set("border-radius", "var(--lumo-border-radius-s)") + .set("padding", "var(--lumo-space-xs)") + .set("background-color", "var(--lumo-contrast-5pct)") + .set("font-family", "monospace") + .set("font-size", "var(--lumo-font-size-s)") + .set("margin-bottom", "var(--lumo-space-xs)") + .set("word-break", "break-all"); + + barcodeBox.add(new Span(barcodeValue)); + return barcodeBox; + } + + private Div createSignaturePreview(JobHistory entry) { + try { + // Extract task ID from details + String details = entry.getDetails(); + if (details == null || !details.contains("Task-ID: ")) { + return null; + } + + String taskId = extractTaskIdFromDetails(details); + if (taskId == null) { + return null; + } + + // Load signature for this task + ObjectId taskObjectId = new ObjectId(taskId); + var signatures = signatureRepository.findByTaskId(taskObjectId); + if (signatures.isEmpty()) { + return null; + } + + // Use the first signature + Signature signature = signatures.get(0); + if (signature.getSignatureSvg() == null || signature.getSignatureSvg().isBlank()) { + return null; + } + + Div previewContainer = new Div(); + previewContainer.getStyle() + .set("margin-top", "var(--lumo-space-s)") + .set("border", "1px solid var(--lumo-contrast-20pct)") + .set("border-radius", "var(--lumo-border-radius-s)") + .set("padding", "var(--lumo-space-xs)") + .set("background-color", "var(--lumo-base-color)") + .set("cursor", "pointer") + .set("width", "200px") + .set("height", "100px") + .set("overflow", "hidden") + .set("display", "flex") + .set("align-items", "center") + .set("justify-content", "center"); + + // Create responsive SVG for preview + com.vaadin.flow.component.Html signatureSvg = createResponsiveSignatureSvg(signature.getSignatureSvg(), "100%", "100%"); + previewContainer.add(signatureSvg); + + // Add click listener for enlarged view + previewContainer.addClickListener(e -> showEnlargedSignature(signature.getSignatureSvg())); + + return previewContainer; + + } catch (Exception e) { + log.error("Error creating signature preview for history entry: {}", e.getMessage()); + return null; + } + } + + private com.vaadin.flow.component.Html createResponsiveSignatureSvg(String svgContent, String width, String height) { + // Make SVG responsive by ensuring proper viewBox and dimensions + String responsiveSvg = svgContent; + + if (!responsiveSvg.contains("viewBox")) { + // Try to extract width and height from SVG and create viewBox + responsiveSvg = responsiveSvg.replaceFirst("" + responsiveSvg + ""); + } + + private void showEnlargedSignature(String svgContent) { + Dialog signatureDialog = new Dialog(); + signatureDialog.setWidth("60vw"); + signatureDialog.setHeight("40vh"); + signatureDialog.setModal(true); + signatureDialog.setCloseOnOutsideClick(true); + signatureDialog.setCloseOnEsc(true); + + try { + // Create enlarged responsive SVG + com.vaadin.flow.component.Html enlargedSignature = createResponsiveSignatureSvg(svgContent, "100%", "100%"); + + VerticalLayout dialogContent = new VerticalLayout(); + dialogContent.setAlignItems(VerticalLayout.Alignment.CENTER); + dialogContent.setJustifyContentMode(VerticalLayout.JustifyContentMode.CENTER); + dialogContent.setSizeFull(); + dialogContent.setPadding(true); + + dialogContent.add(enlargedSignature); + signatureDialog.add(dialogContent); + signatureDialog.open(); + + } catch (Exception e) { + log.error("Error showing enlarged signature: {}", e.getMessage()); + } + } +} \ No newline at end of file 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 f09a0ad..6cd4466 100644 --- a/src/main/java/de/assecutor/votianlt/pages/view/JobSummaryView.java +++ b/src/main/java/de/assecutor/votianlt/pages/view/JobSummaryView.java @@ -222,6 +222,18 @@ public class JobSummaryView extends Main implements HasUrlParameter { // Google Maps Karte mit Route addRouteMap(job); + + // Job History Button + Button jobHistoryButton = new Button("Job History"); + jobHistoryButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY); + jobHistoryButton.getStyle().set("margin-top", "var(--lumo-space-m)"); + jobHistoryButton.addClickListener(e -> { + getUI().ifPresent(ui -> ui.navigate("job_history/" + job.getId().toHexString())); + }); + + HorizontalLayout buttonLayout = new HorizontalLayout(jobHistoryButton); + buttonLayout.setJustifyContentMode(HorizontalLayout.JustifyContentMode.CENTER); + content.add(buttonLayout); } private VerticalLayout borderedBox() { diff --git a/src/main/java/de/assecutor/votianlt/pages/view/ShowJobsView.java b/src/main/java/de/assecutor/votianlt/pages/view/ShowJobsView.java index b6d79d9..3bacad7 100644 --- a/src/main/java/de/assecutor/votianlt/pages/view/ShowJobsView.java +++ b/src/main/java/de/assecutor/votianlt/pages/view/ShowJobsView.java @@ -2,11 +2,13 @@ package de.assecutor.votianlt.pages.view; import com.vaadin.flow.component.datepicker.DatePicker; import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.combobox.ComboBox; import com.vaadin.flow.component.grid.Grid; import com.vaadin.flow.component.html.Anchor; import com.vaadin.flow.component.html.H2; import com.vaadin.flow.component.orderedlayout.HorizontalLayout; import com.vaadin.flow.component.orderedlayout.VerticalLayout; +import com.vaadin.flow.component.textfield.TextField; import com.vaadin.flow.server.StreamResource; import com.vaadin.flow.router.PageTitle; import com.vaadin.flow.router.Route; @@ -24,6 +26,8 @@ public class ShowJobsView extends VerticalLayout { private final DatePicker startDate = new DatePicker("Startdatum"); private final DatePicker endDate = new DatePicker("Enddatum"); + private final TextField searchField = new TextField("Auftragsnummer suchen"); + private final ComboBox statusFilter = new ComboBox<>("Status"); private final JobRepository jobRepository; private final SecurityService securityService; private final Grid grid = new Grid<>(Job.class, false); @@ -35,9 +39,20 @@ public class ShowJobsView extends VerticalLayout { setSizeFull(); setPadding(true); setSpacing(true); + + // Configure status filter + statusFilter.setItems("Alle", "Offen", "Erledigt"); + statusFilter.setValue("Offen"); + statusFilter.setWidth("150px"); + + // Configure search field + searchField.setPlaceholder("Auftragsnummer eingeben..."); + searchField.setClearButtonVisible(true); + searchField.setWidth("200px"); + // Filterleiste mit Export-Button am rechten Rand Button applyFilter = new Button("Anwenden"); - HorizontalLayout leftFilters = new HorizontalLayout(startDate, endDate, applyFilter); + HorizontalLayout leftFilters = new HorizontalLayout(startDate, endDate, searchField, statusFilter, applyFilter); leftFilters.setAlignItems(Alignment.END); HorizontalLayout filterBar = new HorizontalLayout(); @@ -50,7 +65,8 @@ public class ShowJobsView extends VerticalLayout { add(filterBar); - add(new H2("Offene Aufträge")); + H2 title = new H2("Aufträge"); + add(title); // Init default period: last 30 days java.time.LocalDate today = java.time.LocalDate.now(); startDate.setValue(today.minusDays(30)); @@ -58,6 +74,12 @@ public class ShowJobsView extends VerticalLayout { applyFilter.addClickListener(e -> loadData()); exportButton.addClickListener(e -> exportToCsv()); + // Add real-time filtering + searchField.addValueChangeListener(e -> loadData()); + statusFilter.addValueChangeListener(e -> loadData()); + startDate.addValueChangeListener(e -> loadData()); + endDate.addValueChangeListener(e -> loadData()); + // Configure grid columns: Kunde, Auftragsnummer, Auftragsdatum, Zielort grid.addColumn(Job::getDeliveryCompany).setHeader("Kunde").setAutoWidth(true).setFlexGrow(1).setSortable(true); @@ -94,17 +116,30 @@ public class ShowJobsView extends VerticalLayout { // Aktuellen Benutzer (ObjectId Hex) ermitteln String currentUserIdHex = securityService.getCurrentUserId().toHexString(); - // Hole Aufträge im Zeitraum, filtere auf offenen Status und auf den angemeldeten Benutzer - var inRange = jobRepository.findByCreatedAtBetween(startDt, endDt); - var openAndOwn = inRange.stream() - .filter(j -> j.getStatus() == JobStatus.CREATED - || j.getStatus() == JobStatus.IN_PROGRESS - || j.getStatus() == JobStatus.PICKUP_SCHEDULED - || j.getStatus() == JobStatus.PICKED_UP - || j.getStatus() == JobStatus.IN_TRANSIT) - .filter(j -> j.getCreatedBy() != null && j.getCreatedBy().equals(currentUserIdHex)) - .toList(); - grid.setItems(openAndOwn); + // Status-Filter bestimmen + String selectedStatus = statusFilter.getValue(); + java.util.List statusList; + + if ("Erledigt".equals(selectedStatus)) { + statusList = java.util.List.of(JobStatus.DELIVERED, JobStatus.COMPLETED, JobStatus.CANCELLED); + } else if ("Offen".equals(selectedStatus)) { + statusList = java.util.List.of(JobStatus.CREATED, JobStatus.IN_PROGRESS, + JobStatus.PICKUP_SCHEDULED, JobStatus.PICKED_UP, + JobStatus.IN_TRANSIT); + } else { // "Alle" + statusList = java.util.Arrays.asList(JobStatus.values()); + } + + // Suchtext für Auftragsnummer + String searchText = searchField.getValue(); + String jobNumberPattern = searchText != null && !searchText.trim().isEmpty() + ? searchText.trim() + : ".*"; // Regex für alle wenn leer + + // Verwende die erweiterte Suchmethode + var filteredJobs = jobRepository.findWithFilters(startDt, endDt, currentUserIdHex, + jobNumberPattern, statusList); + grid.setItems(filteredJobs); } private void exportToCsv() { diff --git a/src/main/java/de/assecutor/votianlt/repository/JobHistoryRepository.java b/src/main/java/de/assecutor/votianlt/repository/JobHistoryRepository.java new file mode 100644 index 0000000..61e9889 --- /dev/null +++ b/src/main/java/de/assecutor/votianlt/repository/JobHistoryRepository.java @@ -0,0 +1,64 @@ +package de.assecutor.votianlt.repository; + +import de.assecutor.votianlt.model.JobHistory; +import de.assecutor.votianlt.model.JobHistoryType; +import org.bson.types.ObjectId; +import org.springframework.data.mongodb.repository.MongoRepository; +import org.springframework.data.mongodb.repository.Query; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; +import java.util.List; + +@Repository +public interface JobHistoryRepository extends MongoRepository { + + /** + * Find all history entries for a specific job, ordered by timestamp descending (newest first) + */ + List findByJobIdOrderByTimestampDesc(ObjectId jobId); + + /** + * Find all history entries for a specific job, ordered by timestamp ascending (oldest first) + */ + List findByJobIdOrderByTimestampAsc(ObjectId jobId); + + /** + * Find history entries for a job within a specific time range + */ + List findByJobIdAndTimestampBetweenOrderByTimestampDesc( + ObjectId jobId, LocalDateTime start, LocalDateTime end); + + /** + * Find history entries by change type for a specific job + */ + List findByJobIdAndChangeTypeOrderByTimestampDesc( + ObjectId jobId, JobHistoryType changeType); + + /** + * Find history entries made by a specific user + */ + List findByChangedByOrderByTimestampDesc(String changedBy); + + /** + * Find recent history entries across all jobs (for dashboard/overview) + */ + @Query(value = "{}", sort = "{ 'timestamp' : -1 }") + List findRecentHistoryEntries(); + + /** + * Count history entries for a specific job + */ + long countByJobId(ObjectId jobId); + + /** + * Find the latest history entry for a specific job + */ + @Query(value = "{'jobId': ?0}", sort = "{ 'timestamp' : -1 }") + JobHistory findLatestByJobId(ObjectId jobId); + + /** + * Delete all history entries for a specific job + */ + void deleteByJobId(ObjectId jobId); +} \ No newline at end of file diff --git a/src/main/java/de/assecutor/votianlt/repository/JobRepository.java b/src/main/java/de/assecutor/votianlt/repository/JobRepository.java index 7941dcf..2d7c739 100644 --- a/src/main/java/de/assecutor/votianlt/repository/JobRepository.java +++ b/src/main/java/de/assecutor/votianlt/repository/JobRepository.java @@ -89,4 +89,20 @@ public interface JobRepository extends MongoRepository { * Findet alle Aufträge, die einem bestimmten App-Nutzer zugewiesen sind */ List findByAppUser(String appUser); + + /** + * Findet Aufträge anhand einer partiellen Auftragsnummer (case-insensitive) + */ + @Query("{'jobNumber': {'$regex': ?0, '$options': 'i'}}") + List findByJobNumberContainingIgnoreCase(String jobNumber); + + /** + * Erweiterte Suche: Zeitraum, Auftragsnummer und Status kombiniert + */ + @Query("{'createdAt': {'$gte': ?0, '$lte': ?1}, 'createdBy': ?2, " + + "'jobNumber': {'$regex': ?3, '$options': 'i'}, " + + "'status': {'$in': ?4}}") + List findWithFilters(LocalDateTime startDate, LocalDateTime endDate, + String createdBy, String jobNumberPattern, + List statusList); } diff --git a/src/main/java/de/assecutor/votianlt/service/JobHistoryService.java b/src/main/java/de/assecutor/votianlt/service/JobHistoryService.java new file mode 100644 index 0000000..6ce74f0 --- /dev/null +++ b/src/main/java/de/assecutor/votianlt/service/JobHistoryService.java @@ -0,0 +1,274 @@ +package de.assecutor.votianlt.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import de.assecutor.votianlt.model.Job; +import de.assecutor.votianlt.model.JobHistory; +import de.assecutor.votianlt.model.JobHistoryType; +import de.assecutor.votianlt.model.JobStatus; +import de.assecutor.votianlt.repository.JobHistoryRepository; +import lombok.extern.slf4j.Slf4j; +import org.bson.types.ObjectId; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +@Slf4j +public class JobHistoryService { + + private final JobHistoryRepository jobHistoryRepository; + private final ObjectMapper objectMapper; + + public JobHistoryService(JobHistoryRepository jobHistoryRepository) { + this.jobHistoryRepository = jobHistoryRepository; + this.objectMapper = new ObjectMapper(); + } + + /** + * Log job creation + */ + public void logJobCreation(Job job, String createdBy) { + try { + JobHistory history = new JobHistory( + job.getId(), + "Job erstellt", + "Neuer Job wurde erstellt: " + (job.getJobNumber() != null ? job.getJobNumber() : "Ohne Nummer"), + createdBy, + JobHistoryType.CREATE, + null, + "Job erstellt" + ); + + if (job.getDeliveryCompany() != null) { + history.setDetails("Kunde: " + job.getDeliveryCompany()); + } + + jobHistoryRepository.save(history); + log.debug("Job creation logged for job {}", job.getIdAsString()); + } catch (Exception e) { + log.error("Failed to log job creation for job {}: {}", job.getIdAsString(), e.getMessage()); + } + } + + /** + * Log job status change + */ + public void logStatusChange(Job job, JobStatus oldStatus, JobStatus newStatus, String changedBy) { + try { + String description = String.format("Status geändert von %s zu %s", + formatStatus(oldStatus), + formatStatus(newStatus)); + + JobHistory history = new JobHistory( + job.getId(), + "Status-Änderung", + description, + changedBy, + JobHistoryType.STATUS_CHANGE, + oldStatus != null ? oldStatus.toString() : null, + newStatus != null ? newStatus.toString() : null + ); + + jobHistoryRepository.save(history); + log.debug("Status change logged for job {}: {} -> {}", job.getIdAsString(), oldStatus, newStatus); + } catch (Exception e) { + log.error("Failed to log status change for job {}: {}", job.getIdAsString(), e.getMessage()); + } + } + + /** + * Log general job update + */ + public void logJobUpdate(Job oldJob, Job newJob, String changedBy, String reason) { + try { + String description = generateUpdateDescription(oldJob, newJob); + + JobHistory history = new JobHistory( + newJob.getId(), + reason != null ? reason : "Job aktualisiert", + description, + changedBy, + JobHistoryType.UPDATE, + serializeJobForComparison(oldJob), + serializeJobForComparison(newJob) + ); + + jobHistoryRepository.save(history); + log.debug("Job update logged for job {}", newJob.getIdAsString()); + } catch (Exception e) { + log.error("Failed to log job update for job {}: {}", newJob.getIdAsString(), e.getMessage()); + } + } + + /** + * Log task completion + */ + public void logTaskCompletion(ObjectId jobId, String taskType, String taskId, String completedBy) { + logTaskCompletion(jobId, taskType, taskId, completedBy, null, null); + } + + /** + * Log task completion with detailed information and extraData + */ + public void logTaskCompletion(ObjectId jobId, String taskType, String taskId, String completedBy, + String taskDisplayName, String extraDataSummary) { + try { + String taskName = taskDisplayName != null ? taskDisplayName : taskType; + String description = String.format("Aufgabe abgeschlossen: %s", taskName); + + if (extraDataSummary != null && !extraDataSummary.isBlank()) { + description += " - " + extraDataSummary; + } + + JobHistory history = new JobHistory( + jobId, + "Aufgabe abgeschlossen", + description, + completedBy, + JobHistoryType.TASK_COMPLETED, + "In Bearbeitung", + "Abgeschlossen" + ); + + // Detaillierte Informationen in details speichern + StringBuilder details = new StringBuilder(); + details.append("Task-ID: ").append(taskId); + details.append(", Task-Typ: ").append(taskType); + + if (taskDisplayName != null && !taskDisplayName.equals(taskType)) { + details.append(", Name: ").append(taskDisplayName); + } + + if (extraDataSummary != null && !extraDataSummary.isBlank()) { + details.append(", Zusatzdaten: ").append(extraDataSummary); + } + + history.setDetails(details.toString()); + jobHistoryRepository.save(history); + log.debug("Task completion logged for job {}, task {} with details", jobId.toHexString(), taskId); + } catch (Exception e) { + log.error("Failed to log task completion for job {}: {}", jobId.toHexString(), e.getMessage()); + } + } + + /** + * Log job assignment + */ + public void logJobAssignment(Job job, String oldAssignee, String newAssignee, String changedBy) { + try { + String description; + if (oldAssignee == null && newAssignee != null) { + description = "Job zugewiesen an: " + newAssignee; + } else if (oldAssignee != null && newAssignee == null) { + description = "Job-Zuweisung entfernt von: " + oldAssignee; + } else { + description = String.format("Job-Zuweisung geändert von %s zu %s", oldAssignee, newAssignee); + } + + JobHistory history = new JobHistory( + job.getId(), + "Zuweisung geändert", + description, + changedBy, + JobHistoryType.ASSIGNMENT, + oldAssignee, + newAssignee + ); + + jobHistoryRepository.save(history); + log.debug("Job assignment logged for job {}", job.getIdAsString()); + } catch (Exception e) { + log.error("Failed to log job assignment for job {}: {}", job.getIdAsString(), e.getMessage()); + } + } + + /** + * Log custom event + */ + public void logCustomEvent(ObjectId jobId, String reason, String description, String changedBy, + JobHistoryType type) { + try { + JobHistory history = new JobHistory(jobId, reason, description, changedBy, type, null, null); + jobHistoryRepository.save(history); + log.debug("Custom event logged for job {}: {}", jobId.toHexString(), reason); + } catch (Exception e) { + log.error("Failed to log custom event for job {}: {}", jobId.toHexString(), e.getMessage()); + } + } + + /** + * Get job history + */ + public List getJobHistory(ObjectId jobId) { + return jobHistoryRepository.findByJobIdOrderByTimestampDesc(jobId); + } + + /** + * Get job history count + */ + public long getJobHistoryCount(ObjectId jobId) { + return jobHistoryRepository.countByJobId(jobId); + } + + // Helper methods + + private String formatStatus(JobStatus status) { + if (status == null) return "Unbekannt"; + + return switch (status) { + case CREATED -> "Erstellt"; + case IN_PROGRESS -> "In Bearbeitung"; + case PICKUP_SCHEDULED -> "Abholung geplant"; + case PICKED_UP -> "Abgeholt"; + case IN_TRANSIT -> "Unterwegs"; + case DELIVERED -> "Zugestellt"; + case COMPLETED -> "Abgeschlossen"; + case CANCELLED -> "Storniert"; + default -> status.toString(); + }; + } + + private String generateUpdateDescription(Job oldJob, Job newJob) { + StringBuilder description = new StringBuilder("Job-Daten aktualisiert"); + + if (oldJob == null) { + return description.toString(); + } + + // Check for specific field changes + boolean hasChanges = false; + + if (!equals(oldJob.getDeliveryCompany(), newJob.getDeliveryCompany())) { + description.append(" - Kunde"); + hasChanges = true; + } + + if (!equals(oldJob.getPickupCity(), newJob.getPickupCity()) || + !equals(oldJob.getDeliveryCity(), newJob.getDeliveryCity())) { + if (hasChanges) description.append(","); + description.append(" - Orte"); + hasChanges = true; + } + + if (!equals(oldJob.getRemark(), newJob.getRemark())) { + if (hasChanges) description.append(","); + description.append(" - Bemerkung"); + hasChanges = true; + } + + return description.toString(); + } + + private boolean equals(Object a, Object b) { + return (a == null && b == null) || (a != null && a.equals(b)); + } + + private String serializeJobForComparison(Job job) { + try { + // Only serialize relevant fields for comparison + return objectMapper.writeValueAsString(job); + } catch (Exception e) { + return "Serialization failed"; + } + } +} \ No newline at end of file