From 6279c972f1bd7ea663cc14540c8490c6c13f042d Mon Sep 17 00:00:00 2001 From: Sven Carstensen Date: Wed, 4 Feb 2026 11:33:43 +0100 Subject: [PATCH] Erweiterungen --- .../votianlt/ai/config/LlmConfig.java | 13 +- .../ai/service/AiStatisticsService.java | 388 +++++------- .../votianlt/ai/service/LlmRestClient.java | 57 +- .../votianlt/config/JacksonConfig.java | 9 +- .../votianlt/config/MongoConfig.java | 3 + .../config/PasswordEncoderConfig.java | 1 - .../controller/MessageApiController.java | 52 +- .../controller/MessageController.java | 36 +- .../dto/ChatMessageInboundPayload.java | 7 +- .../dto/ChatMessageOutboundPayload.java | 32 +- .../votianlt/dto/ClientMessageSummary.java | 2 +- .../votianlt/event/JobCreatedEvent.java | 19 + .../event/MessageReadStatusChangedEvent.java | 7 +- .../votianlt/event/MessageReceivedEvent.java | 6 +- .../votianlt/mcp/config/McpServerConfig.java | 4 +- .../votianlt/mcp/tools/JobQueryTool.java | 41 +- .../votianlt/mcp/tools/JobStatisticsTool.java | 45 +- .../mcp/tools/TaskCompletionTool.java | 13 +- .../config/PluginMessagingConfig.java | 92 +-- .../delivery/AcknowledgmentHandler.java | 16 +- .../messaging/delivery/DeliveryConfig.java | 1 - .../delivery/MessageDeliveryService.java | 87 ++- .../delivery/MessageDeliveryServiceImpl.java | 136 ++-- .../messaging/delivery/RetryScheduler.java | 1 - .../votianlt/messaging/model/AckStatus.java | 5 +- .../model/AcknowledgmentMessage.java | 1 - .../messaging/model/DeliveryOptions.java | 14 +- .../messaging/model/DeliveryReceipt.java | 17 +- .../messaging/model/DeliveryStatus.java | 9 +- .../messaging/model/MessageEnvelope.java | 6 +- .../messaging/model/PendingDelivery.java | 16 +- .../plugin/ConnectionStateEvent.java | 1 - .../messaging/plugin/MessagingPlugin.java | 120 ++-- .../messaging/plugin/PluginConfig.java | 41 +- .../messaging/plugin/PluginException.java | 1 - .../messaging/plugin/PluginManager.java | 90 +-- .../messaging/plugin/PluginMetadata.java | 7 +- .../messaging/plugin/ReceivedMessage.java | 10 +- .../messaging/plugin/SendOptions.java | 24 +- .../plugin/mqtt/MqttMessagingPlugin.java | 156 ++--- .../de/assecutor/votianlt/model/AppUser.java | 1 - .../de/assecutor/votianlt/model/Message.java | 13 +- .../votianlt/model/MessageContentType.java | 3 +- .../votianlt/model/MessageOrigin.java | 2 +- .../assecutor/votianlt/model/MessageType.java | 2 +- .../assecutor/votianlt/model/PriceTable.java | 3 +- .../model/invoices/CustomerInvoice.java | 16 +- .../model/invoices/CustomerInvoiceData.java | 2 +- .../model/invoices/CustomerInvoiceItem.java | 15 +- .../model/invoices/SystemInvoiceData.java | 213 +++++-- .../votianlt/model/task/TaskType.java | 7 +- .../votianlt/mqtt/MqttPublisher.java | 43 +- .../pages/base/ui/view/AdminLayout.java | 15 +- .../votianlt/pages/service/AddJobService.java | 11 + .../pages/service/UserInvoiceDataService.java | 3 +- .../votianlt/pages/view/AddAppUserView.java | 13 +- .../votianlt/pages/view/AddCustomerView.java | 6 +- .../votianlt/pages/view/AddJobView.java | 292 +++++---- .../pages/view/AdminDashboardView.java | 33 +- .../pages/view/AdminPricetableView.java | 24 +- .../pages/view/AuthenticatedStartView.java | 2 +- .../votianlt/pages/view/EditAppUserView.java | 3 - .../votianlt/pages/view/EditProfileView.java | 63 +- .../votianlt/pages/view/InvoicesView.java | 5 +- .../votianlt/pages/view/JobHistoryView.java | 5 +- .../votianlt/pages/view/JobSummaryView.java | 93 ++- .../votianlt/pages/view/LoginView.java | 6 +- .../pages/view/MessageDetailsView.java | 261 ++++---- .../votianlt/pages/view/MessagesView.java | 160 ++++- .../votianlt/pages/view/MyInvoicesView.java | 12 +- .../votianlt/pages/view/PdfTestView.java | 11 +- .../votianlt/pages/view/ShowJobsView.java | 151 ++++- .../votianlt/pages/view/StartView.java | 3 +- .../votianlt/pages/view/StatisticsView.java | 580 ++++++++---------- .../votianlt/pages/view/UserMessagesView.java | 91 ++- .../votianlt/repository/JobRepository.java | 21 +- .../repository/MessageRepository.java | 6 +- .../repository/PendingDeliveryRepository.java | 4 +- .../service/ClientConnectionService.java | 78 +-- .../service/CustomerInvoiceService.java | 44 +- .../votianlt/service/EmailService.java | 4 +- .../votianlt/service/JobBroadcaster.java | 71 +++ .../service/JobStatisticsService.java | 83 +-- .../service/MessageBadgeUpdateService.java | 20 +- .../votianlt/service/MessageBroadcaster.java | 28 +- .../votianlt/service/MessageService.java | 79 ++- .../service/MonthlySchedulerService.java | 22 +- .../service/SystemInvoiceService.java | 23 +- .../votianlt/util/DateTimeFormatUtil.java | 80 +++ 89 files changed, 2371 insertions(+), 1941 deletions(-) create mode 100644 src/main/java/de/assecutor/votianlt/event/JobCreatedEvent.java create mode 100644 src/main/java/de/assecutor/votianlt/service/JobBroadcaster.java create mode 100644 src/main/java/de/assecutor/votianlt/util/DateTimeFormatUtil.java diff --git a/src/main/java/de/assecutor/votianlt/ai/config/LlmConfig.java b/src/main/java/de/assecutor/votianlt/ai/config/LlmConfig.java index e730a98..232e8f4 100644 --- a/src/main/java/de/assecutor/votianlt/ai/config/LlmConfig.java +++ b/src/main/java/de/assecutor/votianlt/ai/config/LlmConfig.java @@ -10,8 +10,8 @@ import java.net.URI; import java.net.URL; /** - * Configuration for LLM integration via LM Studio. - * LM Studio provides an OpenAI-compatible API. + * Configuration for LLM integration via LM Studio. LM Studio provides an + * OpenAI-compatible API. */ @Configuration @Slf4j @@ -37,13 +37,16 @@ public class LlmConfig { // Test 1: Basic connectivity testEndpoint(baseUrl + "/v1/models", "GET", null); - // Test 2: Chat completions endpoint WITHOUT streaming (POST with minimal payload) - String testPayload = "{\"model\":\"" + model + "\",\"messages\":[{\"role\":\"user\",\"content\":\"ping\"}],\"max_tokens\":1,\"stream\":false}"; + // Test 2: Chat completions endpoint WITHOUT streaming (POST with minimal + // payload) + String testPayload = "{\"model\":\"" + model + + "\",\"messages\":[{\"role\":\"user\",\"content\":\"ping\"}],\"max_tokens\":1,\"stream\":false}"; log.info("Test payload (stream=false): {}", testPayload); testEndpoint(baseUrl + "/v1/chat/completions", "POST", testPayload); // Test 3: Chat completions WITH streaming to compare behavior - String streamPayload = "{\"model\":\"" + model + "\",\"messages\":[{\"role\":\"user\",\"content\":\"ping\"}],\"max_tokens\":1,\"stream\":true}"; + String streamPayload = "{\"model\":\"" + model + + "\",\"messages\":[{\"role\":\"user\",\"content\":\"ping\"}],\"max_tokens\":1,\"stream\":true}"; log.info("Test payload (stream=true): {}", streamPayload); testEndpoint(baseUrl + "/v1/chat/completions", "POST", streamPayload); } diff --git a/src/main/java/de/assecutor/votianlt/ai/service/AiStatisticsService.java b/src/main/java/de/assecutor/votianlt/ai/service/AiStatisticsService.java index 0aacecd..913ef6c 100644 --- a/src/main/java/de/assecutor/votianlt/ai/service/AiStatisticsService.java +++ b/src/main/java/de/assecutor/votianlt/ai/service/AiStatisticsService.java @@ -15,8 +15,8 @@ import java.util.List; import java.util.Map; /** - * Service for AI-assisted statistics analysis with chart visualization. - * Uses LM Studio via direct REST client (like aimailassistant) instead of Spring AI. + * Service for AI-assisted statistics analysis with chart visualization. Uses LM + * Studio via direct REST client (like aimailassistant) instead of Spring AI. */ @Service @Slf4j @@ -36,11 +36,8 @@ public class AiStatisticsService { /** * Response record containing text and optional chart data. */ - public record StatisticsResponse( - String textResponse, - String chartType, - String chartData - ) {} + public record StatisticsResponse(String textResponse, String chartType, String chartData) { + } /** * Analyze a statistics query and return a response with optional visualization. @@ -48,11 +45,11 @@ public class AiStatisticsService { public StatisticsResponse analyzeStatisticsQuery(String userQuery) { log.info("Processing statistics query: {}", userQuery); - // Determine query type and prepare chart data (includes customer filter detection) + // Determine query type and prepare chart data (includes customer filter + // detection) QueryAnalysis analysis = analyzeQueryType(userQuery); - log.debug("Query analysis - Type: {}, Chart: {}, Customer: {}, Status: {}", - analysis.queryType, analysis.chartType, - analysis.customerFilter != null ? analysis.customerFilter : "none", + log.debug("Query analysis - Type: {}, Chart: {}, Customer: {}, Status: {}", analysis.queryType, + analysis.chartType, analysis.customerFilter != null ? analysis.customerFilter : "none", analysis.statusFilter != null ? analysis.statusFilter : "none"); // Gather context (statistics or job list depending on query type) @@ -62,8 +59,7 @@ public class AiStatisticsService { String prompt = buildPrompt(userQuery, statisticsContext, analysis); // System prompt - different for list vs statistics queries - String systemPrompt = analysis.queryType.equals("list") - ? buildListSystemPrompt() + String systemPrompt = analysis.queryType.equals("list") ? buildListSystemPrompt() : buildStatisticsSystemPrompt(); // Call LLM via direct REST client (like aimailassistant) @@ -74,21 +70,19 @@ public class AiStatisticsService { return new StatisticsResponse(llmResponse, analysis.chartType, analysis.chartData); } else { log.warn("LLM returned null or blank response, using fallback"); - return new StatisticsResponse( - buildFallbackResponse(analysis), - analysis.chartType, - analysis.chartData - ); + return new StatisticsResponse(buildFallbackResponse(analysis), analysis.chartType, analysis.chartData); } } - private record QueryAnalysis( - String queryType, - String chartType, - String chartData, - String customerFilter, // null = no filter, show all data - JobStatus statusFilter // null = no status filter - ) {} + private record QueryAnalysis(String queryType, String chartType, String chartData, String customerFilter, // null = + // no + // filter, + // show + // all + // data + JobStatus statusFilter // null = no status filter + ) { + } private String buildStatisticsSystemPrompt() { return """ @@ -134,47 +128,42 @@ public class AiStatisticsService { } /** - * Detect user-specified chart type from the query. - * Returns null if no specific chart type was requested. + * Detect user-specified chart type from the query. Returns null if no specific + * chart type was requested. */ private String detectUserChartTypePreference(String query) { String lowerQuery = query.toLowerCase(); // Balkendiagramm / Bar Chart - if (lowerQuery.contains("balken") || lowerQuery.contains("bar chart") || - lowerQuery.contains("säulen") || lowerQuery.contains("balkendiagramm")) { + if (lowerQuery.contains("balken") || lowerQuery.contains("bar chart") || lowerQuery.contains("säulen") + || lowerQuery.contains("balkendiagramm")) { return "bar"; } // Tortendiagramm / Pie Chart - if (lowerQuery.contains("torten") || lowerQuery.contains("pie") || - lowerQuery.contains("kreis") || lowerQuery.contains("tortendiagramm") || - lowerQuery.contains("kreisdiagramm")) { + if (lowerQuery.contains("torten") || lowerQuery.contains("pie") || lowerQuery.contains("kreis") + || lowerQuery.contains("tortendiagramm") || lowerQuery.contains("kreisdiagramm")) { return "pie"; } // Donut / Ring Chart - if (lowerQuery.contains("donut") || lowerQuery.contains("ring") || - lowerQuery.contains("doughnut")) { + if (lowerQuery.contains("donut") || lowerQuery.contains("ring") || lowerQuery.contains("doughnut")) { return "doughnut"; } // Liniendiagramm / Line Chart - if (lowerQuery.contains("linie") || lowerQuery.contains("line") || - lowerQuery.contains("liniendiagramm") || lowerQuery.contains("kurve") || - lowerQuery.contains("graph")) { + if (lowerQuery.contains("linie") || lowerQuery.contains("line") || lowerQuery.contains("liniendiagramm") + || lowerQuery.contains("kurve") || lowerQuery.contains("graph")) { return "line"; } // Flächendiagramm / Area Chart - if (lowerQuery.contains("fläche") || lowerQuery.contains("area") || - lowerQuery.contains("flächendiagramm")) { + if (lowerQuery.contains("fläche") || lowerQuery.contains("area") || lowerQuery.contains("flächendiagramm")) { return "line"; // Line with fill=true } // Radar Chart - if (lowerQuery.contains("radar") || lowerQuery.contains("netz") || - lowerQuery.contains("spinne")) { + if (lowerQuery.contains("radar") || lowerQuery.contains("netz") || lowerQuery.contains("spinne")) { return "radar"; } @@ -216,31 +205,29 @@ public class AiStatisticsService { String chartData; // Status-bezogene Anfragen (Statistik, nicht Liste) - if ((lowerQuery.contains("status") && (lowerQuery.contains("statistik") || lowerQuery.contains("verteilung") || - lowerQuery.contains("übersicht") || lowerQuery.contains("wie viele"))) || - lowerQuery.contains("zählen") || lowerQuery.contains("anzahl")) { + if ((lowerQuery.contains("status") && (lowerQuery.contains("statistik") || lowerQuery.contains("verteilung") + || lowerQuery.contains("übersicht") || lowerQuery.contains("wie viele"))) + || lowerQuery.contains("zählen") || lowerQuery.contains("anzahl")) { queryType = "status"; defaultChartType = "doughnut"; chartData = buildStatusChartData(customerFilter); } // Umsatz-bezogene Anfragen - else if (lowerQuery.contains("umsatz") || lowerQuery.contains("revenue") || - lowerQuery.contains("einnahmen")) { + else if (lowerQuery.contains("umsatz") || lowerQuery.contains("revenue") || lowerQuery.contains("einnahmen")) { queryType = "revenue"; defaultChartType = "bar"; - chartData = customerFilter != null ? buildCustomerRevenueChartData(customerFilter) : buildRevenueChartData(); + chartData = customerFilter != null ? buildCustomerRevenueChartData(customerFilter) + : buildRevenueChartData(); } // Trend-bezogene Anfragen - else if (lowerQuery.contains("trend") || lowerQuery.contains("monat") || - lowerQuery.contains("entwicklung") || lowerQuery.contains("jahr") || - lowerQuery.contains("verlauf")) { + else if (lowerQuery.contains("trend") || lowerQuery.contains("monat") || lowerQuery.contains("entwicklung") + || lowerQuery.contains("jahr") || lowerQuery.contains("verlauf")) { queryType = "trend"; defaultChartType = "line"; chartData = buildTrendChartData(customerFilter); } // Task-bezogene Anfragen - else if (lowerQuery.contains("task") || lowerQuery.contains("aufgabe") || - lowerQuery.contains("erledigt")) { + else if (lowerQuery.contains("task") || lowerQuery.contains("aufgabe") || lowerQuery.contains("erledigt")) { queryType = "tasks"; defaultChartType = "doughnut"; chartData = buildTaskChartData(); @@ -274,7 +261,8 @@ public class AiStatisticsService { if (lowerQuery.contains("abgeholt") || lowerQuery.contains("picked up")) { return JobStatus.PICKED_UP; } - if (lowerQuery.contains("in transport") || lowerQuery.contains("in transit") || lowerQuery.contains("unterwegs")) { + if (lowerQuery.contains("in transport") || lowerQuery.contains("in transit") + || lowerQuery.contains("unterwegs")) { return JobStatus.IN_TRANSIT; } if (lowerQuery.contains("zugestellt") || lowerQuery.contains("delivered") || lowerQuery.contains("geliefert")) { @@ -283,7 +271,8 @@ public class AiStatisticsService { if (lowerQuery.contains("abgeschlossen") || lowerQuery.contains("completed") || lowerQuery.contains("fertig")) { return JobStatus.COMPLETED; } - if (lowerQuery.contains("storniert") || lowerQuery.contains("cancelled") || lowerQuery.contains("abgebrochen")) { + if (lowerQuery.contains("storniert") || lowerQuery.contains("cancelled") + || lowerQuery.contains("abgebrochen")) { return JobStatus.CANCELLED; } return null; @@ -294,45 +283,31 @@ public class AiStatisticsService { */ private boolean isListQuery(String lowerQuery) { // Keywords that indicate a list/detail query (not statistics) - boolean hasListKeywords = lowerQuery.contains("zeige alle") || - lowerQuery.contains("liste") || - lowerQuery.contains("welche jobs") || - lowerQuery.contains("alle jobs") || - lowerQuery.contains("alle aufträge") || - lowerQuery.contains("zeige die jobs") || - lowerQuery.contains("zeige die aufträge") || - lowerQuery.contains("zeig mir die") || - lowerQuery.contains("gib mir die"); + boolean hasListKeywords = lowerQuery.contains("zeige alle") || lowerQuery.contains("liste") + || lowerQuery.contains("welche jobs") || lowerQuery.contains("alle jobs") + || lowerQuery.contains("alle aufträge") || lowerQuery.contains("zeige die jobs") + || lowerQuery.contains("zeige die aufträge") || lowerQuery.contains("zeig mir die") + || lowerQuery.contains("gib mir die"); // Keywords that indicate statistics (override list detection) - boolean hasStatsKeywords = lowerQuery.contains("statistik") || - lowerQuery.contains("diagramm") || - lowerQuery.contains("chart") || - lowerQuery.contains("verteilung") || - lowerQuery.contains("wie viele") || - lowerQuery.contains("anzahl") || - lowerQuery.contains("zähle") || - lowerQuery.contains("umsatz") || - lowerQuery.contains("trend") || - lowerQuery.contains("torte") || - lowerQuery.contains("balken"); + boolean hasStatsKeywords = lowerQuery.contains("statistik") || lowerQuery.contains("diagramm") + || lowerQuery.contains("chart") || lowerQuery.contains("verteilung") || lowerQuery.contains("wie viele") + || lowerQuery.contains("anzahl") || lowerQuery.contains("zähle") || lowerQuery.contains("umsatz") + || lowerQuery.contains("trend") || lowerQuery.contains("torte") || lowerQuery.contains("balken"); return hasListKeywords && !hasStatsKeywords; } /** - * Detect customer filter from the query. - * Returns the matching customer name or null if no filter detected. + * Detect customer filter from the query. Returns the matching customer name or + * null if no filter detected. */ private String detectCustomerFilter(String query) { String lowerQuery = query.toLowerCase(); // Keywords that indicate a customer filter - String[] filterIndicators = { - "für ", "von ", "bei ", "kunde ", "firma ", "unternehmen ", - "für die firma ", "für den kunden ", "von der firma ", "vom kunden ", - "nur ", "ausschließlich ", "speziell " - }; + String[] filterIndicators = { "für ", "von ", "bei ", "kunde ", "firma ", "unternehmen ", "für die firma ", + "für den kunden ", "von der firma ", "vom kunden ", "nur ", "ausschließlich ", "speziell " }; // Check if any indicator is present boolean hasIndicator = false; @@ -367,15 +342,14 @@ public class AiStatisticsService { List labels = new ArrayList<>(); List data = new ArrayList<>(); // Moderne Farbpalette mit satteren Farben - List colors = List.of( - "#06b6d4", // CREATED - cyan - "#f59e0b", // IN_PROGRESS - amber - "#3b82f6", // PICKUP_SCHEDULED - blau - "#8b5cf6", // PICKED_UP - violett - "#f97316", // IN_TRANSIT - orange - "#22c55e", // DELIVERED - grün - "#6366f1", // COMPLETED - indigo - "#ef4444" // CANCELLED - rot + List colors = List.of("#06b6d4", // CREATED - cyan + "#f59e0b", // IN_PROGRESS - amber + "#3b82f6", // PICKUP_SCHEDULED - blau + "#8b5cf6", // PICKED_UP - violett + "#f97316", // IN_TRANSIT - orange + "#22c55e", // DELIVERED - grün + "#6366f1", // COMPLETED - indigo + "#ef4444" // CANCELLED - rot ); for (JobStatus status : JobStatus.values()) { @@ -402,12 +376,10 @@ public class AiStatisticsService { } // Gradient-ähnliche Farbpalette für Balken - List colors = List.of( - "#6366f1", "#8b5cf6", "#a855f7", "#c084fc", - "#d8b4fe", "#e9d5ff", "#f3e8ff", "#faf5ff", - "#ede9fe", "#ddd6fe" - ); - return buildChartJsonDouble(labels, data, colors.subList(0, Math.min(labels.size(), colors.size())), "Umsatz (EUR)"); + List colors = List.of("#6366f1", "#8b5cf6", "#a855f7", "#c084fc", "#d8b4fe", "#e9d5ff", "#f3e8ff", + "#faf5ff", "#ede9fe", "#ddd6fe"); + return buildChartJsonDouble(labels, data, colors.subList(0, Math.min(labels.size(), colors.size())), + "Umsatz (EUR)"); } private String buildCustomerRevenueChartData(String customer) { @@ -418,11 +390,8 @@ public class AiStatisticsService { long inProgress = statusCounts.getOrDefault(JobStatus.IN_PROGRESS, 0L); List labels = List.of("Aufträge gesamt", "Abgeschlossen", "In Bearbeitung", "Umsatz (€/100)"); - List data = List.of( - (double) totalJobs, - (double) completed, - (double) inProgress, - totalRevenue.doubleValue() / 100 // Scale down for better visualization + List data = List.of((double) totalJobs, (double) completed, (double) inProgress, + totalRevenue.doubleValue() / 100 // Scale down for better visualization ); List colors = List.of("#3b82f6", "#22c55e", "#f59e0b", "#6366f1"); @@ -435,16 +404,15 @@ public class AiStatisticsService { ? statisticsService.getMonthlyJobCountsForCustomer(currentYear, customerFilter) : statisticsService.getMonthlyJobCounts(currentYear); - List labels = List.of("Jan", "Feb", "Mär", "Apr", "Mai", "Jun", - "Jul", "Aug", "Sep", "Okt", "Nov", "Dez"); + List labels = List.of("Jan", "Feb", "Mär", "Apr", "Mai", "Jun", "Jul", "Aug", "Sep", "Okt", "Nov", + "Dez"); List data = new ArrayList<>(); for (Month month : Month.values()) { data.add(monthlyData.getOrDefault(month, 0L)); } - String datasetLabel = customerFilter != null - ? String.format("%s - %d", customerFilter, currentYear) + String datasetLabel = customerFilter != null ? String.format("%s - %d", customerFilter, currentYear) : String.format("Aufträge %d", currentYear); return String.format(""" @@ -471,10 +439,7 @@ public class AiStatisticsService { Map taskStats = statisticsService.getTaskCompletionStats(); List labels = List.of("Erledigt", "Ausstehend"); - List data = List.of( - taskStats.getOrDefault("completed", 0L), - taskStats.getOrDefault("pending", 0L) - ); + List data = List.of(taskStats.getOrDefault("completed", 0L), taskStats.getOrDefault("pending", 0L)); List colors = List.of("#22c55e", "#f59e0b"); return buildChartJson(labels, data, colors, "Aufgaben"); @@ -485,8 +450,7 @@ public class AiStatisticsService { ? statisticsService.getJobCountsByStatusForCustomer(customerFilter) : statisticsService.getJobCountsByStatus(); - long total = customerFilter != null - ? statisticsService.getTotalJobCountForCustomer(customerFilter) + long total = customerFilter != null ? statisticsService.getTotalJobCountForCustomer(customerFilter) : statisticsService.getTotalJobCount(); long completed = statusCounts.getOrDefault(JobStatus.COMPLETED, 0L); long inProgress = statusCounts.getOrDefault(JobStatus.IN_PROGRESS, 0L); @@ -570,8 +534,8 @@ public class AiStatisticsService { // General statistics (all data) var statusCounts = statisticsService.getJobCountsByStatus(); context.append("**Aktuelle Auftragsstatistiken:**\n"); - statusCounts.forEach((status, count) -> - context.append(String.format("- %s: %d Aufträge\n", status.getDisplayName(), count))); + statusCounts.forEach((status, count) -> context + .append(String.format("- %s: %d Aufträge\n", status.getDisplayName(), count))); context.append(String.format("\n**Gesamtübersicht:**\n")); context.append(String.format("- Gesamtanzahl Aufträge: %d\n", statisticsService.getTotalJobCount())); @@ -591,8 +555,7 @@ public class AiStatisticsService { context.append("\n**Top 5 Kunden nach Umsatz:**\n"); for (var entry : topCustomers) { context.append(String.format("- %s: %.2f EUR\n", - entry.getKey() != null ? entry.getKey() : "Unbekannt", - entry.getValue())); + entry.getKey() != null ? entry.getKey() : "Unbekannt", entry.getValue())); } } } @@ -606,8 +569,8 @@ public class AiStatisticsService { List jobs; if (customerFilter != null && statusFilter != null) { jobs = statisticsService.getJobsByCustomerAndStatus(customerFilter, statusFilter); - context.append(String.format("**Jobs für %s mit Status %s:**\n\n", - customerFilter, statusFilter.getDisplayName())); + context.append( + String.format("**Jobs für %s mit Status %s:**\n\n", customerFilter, statusFilter.getDisplayName())); } else if (customerFilter != null) { jobs = statisticsService.getJobsByCustomer(customerFilter); context.append(String.format("**Jobs für %s:**\n\n", customerFilter)); @@ -628,10 +591,10 @@ public class AiStatisticsService { context.append(String.format("... und %d weitere Jobs\n", jobs.size() - 10)); break; } - context.append(String.format("- %s: %s (%s)\n", - job.getJobNumber() != null ? job.getJobNumber() : "Ohne Nr.", - job.getCustomerSelection() != null ? job.getCustomerSelection() : "Unbekannt", - job.getStatus().getDisplayName())); + context.append( + String.format("- %s: %s (%s)\n", job.getJobNumber() != null ? job.getJobNumber() : "Ohne Nr.", + job.getCustomerSelection() != null ? job.getCustomerSelection() : "Unbekannt", + job.getStatus().getDisplayName())); shown++; } @@ -639,7 +602,8 @@ public class AiStatisticsService { } private String buildPrompt(String userQuery, String statisticsContext, QueryAnalysis analysis) { - // User prompt contains only the context and question (system prompt is passed separately) + // User prompt contains only the context and question (system prompt is passed + // separately) return String.format(""" %s @@ -652,107 +616,93 @@ public class AiStatisticsService { JobStatus statusFilter = analysis.statusFilter; return switch (analysis.queryType) { - case "list" -> { - List jobs; - if (customer != null && statusFilter != null) { - jobs = statisticsService.getJobsByCustomerAndStatus(customer, statusFilter); - yield String.format("Es wurden %d Jobs für %s mit Status \"%s\" gefunden.", - jobs.size(), customer, statusFilter.getDisplayName()); - } else if (customer != null) { - jobs = statisticsService.getJobsByCustomer(customer); - yield String.format("Es wurden %d Jobs für %s gefunden.", jobs.size(), customer); - } else if (statusFilter != null) { - jobs = statisticsService.getJobsByStatus(statusFilter); - yield String.format("Es wurden %d Jobs mit Status \"%s\" gefunden.", - jobs.size(), statusFilter.getDisplayName()); - } else { - yield "Hier sind die aktuellen Jobs."; - } + case "list" -> { + List jobs; + if (customer != null && statusFilter != null) { + jobs = statisticsService.getJobsByCustomerAndStatus(customer, statusFilter); + yield String.format("Es wurden %d Jobs für %s mit Status \"%s\" gefunden.", jobs.size(), customer, + statusFilter.getDisplayName()); + } else if (customer != null) { + jobs = statisticsService.getJobsByCustomer(customer); + yield String.format("Es wurden %d Jobs für %s gefunden.", jobs.size(), customer); + } else if (statusFilter != null) { + jobs = statisticsService.getJobsByStatus(statusFilter); + yield String.format("Es wurden %d Jobs mit Status \"%s\" gefunden.", jobs.size(), + statusFilter.getDisplayName()); + } else { + yield "Hier sind die aktuellen Jobs."; } - case "status" -> { - var counts = customer != null - ? statisticsService.getJobCountsByStatusForCustomer(customer) - : statisticsService.getJobCountsByStatus(); - String title = customer != null - ? String.format("**Auftragsübersicht für %s:**\n\n", customer) - : "**Auftragsübersicht nach Status:**\n\n"; - StringBuilder sb = new StringBuilder(title); - counts.forEach((status, count) -> { - if (count > 0) { - sb.append(String.format("- **%s:** %d Aufträge\n", status.getDisplayName(), count)); - } - }); - long total = customer != null - ? statisticsService.getTotalJobCountForCustomer(customer) - : statisticsService.getTotalJobCount(); - sb.append(String.format("\n**Gesamt:** %d Aufträge", total)); + } + case "status" -> { + var counts = customer != null ? statisticsService.getJobCountsByStatusForCustomer(customer) + : statisticsService.getJobCountsByStatus(); + String title = customer != null ? String.format("**Auftragsübersicht für %s:**\n\n", customer) + : "**Auftragsübersicht nach Status:**\n\n"; + StringBuilder sb = new StringBuilder(title); + counts.forEach((status, count) -> { + if (count > 0) { + sb.append(String.format("- **%s:** %d Aufträge\n", status.getDisplayName(), count)); + } + }); + long total = customer != null ? statisticsService.getTotalJobCountForCustomer(customer) + : statisticsService.getTotalJobCount(); + sb.append(String.format("\n**Gesamt:** %d Aufträge", total)); + yield sb.toString(); + } + case "revenue" -> { + if (customer != null) { + var revenue = statisticsService.getTotalRevenueForCustomer(customer); + var jobCount = statisticsService.getTotalJobCountForCustomer(customer); + yield String.format("**Umsatz für %s:**\n\n" + "- **Gesamtumsatz:** %.2f EUR\n" + "- **Aufträge:** %d", + customer, revenue, jobCount); + } else { + var topCustomers = statisticsService.getTopCustomersByRevenue(5); + StringBuilder sb = new StringBuilder("**Top Kunden nach Umsatz:**\n\n"); + int rank = 1; + for (var entry : topCustomers) { + sb.append(String.format("%d. **%s:** %.2f EUR\n", rank++, + entry.getKey() != null ? entry.getKey() : "Unbekannt", entry.getValue())); + } + sb.append(String.format("\n**Gesamtumsatz:** %.2f EUR", statisticsService.getTotalRevenue())); yield sb.toString(); } - case "revenue" -> { - if (customer != null) { - var revenue = statisticsService.getTotalRevenueForCustomer(customer); - var jobCount = statisticsService.getTotalJobCountForCustomer(customer); - yield String.format("**Umsatz für %s:**\n\n" + - "- **Gesamtumsatz:** %.2f EUR\n" + - "- **Aufträge:** %d", - customer, revenue, jobCount); - } else { - var topCustomers = statisticsService.getTopCustomersByRevenue(5); - StringBuilder sb = new StringBuilder("**Top Kunden nach Umsatz:**\n\n"); - int rank = 1; - for (var entry : topCustomers) { - sb.append(String.format("%d. **%s:** %.2f EUR\n", - rank++, - entry.getKey() != null ? entry.getKey() : "Unbekannt", - entry.getValue())); - } - sb.append(String.format("\n**Gesamtumsatz:** %.2f EUR", statisticsService.getTotalRevenue())); - yield sb.toString(); - } - } - case "trend" -> { - int year = Year.now().getValue(); - var monthly = customer != null - ? statisticsService.getMonthlyJobCountsForCustomer(year, customer) - : statisticsService.getMonthlyJobCounts(year); - long total = monthly.values().stream().mapToLong(Long::longValue).sum(); - String title = customer != null - ? String.format("**Monatstrend %d für %s:**", year, customer) - : String.format("**Monatstrend %d:**", year); - yield String.format("%s\n\nInsgesamt wurden %d Aufträge erstellt. " + - "Die Verteilung ist im Diagramm ersichtlich.", title, total); - } - case "tasks" -> { - var taskStats = statisticsService.getTaskCompletionStats(); - long total = taskStats.getOrDefault("total", 0L); - long completed = taskStats.getOrDefault("completed", 0L); - double rate = total > 0 ? (double) completed / total * 100 : 0; - yield String.format("**Aufgabenstatistik:**\n\n" + - "- **Gesamt:** %d Aufgaben\n" + - "- **Erledigt:** %d (%.1f%%)\n" + - "- **Ausstehend:** %d", - total, completed, rate, taskStats.getOrDefault("pending", 0L)); - } - default -> { - if (customer != null) { - yield String.format("**Übersicht für %s:**\n\n" + - "- **Aufträge gesamt:** %d\n" + - "- **Abschlussrate:** %.1f%%\n" + - "- **Umsatz:** %.2f EUR", - customer, - statisticsService.getTotalJobCountForCustomer(customer), - statisticsService.getCompletionRateForCustomer(customer), - statisticsService.getTotalRevenueForCustomer(customer)); - } else { - yield String.format("**Übersicht:**\n\n" + - "- **Aufträge gesamt:** %d\n" + - "- **Abschlussrate:** %.1f%%\n" + - "- **Gesamtumsatz:** %.2f EUR", - statisticsService.getTotalJobCount(), - statisticsService.getCompletionRate(), - statisticsService.getTotalRevenue()); - } + } + case "trend" -> { + int year = Year.now().getValue(); + var monthly = customer != null ? statisticsService.getMonthlyJobCountsForCustomer(year, customer) + : statisticsService.getMonthlyJobCounts(year); + long total = monthly.values().stream().mapToLong(Long::longValue).sum(); + String title = customer != null ? String.format("**Monatstrend %d für %s:**", year, customer) + : String.format("**Monatstrend %d:**", year); + yield String.format( + "%s\n\nInsgesamt wurden %d Aufträge erstellt. " + "Die Verteilung ist im Diagramm ersichtlich.", + title, total); + } + case "tasks" -> { + var taskStats = statisticsService.getTaskCompletionStats(); + long total = taskStats.getOrDefault("total", 0L); + long completed = taskStats.getOrDefault("completed", 0L); + double rate = total > 0 ? (double) completed / total * 100 : 0; + yield String.format("**Aufgabenstatistik:**\n\n" + "- **Gesamt:** %d Aufgaben\n" + + "- **Erledigt:** %d (%.1f%%)\n" + "- **Ausstehend:** %d", total, completed, rate, + taskStats.getOrDefault("pending", 0L)); + } + default -> { + if (customer != null) { + yield String.format( + "**Übersicht für %s:**\n\n" + "- **Aufträge gesamt:** %d\n" + "- **Abschlussrate:** %.1f%%\n" + + "- **Umsatz:** %.2f EUR", + customer, statisticsService.getTotalJobCountForCustomer(customer), + statisticsService.getCompletionRateForCustomer(customer), + statisticsService.getTotalRevenueForCustomer(customer)); + } else { + yield String.format( + "**Übersicht:**\n\n" + "- **Aufträge gesamt:** %d\n" + "- **Abschlussrate:** %.1f%%\n" + + "- **Gesamtumsatz:** %.2f EUR", + statisticsService.getTotalJobCount(), statisticsService.getCompletionRate(), + statisticsService.getTotalRevenue()); } + } }; } } diff --git a/src/main/java/de/assecutor/votianlt/ai/service/LlmRestClient.java b/src/main/java/de/assecutor/votianlt/ai/service/LlmRestClient.java index 25fed44..f7307ea 100644 --- a/src/main/java/de/assecutor/votianlt/ai/service/LlmRestClient.java +++ b/src/main/java/de/assecutor/votianlt/ai/service/LlmRestClient.java @@ -13,8 +13,8 @@ import java.util.List; import java.util.Map; /** - * Direct REST client for LM Studio API. - * Uses Spring WebClient like aimailassistant - bypasses Spring AI. + * Direct REST client for LM Studio API. Uses Spring WebClient like + * aimailassistant - bypasses Spring AI. */ @Component @Slf4j @@ -24,14 +24,10 @@ public class LlmRestClient { private final ObjectMapper objectMapper; private final String model; - public LlmRestClient( - @Value("${spring.ai.openai.base-url:http://192.168.180.10:1234}") String baseUrl, - @Value("${spring.ai.openai.chat.options.model:local-model}") String model, - ObjectMapper objectMapper) { + public LlmRestClient(@Value("${spring.ai.openai.base-url:http://192.168.180.10:1234}") String baseUrl, + @Value("${spring.ai.openai.chat.options.model:local-model}") String model, ObjectMapper objectMapper) { - this.webClient = WebClient.builder() - .baseUrl(baseUrl + "/v1/chat/completions") - .build(); + this.webClient = WebClient.builder().baseUrl(baseUrl + "/v1/chat/completions").build(); this.model = model; this.objectMapper = objectMapper; @@ -41,8 +37,10 @@ public class LlmRestClient { /** * Send a chat completion request to LM Studio. * - * @param systemPrompt System prompt for context - * @param userMessage User message/question + * @param systemPrompt + * System prompt for context + * @param userMessage + * User message/question * @return LLM response text, or null on error */ public String chat(String systemPrompt, String userMessage) { @@ -52,36 +50,29 @@ public class LlmRestClient { /** * Send a chat completion request to LM Studio with custom parameters. * - * @param systemPrompt System prompt for context - * @param userMessage User message/question - * @param temperature Temperature for response randomness (0.0-1.0) - * @param maxTokens Maximum tokens in response + * @param systemPrompt + * System prompt for context + * @param userMessage + * User message/question + * @param temperature + * Temperature for response randomness (0.0-1.0) + * @param maxTokens + * Maximum tokens in response * @return LLM response text, or null on error */ public String chat(String systemPrompt, String userMessage, double temperature, int maxTokens) { try { - Map request = Map.of( - "model", model, - "messages", List.of( - Map.of("role", "system", "content", systemPrompt != null ? systemPrompt : ""), - Map.of("role", "user", "content", userMessage) - ), - "temperature", temperature, - "max_tokens", maxTokens, - "stream", false // WICHTIG: Kein Streaming! + Map request = Map.of("model", model, "messages", + List.of(Map.of("role", "system", "content", systemPrompt != null ? systemPrompt : ""), + Map.of("role", "user", "content", userMessage)), + "temperature", temperature, "max_tokens", maxTokens, "stream", false // WICHTIG: Kein Streaming! ); - log.info("Sending request to LLM (model: {}, prompt length: {} chars)...", - model, userMessage.length()); + log.info("Sending request to LLM (model: {}, prompt length: {} chars)...", model, userMessage.length()); long startTime = System.currentTimeMillis(); - String response = webClient.post() - .contentType(MediaType.APPLICATION_JSON) - .bodyValue(request) - .retrieve() - .bodyToMono(String.class) - .timeout(Duration.ofSeconds(120)) - .block(); + String response = webClient.post().contentType(MediaType.APPLICATION_JSON).bodyValue(request).retrieve() + .bodyToMono(String.class).timeout(Duration.ofSeconds(120)).block(); long duration = System.currentTimeMillis() - startTime; log.info("LLM response received in {}ms", duration); diff --git a/src/main/java/de/assecutor/votianlt/config/JacksonConfig.java b/src/main/java/de/assecutor/votianlt/config/JacksonConfig.java index f100463..374c0c5 100644 --- a/src/main/java/de/assecutor/votianlt/config/JacksonConfig.java +++ b/src/main/java/de/assecutor/votianlt/config/JacksonConfig.java @@ -8,15 +8,15 @@ import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Primary; /** - * Jackson configuration for consistent JSON serialization across the application. - * Ensures all date/time fields are serialized as ISO 8601 strings. + * Jackson configuration for consistent JSON serialization across the + * application. Ensures all date/time fields are serialized as ISO 8601 strings. */ @Configuration public class JacksonConfig { /** - * Creates a configured ObjectMapper bean that serializes dates as ISO 8601 strings. - * This bean is used throughout the application for JSON serialization. + * Creates a configured ObjectMapper bean that serializes dates as ISO 8601 + * strings. This bean is used throughout the application for JSON serialization. */ @Bean @Primary @@ -28,4 +28,3 @@ public class JacksonConfig { return objectMapper; } } - diff --git a/src/main/java/de/assecutor/votianlt/config/MongoConfig.java b/src/main/java/de/assecutor/votianlt/config/MongoConfig.java index d301b95..7baa4e0 100644 --- a/src/main/java/de/assecutor/votianlt/config/MongoConfig.java +++ b/src/main/java/de/assecutor/votianlt/config/MongoConfig.java @@ -124,6 +124,9 @@ public class MongoConfig { if (source.containsKey("task_order")) { task.setTaskOrder(source.getInteger("task_order", 0)); } + if (source.containsKey("description")) { + task.setDescription(source.getString("description")); + } if (source.containsKey("completed")) { task.setCompleted(source.getBoolean("completed", false)); } diff --git a/src/main/java/de/assecutor/votianlt/config/PasswordEncoderConfig.java b/src/main/java/de/assecutor/votianlt/config/PasswordEncoderConfig.java index d32b5da..9f864c6 100644 --- a/src/main/java/de/assecutor/votianlt/config/PasswordEncoderConfig.java +++ b/src/main/java/de/assecutor/votianlt/config/PasswordEncoderConfig.java @@ -17,4 +17,3 @@ public class PasswordEncoderConfig { return new BCryptPasswordEncoder(); } } - diff --git a/src/main/java/de/assecutor/votianlt/controller/MessageApiController.java b/src/main/java/de/assecutor/votianlt/controller/MessageApiController.java index 1719bf8..2a5e902 100644 --- a/src/main/java/de/assecutor/votianlt/controller/MessageApiController.java +++ b/src/main/java/de/assecutor/votianlt/controller/MessageApiController.java @@ -14,8 +14,8 @@ import java.util.List; import java.util.Map; /** - * REST API controller for message operations. - * Provides endpoints for sending messages, retrieving messages, and marking messages as read. + * REST API controller for message operations. Provides endpoints for sending + * messages, retrieving messages, and marking messages as read. */ @RestController @RequestMapping("/api/messages") @@ -29,9 +29,8 @@ public class MessageApiController { } /** - * Send a general message to a client - * POST /api/messages/send - * Body: { "content": "message text", "receiver": "appUserId", "contentType": "TEXT|IMAGE" } + * Send a general message to a client POST /api/messages/send Body: { "content": + * "message text", "receiver": "appUserId", "contentType": "TEXT|IMAGE" } */ @PostMapping("/send") public ResponseEntity sendGeneralMessage(@RequestBody Map request) { @@ -40,8 +39,7 @@ public class MessageApiController { String receiver = request.get("receiver"); MessageContentType contentType = resolveContentType(request.get("contentType")); - if (content == null || content.isBlank() || - receiver == null || receiver.isBlank()) { + if (content == null || content.isBlank() || receiver == null || receiver.isBlank()) { log.warn("Invalid message request: missing required fields"); return ResponseEntity.badRequest().build(); } @@ -60,10 +58,9 @@ public class MessageApiController { } /** - * Send a job-related message to a client - * POST /api/messages/send-job-message - * Body: { "content": "message text", "receiver": "appUserId", - * "jobId": "job id", "jobNumber": "job number", "contentType": "TEXT|IMAGE" } + * Send a job-related message to a client POST /api/messages/send-job-message + * Body: { "content": "message text", "receiver": "appUserId", "jobId": "job + * id", "jobNumber": "job number", "contentType": "TEXT|IMAGE" } */ @PostMapping("/send-job-message") public ResponseEntity sendJobMessage(@RequestBody Map request) { @@ -74,9 +71,8 @@ public class MessageApiController { String jobNumber = request.get("jobNumber"); MessageContentType contentType = resolveContentType(request.get("contentType")); - if (content == null || content.isBlank() || - receiver == null || receiver.isBlank() || - jobIdStr == null || jobIdStr.isBlank()) { + if (content == null || content.isBlank() || receiver == null || receiver.isBlank() || jobIdStr == null + || jobIdStr.isBlank()) { log.warn("Invalid job message request: missing required fields"); return ResponseEntity.badRequest().build(); } @@ -96,8 +92,8 @@ public class MessageApiController { } /** - * Get all messages for a specific receiver - * GET /api/messages/receiver/{username} + * Get all messages for a specific receiver GET + * /api/messages/receiver/{username} */ @GetMapping("/receiver/{username}") public ResponseEntity> getMessagesForReceiver(@PathVariable String username) { @@ -111,8 +107,8 @@ public class MessageApiController { } /** - * Get all unread messages for a specific receiver - * GET /api/messages/receiver/{username}/unread + * Get all unread messages for a specific receiver GET + * /api/messages/receiver/{username}/unread */ @GetMapping("/receiver/{username}/unread") public ResponseEntity> getUnreadMessagesForReceiver(@PathVariable String username) { @@ -126,8 +122,8 @@ public class MessageApiController { } /** - * Get unread message count for a specific receiver - * GET /api/messages/receiver/{username}/unread-count + * Get unread message count for a specific receiver GET + * /api/messages/receiver/{username}/unread-count */ @GetMapping("/receiver/{username}/unread-count") public ResponseEntity> getUnreadMessageCount(@PathVariable String username) { @@ -141,8 +137,7 @@ public class MessageApiController { } /** - * Get all messages related to a specific job - * GET /api/messages/job/{jobId} + * Get all messages related to a specific job GET /api/messages/job/{jobId} */ @GetMapping("/job/{jobId}") public ResponseEntity> getMessagesForJob(@PathVariable String jobId) { @@ -160,8 +155,7 @@ public class MessageApiController { } /** - * Get all messages (for admin/overview) - * GET /api/messages/all + * Get all messages (for admin/overview) GET /api/messages/all */ @GetMapping("/all") public ResponseEntity> getAllMessages() { @@ -175,8 +169,8 @@ public class MessageApiController { } /** - * Get messages by origin (incoming/outgoing/server) - * GET /api/messages/origin/{origin} + * Get messages by origin (incoming/outgoing/server) GET + * /api/messages/origin/{origin} */ @GetMapping("/origin/{origin}") public ResponseEntity> getMessagesByOrigin(@PathVariable String origin) { @@ -194,8 +188,7 @@ public class MessageApiController { } /** - * Mark a message as read - * PUT /api/messages/{messageId}/mark-read + * Mark a message as read PUT /api/messages/{messageId}/mark-read */ @PutMapping("/{messageId}/mark-read") public ResponseEntity markMessageAsRead(@PathVariable String messageId) { @@ -213,8 +206,7 @@ public class MessageApiController { } /** - * Delete a message - * DELETE /api/messages/{messageId} + * Delete a message DELETE /api/messages/{messageId} */ @DeleteMapping("/{messageId}") public ResponseEntity deleteMessage(@PathVariable String messageId) { diff --git a/src/main/java/de/assecutor/votianlt/controller/MessageController.java b/src/main/java/de/assecutor/votianlt/controller/MessageController.java index d0be1e5..3bf7343 100644 --- a/src/main/java/de/assecutor/votianlt/controller/MessageController.java +++ b/src/main/java/de/assecutor/votianlt/controller/MessageController.java @@ -76,9 +76,9 @@ public class MessageController { public MessageController(MqttPublisher mqttPublisher, AppUserRepository appUserRepository, AppUserService appUserService, JobRepository jobRepository, CargoItemRepository cargoItemRepository, TaskRepository taskRepository, PhotoRepository photoRepository, BarcodeRepository barcodeRepository, - SignatureRepository signatureRepository, CommentRepository commentRepository, JobHistoryService jobHistoryService, - EmailService emailService, MessageService messageService, ObjectMapper objectMapper, - ClientConnectionService clientConnectionService) { + SignatureRepository signatureRepository, CommentRepository commentRepository, + JobHistoryService jobHistoryService, EmailService emailService, MessageService messageService, + ObjectMapper objectMapper, ClientConnectionService clientConnectionService) { this.mqttPublisher = mqttPublisher; this.appUserRepository = appUserRepository; this.appUserService = appUserService; @@ -181,8 +181,8 @@ public class MessageController { List allJobs = jobRepository.findAll(); log.info("DEBUG: Total jobs in database: {}", allJobs.size()); for (Job job : allJobs) { - log.info("DEBUG: Job {} (number: {}) has app_user='{}', digitalProcessing={}", - job.getIdAsString(), job.getJobNumber(), job.getAppUser(), job.isDigitalProcessing()); + log.info("DEBUG: Job {} (number: {}) has app_user='{}', digitalProcessing={}", job.getIdAsString(), + job.getJobNumber(), job.getAppUser(), job.isDigitalProcessing()); } // For each job, fetch related cargo items and tasks (ordered by task order) @@ -304,7 +304,8 @@ public class MessageController { if (extra instanceof Map extraData) { Object barcodesObj = extraData.get("barcodes"); if (barcodesObj instanceof List barcodesList) { - // Suppressing unchecked cast warning as extraData structure is validated from MQTT payload + // Suppressing unchecked cast warning as extraData structure is validated from + // MQTT payload @SuppressWarnings("unchecked") List barcodes = (List) barcodesList; @@ -404,15 +405,15 @@ public class MessageController { if (extra instanceof Map extraData) { Object photosObj = extraData.get("photos"); if (photosObj instanceof List photosList) { - // Suppressing unchecked cast warning as extraData structure is validated from MQTT payload + // Suppressing unchecked cast warning as extraData structure is validated from + // MQTT payload @SuppressWarnings("unchecked") List photos = (List) photosList; if (!photos.isEmpty()) { for (String photoString : photos) { String completedBy = task.getCompletedBy() != null ? task.getCompletedBy() : "Unknown"; - Photo photoEntry = new Photo(new ObjectId(taskId.toString()), photoString, - completedBy); + Photo photoEntry = new Photo(new ObjectId(taskId.toString()), photoString, completedBy); photoRepository.save(photoEntry); } @@ -609,9 +610,8 @@ public class MessageController { } /** - * Handle pong response from a client. - * Client sends to /server/{clientId}/pong with payload { timestamp }. - * Used for connection monitoring. + * Handle pong response from a client. Client sends to /server/{clientId}/pong + * with payload { timestamp }. Used for connection monitoring. */ public void handlePong(Map payload) { String clientId = payload.get("clientId") != null ? payload.get("clientId").toString() : null; @@ -625,14 +625,10 @@ public class MessageController { } /** - * Handle incoming message from a client via MQTT. - * Client sends to /server/{clientId}/message with payload: - * { - * "content": "message payload", - * "contentType": "TEXT|IMAGE", - * "jobId": "optional job id", - * "jobNumber": "optional job number" - * } + * Handle incoming message from a client via MQTT. Client sends to + * /server/{clientId}/message with payload: { "content": "message payload", + * "contentType": "TEXT|IMAGE", "jobId": "optional job id", "jobNumber": + * "optional job number" } * * The clientId is extracted from the MQTT topic and represents the AppUser ID. * This clientId is stored as the receiver field in the message. diff --git a/src/main/java/de/assecutor/votianlt/dto/ChatMessageInboundPayload.java b/src/main/java/de/assecutor/votianlt/dto/ChatMessageInboundPayload.java index 36dee77..e5eb7c5 100644 --- a/src/main/java/de/assecutor/votianlt/dto/ChatMessageInboundPayload.java +++ b/src/main/java/de/assecutor/votianlt/dto/ChatMessageInboundPayload.java @@ -8,8 +8,8 @@ import org.bson.types.ObjectId; * Normalized payload for chat messages sent by mobile clients via MQTT. * receiver = AppUser ID (clientId) extracted from MQTT topic */ -public record ChatMessageInboundPayload(String receiver, String content, - MessageContentType contentType, ObjectId jobId, String jobNumber) { +public record ChatMessageInboundPayload(String receiver, String content, MessageContentType contentType, ObjectId jobId, + String jobNumber) { public ChatMessageInboundPayload { contentType = contentType != null ? contentType : MessageContentType.TEXT; @@ -58,8 +58,7 @@ public record ChatMessageInboundPayload(String receiver, String content, try { return new ObjectId(candidate); } catch (IllegalArgumentException ex) { - throw new IllegalArgumentException( - "Field '%s' must be a valid MongoDB ObjectId".formatted(fieldName), ex); + throw new IllegalArgumentException("Field '%s' must be a valid MongoDB ObjectId".formatted(fieldName), ex); } } diff --git a/src/main/java/de/assecutor/votianlt/dto/ChatMessageOutboundPayload.java b/src/main/java/de/assecutor/votianlt/dto/ChatMessageOutboundPayload.java index e23b85f..854f6fe 100644 --- a/src/main/java/de/assecutor/votianlt/dto/ChatMessageOutboundPayload.java +++ b/src/main/java/de/assecutor/votianlt/dto/ChatMessageOutboundPayload.java @@ -7,32 +7,16 @@ import de.assecutor.votianlt.model.MessageType; import java.time.LocalDateTime; /** - * Outbound chat message payload published to MQTT subscribers. - * The receiver is implicit from the MQTT topic (/client/{appUserId}/message) + * Outbound chat message payload published to MQTT subscribers. The receiver is + * implicit from the MQTT topic (/client/{appUserId}/message) */ -public record ChatMessageOutboundPayload( - String messageId, - String content, - MessageContentType contentType, - MessageOrigin origin, - MessageType messageType, - LocalDateTime createdAt, - String jobId, - String jobNumber, - boolean read -) { +public record ChatMessageOutboundPayload(String messageId, String content, MessageContentType contentType, + MessageOrigin origin, MessageType messageType, LocalDateTime createdAt, String jobId, String jobNumber, + boolean read) { public static ChatMessageOutboundPayload fromMessage(Message message) { - return new ChatMessageOutboundPayload( - message.getIdAsString(), - message.getContent(), - message.getContentType(), - message.getOrigin(), - message.getMessageType(), - message.getCreatedAt(), - message.getJobIdAsString(), - message.getJobNumber(), - message.isRead() - ); + return new ChatMessageOutboundPayload(message.getIdAsString(), message.getContent(), message.getContentType(), + message.getOrigin(), message.getMessageType(), message.getCreatedAt(), message.getJobIdAsString(), + message.getJobNumber(), message.isRead()); } } diff --git a/src/main/java/de/assecutor/votianlt/dto/ClientMessageSummary.java b/src/main/java/de/assecutor/votianlt/dto/ClientMessageSummary.java index 6990185..aefd60f 100644 --- a/src/main/java/de/assecutor/votianlt/dto/ClientMessageSummary.java +++ b/src/main/java/de/assecutor/votianlt/dto/ClientMessageSummary.java @@ -13,7 +13,7 @@ import java.time.LocalDateTime; @NoArgsConstructor @AllArgsConstructor public class ClientMessageSummary { - + private String clientId; private String clientName; private String clientEmail; diff --git a/src/main/java/de/assecutor/votianlt/event/JobCreatedEvent.java b/src/main/java/de/assecutor/votianlt/event/JobCreatedEvent.java new file mode 100644 index 0000000..9585fd5 --- /dev/null +++ b/src/main/java/de/assecutor/votianlt/event/JobCreatedEvent.java @@ -0,0 +1,19 @@ +package de.assecutor.votianlt.event; + +import de.assecutor.votianlt.model.Job; +import lombok.Getter; +import org.springframework.context.ApplicationEvent; + +/** + * Event published when a new job is created + */ +@Getter +public class JobCreatedEvent extends ApplicationEvent { + + private final Job job; + + public JobCreatedEvent(Object source, Job job) { + super(source); + this.job = job; + } +} diff --git a/src/main/java/de/assecutor/votianlt/event/MessageReadStatusChangedEvent.java b/src/main/java/de/assecutor/votianlt/event/MessageReadStatusChangedEvent.java index 760f87d..2bf38d2 100644 --- a/src/main/java/de/assecutor/votianlt/event/MessageReadStatusChangedEvent.java +++ b/src/main/java/de/assecutor/votianlt/event/MessageReadStatusChangedEvent.java @@ -3,13 +3,12 @@ package de.assecutor.votianlt.event; import org.springframework.context.ApplicationEvent; /** - * Event published when message read status changes (e.g., messages marked as read) - * This allows UI components like the sidebar badge to update accordingly + * Event published when message read status changes (e.g., messages marked as + * read) This allows UI components like the sidebar badge to update accordingly */ public class MessageReadStatusChangedEvent extends ApplicationEvent { - + public MessageReadStatusChangedEvent(Object source) { super(source); } } - diff --git a/src/main/java/de/assecutor/votianlt/event/MessageReceivedEvent.java b/src/main/java/de/assecutor/votianlt/event/MessageReceivedEvent.java index 939e8ce..37257cf 100644 --- a/src/main/java/de/assecutor/votianlt/event/MessageReceivedEvent.java +++ b/src/main/java/de/assecutor/votianlt/event/MessageReceivedEvent.java @@ -7,14 +7,14 @@ import org.springframework.context.ApplicationEvent; * Event published when a new message is received from a client */ public class MessageReceivedEvent extends ApplicationEvent { - + private final Message message; - + public MessageReceivedEvent(Object source, Message message) { super(source); this.message = message; } - + public Message getMessage() { return message; } diff --git a/src/main/java/de/assecutor/votianlt/mcp/config/McpServerConfig.java b/src/main/java/de/assecutor/votianlt/mcp/config/McpServerConfig.java index ae7d91e..8da60df 100644 --- a/src/main/java/de/assecutor/votianlt/mcp/config/McpServerConfig.java +++ b/src/main/java/de/assecutor/votianlt/mcp/config/McpServerConfig.java @@ -10,8 +10,8 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; /** - * Configuration for the MCP (Model Context Protocol) server. - * Registers all MCP tools for job statistics and queries. + * Configuration for the MCP (Model Context Protocol) server. Registers all MCP + * tools for job statistics and queries. */ @Configuration @Slf4j diff --git a/src/main/java/de/assecutor/votianlt/mcp/tools/JobQueryTool.java b/src/main/java/de/assecutor/votianlt/mcp/tools/JobQueryTool.java index 75009cd..0d518a2 100644 --- a/src/main/java/de/assecutor/votianlt/mcp/tools/JobQueryTool.java +++ b/src/main/java/de/assecutor/votianlt/mcp/tools/JobQueryTool.java @@ -27,8 +27,7 @@ public class JobQueryTool { @Tool(description = "Query jobs with optional filters. Returns a list of jobs matching the criteria.") public List queryJobs( - @ToolParam(description = "Optional: Job status filter (CREATED, IN_PROGRESS, PICKUP_SCHEDULED, PICKED_UP, IN_TRANSIT, DELIVERED, COMPLETED, CANCELLED)") - String status, + @ToolParam(description = "Optional: Job status filter (CREATED, IN_PROGRESS, PICKUP_SCHEDULED, PICKED_UP, IN_TRANSIT, DELIVERED, COMPLETED, CANCELLED)") String status, @ToolParam(description = "Optional: Customer name filter") String customer, @ToolParam(description = "Optional: Pickup city filter") String pickupCity, @ToolParam(description = "Optional: Delivery city filter") String deliveryCity, @@ -52,10 +51,7 @@ public class JobQueryTool { jobs = statisticsService.getLatestJobs(actualLimit); } - return jobs.stream() - .limit(actualLimit) - .map(this::toQueryResult) - .toList(); + return jobs.stream().limit(actualLimit).map(this::toQueryResult).toList(); } @Tool(description = "Get detailed information about a specific job by its job number") @@ -71,13 +67,10 @@ public class JobQueryTool { } @Tool(description = "Get jobs assigned to a specific mobile app user") - public List getJobsByAppUser( - @ToolParam(description = "App user identifier") String appUser) { + public List getJobsByAppUser(@ToolParam(description = "App user identifier") String appUser) { log.info("MCP Tool: Getting jobs for app user: {}", appUser); - return statisticsService.getJobsByAppUser(appUser).stream() - .map(this::toQueryResult) - .toList(); + return statisticsService.getJobsByAppUser(appUser).stream().map(this::toQueryResult).toList(); } @Tool(description = "Get the most recent jobs, sorted by creation date descending") @@ -86,9 +79,7 @@ public class JobQueryTool { log.info("MCP Tool: Getting latest jobs, limit: {}", limit); int actualLimit = limit != null ? limit : 10; - return statisticsService.getLatestJobs(actualLimit).stream() - .map(this::toQueryResult) - .toList(); + return statisticsService.getLatestJobs(actualLimit).stream().map(this::toQueryResult).toList(); } @Tool(description = "Get jobs created within a specific date range") @@ -102,27 +93,17 @@ public class JobQueryTool { LocalDateTime end = LocalDateTime.parse(endDate); int actualLimit = limit != null ? limit : 100; - return statisticsService.getJobsByDateRange(start, end).stream() - .limit(actualLimit) - .map(this::toQueryResult) + return statisticsService.getJobsByDateRange(start, end).stream().limit(actualLimit).map(this::toQueryResult) .toList(); } private JobQueryResult toQueryResult(Job job) { - return JobQueryResult.builder() - .jobId(job.getIdAsString()) - .jobNumber(job.getJobNumber()) + return JobQueryResult.builder().jobId(job.getIdAsString()).jobNumber(job.getJobNumber()) .status(job.getStatus() != null ? job.getStatus().name() : null) .statusDisplayName(job.getStatus() != null ? job.getStatus().getDisplayName() : null) - .customer(job.getCustomerSelection()) - .pickupCity(job.getPickupCity()) - .deliveryCity(job.getDeliveryCity()) - .pickupDate(job.getPickupDate()) - .deliveryDate(job.getDeliveryDate()) - .price(job.getPrice()) - .createdAt(job.getCreatedAt()) - .assignedAppUser(job.getAppUser()) - .digitalProcessing(job.isDigitalProcessing()) - .build(); + .customer(job.getCustomerSelection()).pickupCity(job.getPickupCity()) + .deliveryCity(job.getDeliveryCity()).pickupDate(job.getPickupDate()).deliveryDate(job.getDeliveryDate()) + .price(job.getPrice()).createdAt(job.getCreatedAt()).assignedAppUser(job.getAppUser()) + .digitalProcessing(job.isDigitalProcessing()).build(); } } diff --git a/src/main/java/de/assecutor/votianlt/mcp/tools/JobStatisticsTool.java b/src/main/java/de/assecutor/votianlt/mcp/tools/JobStatisticsTool.java index 4e80ba8..89323d9 100644 --- a/src/main/java/de/assecutor/votianlt/mcp/tools/JobStatisticsTool.java +++ b/src/main/java/de/assecutor/votianlt/mcp/tools/JobStatisticsTool.java @@ -17,8 +17,8 @@ import java.util.Map; import java.util.stream.Collectors; /** - * MCP Tool for job statistics queries. - * Provides various statistics and aggregations about jobs. + * MCP Tool for job statistics queries. Provides various statistics and + * aggregations about jobs. */ @Component @Slf4j @@ -42,16 +42,10 @@ public class JobStatisticsTool { long cancelled = countsByStatus.getOrDefault(JobStatus.CANCELLED, 0L); long inProgress = countsByStatus.getOrDefault(JobStatus.IN_PROGRESS, 0L); - return JobStatisticsResult.builder() - .countsByStatus(statusCounts) - .totalJobs(statisticsService.getTotalJobCount()) - .completedJobs(completed) - .cancelledJobs(cancelled) - .inProgressJobs(inProgress) - .completionRate(statisticsService.getCompletionRate()) - .totalRevenue(statisticsService.getTotalRevenue()) - .queryTimestamp(LocalDateTime.now()) - .build(); + return JobStatisticsResult.builder().countsByStatus(statusCounts) + .totalJobs(statisticsService.getTotalJobCount()).completedJobs(completed).cancelledJobs(cancelled) + .inProgressJobs(inProgress).completionRate(statisticsService.getCompletionRate()) + .totalRevenue(statisticsService.getTotalRevenue()).queryTimestamp(LocalDateTime.now()).build(); } @Tool(description = "Get job counts grouped by status (CREATED, IN_PROGRESS, PICKUP_SCHEDULED, PICKED_UP, IN_TRANSIT, DELIVERED, COMPLETED, CANCELLED)") @@ -59,10 +53,8 @@ public class JobStatisticsTool { log.info("MCP Tool: Getting job counts by status"); Map counts = statisticsService.getJobCountsByStatus(); - return counts.entrySet().stream() - .collect(Collectors.toMap( - e -> e.getKey().name() + " (" + e.getKey().getDisplayName() + ")", - Map.Entry::getValue)); + return counts.entrySet().stream().collect(Collectors + .toMap(e -> e.getKey().name() + " (" + e.getKey().getDisplayName() + ")", Map.Entry::getValue)); } @Tool(description = "Get the completion rate as a percentage (completed jobs / total jobs * 100)") @@ -79,17 +71,12 @@ public class JobStatisticsTool { log.info("MCP Tool: Getting revenue by customer, limit: {}", limit); int actualLimit = limit != null ? limit : 10; - return statisticsService.getTopCustomersByRevenue(actualLimit).stream() - .map(entry -> { - String customer = entry.getKey(); - long jobCount = statisticsService.getJobsByCustomer(customer).size(); - return CustomerRevenueResult.builder() - .customer(customer) - .revenue(entry.getValue()) - .jobCount(jobCount) - .build(); - }) - .toList(); + return statisticsService.getTopCustomersByRevenue(actualLimit).stream().map(entry -> { + String customer = entry.getKey(); + long jobCount = statisticsService.getJobsByCustomer(customer).size(); + return CustomerRevenueResult.builder().customer(customer).revenue(entry.getValue()).jobCount(jobCount) + .build(); + }).toList(); } @Tool(description = "Get monthly job trend data for a specific year showing job counts per month") @@ -99,9 +86,7 @@ public class JobStatisticsTool { Map monthlyData = statisticsService.getMonthlyJobCounts(year); return monthlyData.entrySet().stream() - .collect(Collectors.toMap( - e -> e.getKey().toString(), - Map.Entry::getValue)); + .collect(Collectors.toMap(e -> e.getKey().toString(), Map.Entry::getValue)); } @Tool(description = "Get total revenue from all jobs") diff --git a/src/main/java/de/assecutor/votianlt/mcp/tools/TaskCompletionTool.java b/src/main/java/de/assecutor/votianlt/mcp/tools/TaskCompletionTool.java index 65cad94..feb6a32 100644 --- a/src/main/java/de/assecutor/votianlt/mcp/tools/TaskCompletionTool.java +++ b/src/main/java/de/assecutor/votianlt/mcp/tools/TaskCompletionTool.java @@ -32,12 +32,8 @@ public class TaskCompletionTool { double completionRate = total > 0 ? (double) completed / total * 100.0 : 0.0; - return TaskCompletionResult.builder() - .totalTasks(total) - .completedTasks(completed) - .pendingTasks(pending) - .completionRate(completionRate) - .build(); + return TaskCompletionResult.builder().totalTasks(total).completedTasks(completed).pendingTasks(pending) + .completionRate(completionRate).build(); } @Tool(description = "Get a summary of task completion as a formatted string") @@ -51,8 +47,7 @@ public class TaskCompletionTool { double completionRate = total > 0 ? (double) completed / total * 100.0 : 0.0; - return String.format( - "Task Statistics: %d total tasks, %d completed (%.1f%%), %d pending", - total, completed, completionRate, pending); + return String.format("Task Statistics: %d total tasks, %d completed (%.1f%%), %d pending", total, completed, + completionRate, pending); } } diff --git a/src/main/java/de/assecutor/votianlt/messaging/config/PluginMessagingConfig.java b/src/main/java/de/assecutor/votianlt/messaging/config/PluginMessagingConfig.java index 8283d72..ec1f069 100644 --- a/src/main/java/de/assecutor/votianlt/messaging/config/PluginMessagingConfig.java +++ b/src/main/java/de/assecutor/votianlt/messaging/config/PluginMessagingConfig.java @@ -20,8 +20,8 @@ import java.nio.charset.StandardCharsets; import java.util.Map; /** - * Configuration for the plugin-based messaging system. - * Initializes the selected plugin and sets up message routing. + * Configuration for the plugin-based messaging system. Initializes the selected + * plugin and sets up message routing. */ @Configuration @Slf4j @@ -54,8 +54,9 @@ public class PluginMessagingConfig { } /** - * Initialize the messaging plugin after application startup. - * This method is called after all beans are created, so we can safely access MessageDeliveryService. + * Initialize the messaging plugin after application startup. This method is + * called after all beans are created, so we can safely access + * MessageDeliveryService. */ @EventListener(ApplicationReadyEvent.class) public void initializePlugin(ApplicationReadyEvent event) { @@ -66,9 +67,11 @@ public class PluginMessagingConfig { PluginConfig config = createPluginConfig(pluginType); // Get beans from context (after all beans are created) - MessageDeliveryService deliveryService = event.getApplicationContext().getBean(MessageDeliveryService.class); + MessageDeliveryService deliveryService = event.getApplicationContext() + .getBean(MessageDeliveryService.class); MessageController messageController = event.getApplicationContext().getBean(MessageController.class); - ClientConnectionService clientConnectionService = event.getApplicationContext().getBean(ClientConnectionService.class); + ClientConnectionService clientConnectionService = event.getApplicationContext() + .getBean(ClientConnectionService.class); // Set up a listener to subscribe when connected log.info("[PluginMessagingConfig] Adding state listener"); @@ -89,10 +92,12 @@ public class PluginMessagingConfig { }); log.info("[PluginMessagingConfig] State listener added"); - // Activate plugin (this will trigger connection and eventually the listener above) + // Activate plugin (this will trigger connection and eventually the listener + // above) pluginManager.activatePlugin(plugin, config); - log.info("[PluginMessagingConfig] Plugin activation initiated, subscriptions will be set up when connected"); + log.info( + "[PluginMessagingConfig] Plugin activation initiated, subscriptions will be set up when connected"); } catch (Exception e) { log.error("[PluginMessagingConfig] Failed to initialize plugin: {}", e.getMessage(), e); @@ -105,11 +110,11 @@ public class PluginMessagingConfig { */ private MessagingPlugin createPlugin(String type) { return switch (type.toLowerCase()) { - case "mqtt" -> new MqttMessagingPlugin(); - // Add more plugin types here in the future - // case "websocket" -> new WebSocketMessagingPlugin(); - // case "grpc" -> new GrpcMessagingPlugin(); - default -> throw new IllegalArgumentException("Unknown plugin type: " + type); + case "mqtt" -> new MqttMessagingPlugin(); + // Add more plugin types here in the future + // case "websocket" -> new WebSocketMessagingPlugin(); + // case "grpc" -> new GrpcMessagingPlugin(); + default -> throw new IllegalArgumentException("Unknown plugin type: " + type); }; } @@ -120,17 +125,17 @@ public class PluginMessagingConfig { PluginConfig config = new PluginConfig(); switch (type.toLowerCase()) { - case "mqtt" -> { - config.setProperty("broker.host", mqttBrokerHost); - config.setProperty("broker.port", mqttBrokerPort); - config.setProperty("username", mqttUsername); - config.setProperty("password", mqttPassword); - config.setProperty("client.id", mqttClientId); - config.setProperty("auto.reconnect", true); - config.setProperty("clean.start", true); - } - // Add more plugin configurations here - default -> throw new IllegalArgumentException("Unknown plugin type: " + type); + case "mqtt" -> { + config.setProperty("broker.host", mqttBrokerHost); + config.setProperty("broker.port", mqttBrokerPort); + config.setProperty("username", mqttUsername); + config.setProperty("password", mqttPassword); + config.setProperty("client.id", mqttClientId); + config.setProperty("auto.reconnect", true); + config.setProperty("clean.start", true); + } + // Add more plugin configurations here + default -> throw new IllegalArgumentException("Unknown plugin type: " + type); } return config; @@ -139,9 +144,8 @@ public class PluginMessagingConfig { /** * Setup message subscriptions using the new plugin API. */ - private void setupSubscriptions(MessageDeliveryService deliveryService, - MessageController messageController, - ClientConnectionService clientConnectionService) { + private void setupSubscriptions(MessageDeliveryService deliveryService, MessageController messageController, + ClientConnectionService clientConnectionService) { log.info("[PluginMessagingConfig] Setting up message subscriptions"); try { @@ -153,7 +157,8 @@ public class PluginMessagingConfig { // ACK messages are wrapped in MessageEnvelope MessageEnvelope envelope = objectMapper.readValue(json, MessageEnvelope.class); - AcknowledgmentMessage ack = objectMapper.convertValue(envelope.getPayload(), AcknowledgmentMessage.class); + AcknowledgmentMessage ack = objectMapper.convertValue(envelope.getPayload(), + AcknowledgmentMessage.class); deliveryService.handleAcknowledgment(ack); } catch (Exception e) { log.error("[PluginMessagingConfig] Error handling ACK message: {}", e.getMessage(), e); @@ -161,17 +166,12 @@ public class PluginMessagingConfig { }); // Register message handlers for different message types - String[] messageTypes = { - "task_completed", - "jobs/assigned", - "message", - "login", - "pong" - }; + String[] messageTypes = { "task_completed", "jobs/assigned", "message", "login", "pong" }; for (String messageType : messageTypes) { - pluginManager.registerMessageHandler(messageType, (clientId, payload) -> - handleEnvelopedMessage(clientId, payload, deliveryService, messageController, clientConnectionService)); + pluginManager.registerMessageHandler(messageType, + (clientId, payload) -> handleEnvelopedMessage(clientId, payload, deliveryService, + messageController, clientConnectionService)); } log.info("[PluginMessagingConfig] Message subscriptions initialized"); @@ -183,11 +183,11 @@ public class PluginMessagingConfig { } /** - * Handle incoming enveloped message. - * Supports both new envelope format and legacy format for backwards compatibility. + * Handle incoming enveloped message. Supports both new envelope format and + * legacy format for backwards compatibility. */ private void handleEnvelopedMessage(String clientId, byte[] payload, MessageDeliveryService deliveryService, - MessageController messageController, ClientConnectionService clientConnectionService) { + MessageController messageController, ClientConnectionService clientConnectionService) { try { String json = new String(payload, StandardCharsets.UTF_8); log.info("[PluginMessagingConfig] Received JSON from client {}: {}", clientId, json); @@ -214,12 +214,12 @@ public class PluginMessagingConfig { } /** - * Handle legacy message format (without envelope wrapper). - * This supports older clients that don't use the envelope format. + * Handle legacy message format (without envelope wrapper). This supports older + * clients that don't use the envelope format. */ @SuppressWarnings("unchecked") - private void handleLegacyMessage(String clientId, String json, - MessageController messageController, ClientConnectionService clientConnectionService) { + private void handleLegacyMessage(String clientId, String json, MessageController messageController, + ClientConnectionService clientConnectionService) { try { Map payload = objectMapper.readValue(json, Map.class); log.info("[PluginMessagingConfig] Processing legacy message from client {}: {}", clientId, payload); @@ -263,8 +263,8 @@ public class PluginMessagingConfig { log.warn("[PluginMessagingConfig] Unknown legacy message format from client {}: {}", clientId, json); } catch (Exception e) { - log.error("[PluginMessagingConfig] Error handling legacy message from client {}: {}", clientId, e.getMessage(), e); + log.error("[PluginMessagingConfig] Error handling legacy message from client {}: {}", clientId, + e.getMessage(), e); } } } - diff --git a/src/main/java/de/assecutor/votianlt/messaging/delivery/AcknowledgmentHandler.java b/src/main/java/de/assecutor/votianlt/messaging/delivery/AcknowledgmentHandler.java index 0a02383..ca04805 100644 --- a/src/main/java/de/assecutor/votianlt/messaging/delivery/AcknowledgmentHandler.java +++ b/src/main/java/de/assecutor/votianlt/messaging/delivery/AcknowledgmentHandler.java @@ -28,8 +28,8 @@ public class AcknowledgmentHandler { } /** - * Route incoming message envelope to appropriate application handler. - * Unwraps the envelope and delegates to MessageController. + * Route incoming message envelope to appropriate application handler. Unwraps + * the envelope and delegates to MessageController. */ public void routeIncomingMessage(MessageEnvelope envelope) { try { @@ -39,8 +39,9 @@ public class AcknowledgmentHandler { log.debug("[AckHandler] Routing message {} on topic {}", envelope.getMessageId(), topic); // Convert payload to Map for routing - Map payloadMap = objectMapper.convertValue(payload, - new TypeReference>() {}); + Map payloadMap = objectMapper.convertValue(payload, + new TypeReference>() { + }); // Route based on topic pattern if (topic.matches("/server/.+/task_completed")) { @@ -58,8 +59,7 @@ public class AcknowledgmentHandler { } } catch (Exception e) { - log.error("[AckHandler] Error routing message {}: {}", - envelope.getMessageId(), e.getMessage(), e); + log.error("[AckHandler] Error routing message {}: {}", envelope.getMessageId(), e.getMessage(), e); } } @@ -96,8 +96,7 @@ public class AcknowledgmentHandler { } /** - * Handle login request - * Topic: /server/login + * Handle login request Topic: /server/login */ private void handleLogin(Map payload) { try { @@ -146,4 +145,3 @@ public class AcknowledgmentHandler { } } } - diff --git a/src/main/java/de/assecutor/votianlt/messaging/delivery/DeliveryConfig.java b/src/main/java/de/assecutor/votianlt/messaging/delivery/DeliveryConfig.java index e8d4b2b..8231a88 100644 --- a/src/main/java/de/assecutor/votianlt/messaging/delivery/DeliveryConfig.java +++ b/src/main/java/de/assecutor/votianlt/messaging/delivery/DeliveryConfig.java @@ -59,4 +59,3 @@ public class DeliveryConfig { */ private int acknowledgedRetentionDays = 7; } - diff --git a/src/main/java/de/assecutor/votianlt/messaging/delivery/MessageDeliveryService.java b/src/main/java/de/assecutor/votianlt/messaging/delivery/MessageDeliveryService.java index 043f45e..c6f400a 100644 --- a/src/main/java/de/assecutor/votianlt/messaging/delivery/MessageDeliveryService.java +++ b/src/main/java/de/assecutor/votianlt/messaging/delivery/MessageDeliveryService.java @@ -6,28 +6,37 @@ import java.util.Optional; import java.util.concurrent.CompletableFuture; /** - * Service for reliable message delivery with acknowledgment tracking. - * Provides guaranteed delivery with retry mechanism and acknowledgment handling. + * Service for reliable message delivery with acknowledgment tracking. Provides + * guaranteed delivery with retry mechanism and acknowledgment handling. */ public interface MessageDeliveryService { /** - * Send a message to a specific client with delivery tracking and acknowledgment. + * Send a message to a specific client with delivery tracking and + * acknowledgment. * - * @param clientId The target client identifier - * @param messageType The type of message (e.g., "jobs", "message", "auth", "task") - * @param payload The message payload (will be serialized to JSON) - * @param options Delivery options (retries, timeout, etc.) + * @param clientId + * The target client identifier + * @param messageType + * The type of message (e.g., "jobs", "message", "auth", "task") + * @param payload + * The message payload (will be serialized to JSON) + * @param options + * Delivery options (retries, timeout, etc.) * @return CompletableFuture with delivery receipt */ - CompletableFuture sendToClient(String clientId, String messageType, Object payload, DeliveryOptions options); + CompletableFuture sendToClient(String clientId, String messageType, Object payload, + DeliveryOptions options); /** * Send a message to a specific client with default delivery options. * - * @param clientId The target client identifier - * @param messageType The type of message - * @param payload The message payload + * @param clientId + * The target client identifier + * @param messageType + * The type of message + * @param payload + * The message payload * @return CompletableFuture with delivery receipt */ default CompletableFuture sendToClient(String clientId, String messageType, Object payload) { @@ -36,11 +45,17 @@ public interface MessageDeliveryService { /** * Send a message with delivery tracking and acknowledgment. - * @deprecated Use {@link #sendToClient(String, String, Object, DeliveryOptions)} instead + * + * @deprecated Use + * {@link #sendToClient(String, String, Object, DeliveryOptions)} + * instead * - * @param topic The destination topic - * @param payload The message payload (will be serialized to JSON) - * @param options Delivery options (retries, timeout, etc.) + * @param topic + * The destination topic + * @param payload + * The message payload (will be serialized to JSON) + * @param options + * Delivery options (retries, timeout, etc.) * @return CompletableFuture with delivery receipt */ @Deprecated @@ -48,10 +63,13 @@ public interface MessageDeliveryService { /** * Send a message with default delivery options. + * * @deprecated Use {@link #sendToClient(String, String, Object)} instead * - * @param topic The destination topic - * @param payload The message payload + * @param topic + * The destination topic + * @param payload + * The message payload * @return CompletableFuture with delivery receipt */ @Deprecated @@ -60,25 +78,28 @@ public interface MessageDeliveryService { } /** - * Handle incoming message envelope from transport layer. - * Extracts payload and routes to application layer. + * Handle incoming message envelope from transport layer. Extracts payload and + * routes to application layer. * - * @param envelope The received message envelope + * @param envelope + * The received message envelope */ void handleIncomingMessage(MessageEnvelope envelope); /** - * Handle acknowledgment from client. - * Updates delivery status and removes from pending queue. + * Handle acknowledgment from client. Updates delivery status and removes from + * pending queue. * - * @param ack The acknowledgment message + * @param ack + * The acknowledgment message */ void handleAcknowledgment(AcknowledgmentMessage ack); /** * Get the current delivery status for a message. * - * @param messageId The message ID + * @param messageId + * The message ID * @return Optional containing the delivery status, or empty if not found */ Optional getDeliveryStatus(String messageId); @@ -86,29 +107,29 @@ public interface MessageDeliveryService { /** * Get detailed pending delivery information. * - * @param messageId The message ID + * @param messageId + * The message ID * @return Optional containing the pending delivery, or empty if not found */ Optional getPendingDelivery(String messageId); /** - * Retry all pending deliveries that are ready for retry. - * Called by scheduled task. + * Retry all pending deliveries that are ready for retry. Called by scheduled + * task. */ void retryPendingDeliveries(); /** - * Retry pending deliveries for a specific client. - * Called when a client reconnects. + * Retry pending deliveries for a specific client. Called when a client + * reconnects. * - * @param clientId The client identifier + * @param clientId + * The client identifier */ void retryPendingDeliveriesForClient(String clientId); /** - * Clean up expired and completed deliveries. - * Called by scheduled task. + * Clean up expired and completed deliveries. Called by scheduled task. */ void cleanupOldDeliveries(); } - diff --git a/src/main/java/de/assecutor/votianlt/messaging/delivery/MessageDeliveryServiceImpl.java b/src/main/java/de/assecutor/votianlt/messaging/delivery/MessageDeliveryServiceImpl.java index e0169d4..4a09a71 100644 --- a/src/main/java/de/assecutor/votianlt/messaging/delivery/MessageDeliveryServiceImpl.java +++ b/src/main/java/de/assecutor/votianlt/messaging/delivery/MessageDeliveryServiceImpl.java @@ -45,12 +45,8 @@ public class MessageDeliveryServiceImpl implements MessageDeliveryService { private ScheduledExecutorService retryScheduler; - public MessageDeliveryServiceImpl( - PluginManager pluginManager, - PendingDeliveryRepository pendingDeliveryRepository, - AcknowledgmentHandler acknowledgmentHandler, - DeliveryConfig config, - ObjectMapper objectMapper, + public MessageDeliveryServiceImpl(PluginManager pluginManager, PendingDeliveryRepository pendingDeliveryRepository, + AcknowledgmentHandler acknowledgmentHandler, DeliveryConfig config, ObjectMapper objectMapper, ClientConnectionService clientConnectionService) { this.pluginManager = pluginManager; this.pendingDeliveryRepository = pendingDeliveryRepository; @@ -67,14 +63,10 @@ public class MessageDeliveryServiceImpl implements MessageDeliveryService { t.setDaemon(true); return t; }); - retryScheduler.scheduleAtFixedRate( - this::retryPendingDeliveries, - ackRetryIntervalSeconds, - ackRetryIntervalSeconds, - TimeUnit.SECONDS - ); - log.info("[MessageDelivery] Started retry scheduler (interval: {}s, max retries: {})", - ackRetryIntervalSeconds, ackMaxRetries); + retryScheduler.scheduleAtFixedRate(this::retryPendingDeliveries, ackRetryIntervalSeconds, + ackRetryIntervalSeconds, TimeUnit.SECONDS); + log.info("[MessageDelivery] Started retry scheduler (interval: {}s, max retries: {})", ackRetryIntervalSeconds, + ackMaxRetries); } @PreDestroy @@ -94,7 +86,8 @@ public class MessageDeliveryServiceImpl implements MessageDeliveryService { } @Override - public CompletableFuture sendToClient(String clientId, String messageType, Object payload, DeliveryOptions options) { + public CompletableFuture sendToClient(String clientId, String messageType, Object payload, + DeliveryOptions options) { try { String destination = clientId + "/" + messageType; final LocalDateTime expiresAt = options.calculateExpiryTime(); @@ -105,19 +98,12 @@ public class MessageDeliveryServiceImpl implements MessageDeliveryService { byte[] envelopeData = json.getBytes(StandardCharsets.UTF_8); if (options.isRequiresAck()) { - PendingDelivery pending = new PendingDelivery( - messageId, - destination, - envelopeData, - options.getMaxRetries(), - expiresAt - ); + PendingDelivery pending = new PendingDelivery(messageId, destination, envelopeData, + options.getMaxRetries(), expiresAt); pendingDeliveryRepository.save(pending); } - SendOptions sendOptions = SendOptions.builder() - .qos(options.getQos()) - .retained(options.isRetained()) + SendOptions sendOptions = SendOptions.builder().qos(options.getQos()).retained(options.isRetained()) .build(); final boolean requiresAck = options.isRequiresAck(); @@ -125,20 +111,18 @@ public class MessageDeliveryServiceImpl implements MessageDeliveryService { log.info("[MessageDelivery] Sending message {} to client {} (type: {})", messageId, clientId, messageType); - return pluginManager.sendToClient(clientId, messageType, envelopeData, sendOptions) - .thenApply(v -> { - if (requiresAck) { - updatePendingDeliveryAfterSend(messageId, ackTimeout); - } - return DeliveryReceipt.submitted(messageId, destination, expiresAt); - }) - .exceptionally(ex -> { - log.error("[MessageDelivery] Failed to send message {}: {}", messageId, ex.getMessage()); - if (requiresAck) { - markPendingDeliveryFailed(messageId, ex.getMessage()); - } - return DeliveryReceipt.failed(messageId, destination); - }); + return pluginManager.sendToClient(clientId, messageType, envelopeData, sendOptions).thenApply(v -> { + if (requiresAck) { + updatePendingDeliveryAfterSend(messageId, ackTimeout); + } + return DeliveryReceipt.submitted(messageId, destination, expiresAt); + }).exceptionally(ex -> { + log.error("[MessageDelivery] Failed to send message {}: {}", messageId, ex.getMessage()); + if (requiresAck) { + markPendingDeliveryFailed(messageId, ex.getMessage()); + } + return DeliveryReceipt.failed(messageId, destination); + }); } catch (Exception e) { log.error("[MessageDelivery] Error creating message for client {}: {}", clientId, e.getMessage()); @@ -162,8 +146,7 @@ public class MessageDeliveryServiceImpl implements MessageDeliveryService { @Override public void handleIncomingMessage(MessageEnvelope envelope) { try { - log.info("[MessageDelivery] Received message {} on topic {}", - envelope.getMessageId(), envelope.getTopic()); + log.info("[MessageDelivery] Received message {} on topic {}", envelope.getMessageId(), envelope.getTopic()); if (envelope.isRequiresAck()) { sendAcknowledgment(envelope); @@ -172,16 +155,15 @@ public class MessageDeliveryServiceImpl implements MessageDeliveryService { acknowledgmentHandler.routeIncomingMessage(envelope); } catch (Exception e) { - log.error("[MessageDelivery] Error handling incoming message {}: {}", - envelope.getMessageId(), e.getMessage()); + log.error("[MessageDelivery] Error handling incoming message {}: {}", envelope.getMessageId(), + e.getMessage()); } } @Override public void handleAcknowledgment(AcknowledgmentMessage ack) { try { - log.info("[MessageDelivery] Received ACK for message {} (status: {})", - ack.getMessageId(), ack.getStatus()); + log.info("[MessageDelivery] Received ACK for message {} (status: {})", ack.getMessageId(), ack.getStatus()); Optional pendingOpt = pendingDeliveryRepository.findByMessageId(ack.getMessageId()); @@ -192,27 +174,25 @@ public class MessageDeliveryServiceImpl implements MessageDeliveryService { PendingDelivery pending = pendingOpt.get(); switch (ack.getStatus()) { - case RECEIVED, PROCESSED -> { - pendingDeliveryRepository.delete(pending); - } - case FAILED -> { - pending.markAsFailed(ack.getErrorMessage()); - pendingDeliveryRepository.save(pending); - log.warn("[MessageDelivery] Message {} failed on client: {}", - ack.getMessageId(), ack.getErrorMessage()); - } + case RECEIVED, PROCESSED -> { + pendingDeliveryRepository.delete(pending); + } + case FAILED -> { + pending.markAsFailed(ack.getErrorMessage()); + pendingDeliveryRepository.save(pending); + log.warn("[MessageDelivery] Message {} failed on client: {}", ack.getMessageId(), + ack.getErrorMessage()); + } } } catch (Exception e) { - log.error("[MessageDelivery] Error handling ACK for message {}: {}", - ack.getMessageId(), e.getMessage()); + log.error("[MessageDelivery] Error handling ACK for message {}: {}", ack.getMessageId(), e.getMessage()); } } @Override public Optional getDeliveryStatus(String messageId) { - return pendingDeliveryRepository.findByMessageId(messageId) - .map(PendingDelivery::getStatus); + return pendingDeliveryRepository.findByMessageId(messageId).map(PendingDelivery::getStatus); } @Override @@ -272,11 +252,8 @@ public class MessageDeliveryServiceImpl implements MessageDeliveryService { pendingDeliveryRepository.deleteAll(oldAcknowledged); } - List expired = pendingDeliveryRepository - .findByStatusInAndExpiresAtBefore( - List.of(DeliveryStatus.PENDING, DeliveryStatus.SENT), - LocalDateTime.now() - ); + List expired = pendingDeliveryRepository.findByStatusInAndExpiresAtBefore( + List.of(DeliveryStatus.PENDING, DeliveryStatus.SENT), LocalDateTime.now()); for (PendingDelivery pending : expired) { pending.markAsExpired(); @@ -352,17 +329,15 @@ public class MessageDeliveryServiceImpl implements MessageDeliveryService { pending.incrementRetryCount(); SendOptions options = SendOptions.reliable(); - pluginManager.sendToClient(clientId, messageType, pending.getEnvelopeData(), options) - .thenAccept(v -> { - pending.markAsSent(nextRetry); - pendingDeliveryRepository.save(pending); - }) - .exceptionally(ex -> { - log.error("[MessageDelivery] Retry failed for message {}: {}", pending.getMessageId(), ex.getMessage()); - pending.markAsFailed(ex.getMessage()); - pendingDeliveryRepository.save(pending); - return null; - }); + pluginManager.sendToClient(clientId, messageType, pending.getEnvelopeData(), options).thenAccept(v -> { + pending.markAsSent(nextRetry); + pendingDeliveryRepository.save(pending); + }).exceptionally(ex -> { + log.error("[MessageDelivery] Retry failed for message {}: {}", pending.getMessageId(), ex.getMessage()); + pending.markAsFailed(ex.getMessage()); + pendingDeliveryRepository.save(pending); + return null; + }); } catch (Exception e) { log.error("[MessageDelivery] Error retrying delivery {}: {}", pending.getMessageId(), e.getMessage()); @@ -376,11 +351,8 @@ public class MessageDeliveryServiceImpl implements MessageDeliveryService { return; } - AcknowledgmentMessage ack = new AcknowledgmentMessage( - envelope.getMessageId(), - AckStatus.RECEIVED, - "server" - ); + AcknowledgmentMessage ack = new AcknowledgmentMessage(envelope.getMessageId(), AckStatus.RECEIVED, + "server"); String ackJson = objectMapper.writeValueAsString(ack); byte[] ackData = ackJson.getBytes(StandardCharsets.UTF_8); @@ -389,12 +361,14 @@ public class MessageDeliveryServiceImpl implements MessageDeliveryService { pluginManager.sendAckToClient(clientId, envelope.getMessageId(), ackData, SendOptions.fireAndForget()) .exceptionally(ex -> { - log.error("[MessageDelivery] Failed to send ACK for message {}: {}", envelope.getMessageId(), ex.getMessage()); + log.error("[MessageDelivery] Failed to send ACK for message {}: {}", envelope.getMessageId(), + ex.getMessage()); return null; }); } catch (Exception e) { - log.error("[MessageDelivery] Error sending ACK for message {}: {}", envelope.getMessageId(), e.getMessage()); + log.error("[MessageDelivery] Error sending ACK for message {}: {}", envelope.getMessageId(), + e.getMessage()); } } diff --git a/src/main/java/de/assecutor/votianlt/messaging/delivery/RetryScheduler.java b/src/main/java/de/assecutor/votianlt/messaging/delivery/RetryScheduler.java index 38992ef..7b22131 100644 --- a/src/main/java/de/assecutor/votianlt/messaging/delivery/RetryScheduler.java +++ b/src/main/java/de/assecutor/votianlt/messaging/delivery/RetryScheduler.java @@ -43,4 +43,3 @@ public class RetryScheduler { } } } - diff --git a/src/main/java/de/assecutor/votianlt/messaging/model/AckStatus.java b/src/main/java/de/assecutor/votianlt/messaging/model/AckStatus.java index 4e56fe2..5d3182e 100644 --- a/src/main/java/de/assecutor/votianlt/messaging/model/AckStatus.java +++ b/src/main/java/de/assecutor/votianlt/messaging/model/AckStatus.java @@ -8,15 +8,14 @@ public enum AckStatus { * Message was received by the client */ RECEIVED, - + /** * Message was successfully processed by the client */ PROCESSED, - + /** * Message processing failed on the client side */ FAILED } - diff --git a/src/main/java/de/assecutor/votianlt/messaging/model/AcknowledgmentMessage.java b/src/main/java/de/assecutor/votianlt/messaging/model/AcknowledgmentMessage.java index 6d3226c..7d2b31b 100644 --- a/src/main/java/de/assecutor/votianlt/messaging/model/AcknowledgmentMessage.java +++ b/src/main/java/de/assecutor/votianlt/messaging/model/AcknowledgmentMessage.java @@ -60,4 +60,3 @@ public class AcknowledgmentMessage { this.errorMessage = errorMessage; } } - diff --git a/src/main/java/de/assecutor/votianlt/messaging/model/DeliveryOptions.java b/src/main/java/de/assecutor/votianlt/messaging/model/DeliveryOptions.java index eb68b8b..51b9ee9 100644 --- a/src/main/java/de/assecutor/votianlt/messaging/model/DeliveryOptions.java +++ b/src/main/java/de/assecutor/votianlt/messaging/model/DeliveryOptions.java @@ -71,22 +71,14 @@ public class DeliveryOptions { * Options for fire-and-forget messages (no acknowledgment required) */ public static DeliveryOptions fireAndForget() { - return DeliveryOptions.builder() - .requiresAck(false) - .maxRetries(0) - .build(); + return DeliveryOptions.builder().requiresAck(false).maxRetries(0).build(); } /** * Options for critical messages with extended retry */ public static DeliveryOptions critical() { - return DeliveryOptions.builder() - .requiresAck(true) - .maxRetries(5) - .ackTimeout(Duration.ofMinutes(2)) - .expiryDuration(Duration.ofDays(7)) - .build(); + return DeliveryOptions.builder().requiresAck(true).maxRetries(5).ackTimeout(Duration.ofMinutes(2)) + .expiryDuration(Duration.ofDays(7)).build(); } } - diff --git a/src/main/java/de/assecutor/votianlt/messaging/model/DeliveryReceipt.java b/src/main/java/de/assecutor/votianlt/messaging/model/DeliveryReceipt.java index d1eb099..8f02b3f 100644 --- a/src/main/java/de/assecutor/votianlt/messaging/model/DeliveryReceipt.java +++ b/src/main/java/de/assecutor/votianlt/messaging/model/DeliveryReceipt.java @@ -43,26 +43,13 @@ public class DeliveryReceipt { * Create a receipt for a successfully submitted message */ public static DeliveryReceipt submitted(String messageId, String topic, LocalDateTime expiresAt) { - return new DeliveryReceipt( - messageId, - topic, - LocalDateTime.now(), - DeliveryStatus.PENDING, - expiresAt - ); + return new DeliveryReceipt(messageId, topic, LocalDateTime.now(), DeliveryStatus.PENDING, expiresAt); } /** * Create a receipt for a failed submission */ public static DeliveryReceipt failed(String messageId, String topic) { - return new DeliveryReceipt( - messageId, - topic, - LocalDateTime.now(), - DeliveryStatus.FAILED, - null - ); + return new DeliveryReceipt(messageId, topic, LocalDateTime.now(), DeliveryStatus.FAILED, null); } } - diff --git a/src/main/java/de/assecutor/votianlt/messaging/model/DeliveryStatus.java b/src/main/java/de/assecutor/votianlt/messaging/model/DeliveryStatus.java index 5b472d6..b923142 100644 --- a/src/main/java/de/assecutor/votianlt/messaging/model/DeliveryStatus.java +++ b/src/main/java/de/assecutor/votianlt/messaging/model/DeliveryStatus.java @@ -8,25 +8,24 @@ public enum DeliveryStatus { * Message is queued but not yet sent */ PENDING, - + /** * Message has been sent to the transport layer */ SENT, - + /** * Client has acknowledged receipt of the message */ ACKNOWLEDGED, - + /** * Delivery failed after all retry attempts */ FAILED, - + /** * Message expired before delivery could be confirmed */ EXPIRED } - diff --git a/src/main/java/de/assecutor/votianlt/messaging/model/MessageEnvelope.java b/src/main/java/de/assecutor/votianlt/messaging/model/MessageEnvelope.java index 3d033a8..9ceecda 100644 --- a/src/main/java/de/assecutor/votianlt/messaging/model/MessageEnvelope.java +++ b/src/main/java/de/assecutor/votianlt/messaging/model/MessageEnvelope.java @@ -11,9 +11,9 @@ import java.util.Map; import java.util.UUID; /** - * Envelope that wraps all messages sent through the messaging system. - * Contains metadata for delivery tracking and acknowledgment. - * This is a DTO class - not persisted to MongoDB. + * Envelope that wraps all messages sent through the messaging system. Contains + * metadata for delivery tracking and acknowledgment. This is a DTO class - not + * persisted to MongoDB. */ @Data @NoArgsConstructor diff --git a/src/main/java/de/assecutor/votianlt/messaging/model/PendingDelivery.java b/src/main/java/de/assecutor/votianlt/messaging/model/PendingDelivery.java index 37b18e6..9853839 100644 --- a/src/main/java/de/assecutor/votianlt/messaging/model/PendingDelivery.java +++ b/src/main/java/de/assecutor/votianlt/messaging/model/PendingDelivery.java @@ -14,8 +14,8 @@ import org.springframework.data.mongodb.core.mapping.Field; import java.time.LocalDateTime; /** - * Represents a message delivery that is pending acknowledgment. - * Stored in MongoDB for retry and tracking purposes. + * Represents a message delivery that is pending acknowledgment. Stored in + * MongoDB for retry and tracking purposes. */ @Data @NoArgsConstructor @@ -112,8 +112,8 @@ public class PendingDelivery { /** * Constructor for new pending delivery */ - public PendingDelivery(String messageId, String topic, byte[] envelopeData, - int maxRetries, LocalDateTime expiresAt) { + public PendingDelivery(String messageId, String topic, byte[] envelopeData, int maxRetries, + LocalDateTime expiresAt) { this.messageId = messageId; this.topic = topic; this.envelopeData = envelopeData; @@ -183,11 +183,8 @@ public class PendingDelivery { * Check if ready for retry */ public boolean isReadyForRetry() { - return status == DeliveryStatus.SENT - && nextRetryAt != null - && LocalDateTime.now().isAfter(nextRetryAt) - && !hasReachedMaxRetries() - && !isExpired(); + return status == DeliveryStatus.SENT && nextRetryAt != null && LocalDateTime.now().isAfter(nextRetryAt) + && !hasReachedMaxRetries() && !isExpired(); } /** @@ -211,4 +208,3 @@ public class PendingDelivery { return id != null ? id.toString() : null; } } - diff --git a/src/main/java/de/assecutor/votianlt/messaging/plugin/ConnectionStateEvent.java b/src/main/java/de/assecutor/votianlt/messaging/plugin/ConnectionStateEvent.java index 17bf3c0..8263100 100644 --- a/src/main/java/de/assecutor/votianlt/messaging/plugin/ConnectionStateEvent.java +++ b/src/main/java/de/assecutor/votianlt/messaging/plugin/ConnectionStateEvent.java @@ -105,4 +105,3 @@ public class ConnectionStateEvent { return state == ConnectionState.ERROR; } } - diff --git a/src/main/java/de/assecutor/votianlt/messaging/plugin/MessagingPlugin.java b/src/main/java/de/assecutor/votianlt/messaging/plugin/MessagingPlugin.java index c2403e9..0c422cb 100644 --- a/src/main/java/de/assecutor/votianlt/messaging/plugin/MessagingPlugin.java +++ b/src/main/java/de/assecutor/votianlt/messaging/plugin/MessagingPlugin.java @@ -3,9 +3,9 @@ package de.assecutor.votianlt.messaging.plugin; import java.util.concurrent.CompletableFuture; /** - * Interface for messaging transport plugins. - * Plugins implement specific transport protocols (MQTT, WebSocket, gRPC, etc.) - * and provide a unified interface for the messaging layer. + * Interface for messaging transport plugins. Plugins implement specific + * transport protocols (MQTT, WebSocket, gRPC, etc.) and provide a unified + * interface for the messaging layer. * * The plugin is responsible for managing the internal topic/channel structure. * The messaging layer only uses clientId and messageType as identifiers. @@ -13,73 +13,95 @@ import java.util.concurrent.CompletableFuture; public interface MessagingPlugin { /** - * Initialize the plugin with configuration. - * Called once during application startup. + * Initialize the plugin with configuration. Called once during application + * startup. * - * @param config Plugin-specific configuration - * @throws PluginException if initialization fails + * @param config + * Plugin-specific configuration + * @throws PluginException + * if initialization fails */ void init(PluginConfig config) throws PluginException; /** - * Shutdown the plugin and release resources. - * Called during application shutdown. + * Shutdown the plugin and release resources. Called during application + * shutdown. * - * @throws PluginException if shutdown fails + * @throws PluginException + * if shutdown fails */ void exit() throws PluginException; /** - * Callback when connection state changes. - * The plugin should call this method when the underlying transport - * connection state changes (connected, disconnected, error). + * Callback when connection state changes. The plugin should call this method + * when the underlying transport connection state changes (connected, + * disconnected, error). * - * @param listener Connection state listener + * @param listener + * Connection state listener */ void setConnectionListener(ConnectionStateListener listener); /** - * Send a message to a specific client. - * The plugin is responsible for determining the correct topic/channel based on the messageType. + * Send a message to a specific client. The plugin is responsible for + * determining the correct topic/channel based on the messageType. * - * @param clientId Target client identifier - * @param messageType Type of message (e.g., "jobs", "message", "auth", "task") - * @param payload Message payload as byte array - * @param options Transport-specific options + * @param clientId + * Target client identifier + * @param messageType + * Type of message (e.g., "jobs", "message", "auth", "task") + * @param payload + * Message payload as byte array + * @param options + * Transport-specific options * @return CompletableFuture that completes when message is sent - * @throws PluginException if sending fails + * @throws PluginException + * if sending fails */ - CompletableFuture sendToClient(String clientId, String messageType, byte[] payload, SendOptions options) throws PluginException; + CompletableFuture sendToClient(String clientId, String messageType, byte[] payload, SendOptions options) + throws PluginException; /** - * Send an acknowledgment to a specific client. - * The plugin is responsible for determining the correct ACK topic/channel. + * Send an acknowledgment to a specific client. The plugin is responsible for + * determining the correct ACK topic/channel. * - * @param clientId Target client identifier - * @param messageId Message ID being acknowledged - * @param payload ACK payload as byte array - * @param options Transport-specific options + * @param clientId + * Target client identifier + * @param messageId + * Message ID being acknowledged + * @param payload + * ACK payload as byte array + * @param options + * Transport-specific options * @return CompletableFuture that completes when ACK is sent - * @throws PluginException if sending fails + * @throws PluginException + * if sending fails */ - CompletableFuture sendAckToClient(String clientId, String messageId, byte[] payload, SendOptions options) throws PluginException; + CompletableFuture sendAckToClient(String clientId, String messageId, byte[] payload, SendOptions options) + throws PluginException; /** - * Register a handler for incoming messages of a specific type from clients. - * The plugin is responsible for subscribing to the appropriate topics/channels. + * Register a handler for incoming messages of a specific type from clients. The + * plugin is responsible for subscribing to the appropriate topics/channels. * - * @param messageType Type of message to handle (e.g., "task_completed", "message", "jobs/assigned", "login") - * @param handler Message handler to be called when a message is received - * @throws PluginException if registration fails + * @param messageType + * Type of message to handle (e.g., "task_completed", "message", + * "jobs/assigned", "login") + * @param handler + * Message handler to be called when a message is received + * @throws PluginException + * if registration fails */ void registerMessageHandler(String messageType, ClientMessageHandler handler) throws PluginException; /** - * Register a handler for incoming acknowledgments from clients. - * The plugin is responsible for subscribing to the appropriate ACK topics/channels. + * Register a handler for incoming acknowledgments from clients. The plugin is + * responsible for subscribing to the appropriate ACK topics/channels. * - * @param handler ACK handler to be called when an ACK is received - * @throws PluginException if registration fails + * @param handler + * ACK handler to be called when an ACK is received + * @throws PluginException + * if registration fails */ void registerAckHandler(AckHandler handler) throws PluginException; @@ -119,22 +141,25 @@ public interface MessagingPlugin { /** * Called when connection state changes. * - * @param event Connection state event + * @param event + * Connection state event */ void onConnectionStateChanged(ConnectionStateEvent event); } /** - * Handler for received messages from clients. - * Includes the clientId extracted from the topic/channel. + * Handler for received messages from clients. Includes the clientId extracted + * from the topic/channel. */ @FunctionalInterface interface ClientMessageHandler { /** * Called when a message is received from a client. * - * @param clientId Client identifier extracted from the topic/channel - * @param payload Message payload as byte array + * @param clientId + * Client identifier extracted from the topic/channel + * @param payload + * Message payload as byte array */ void onMessageReceived(String clientId, byte[] payload); } @@ -147,10 +172,11 @@ public interface MessagingPlugin { /** * Called when an ACK is received from a client. * - * @param messageId Message ID being acknowledged - * @param payload ACK payload as byte array + * @param messageId + * Message ID being acknowledged + * @param payload + * ACK payload as byte array */ void onAckReceived(String messageId, byte[] payload); } } - diff --git a/src/main/java/de/assecutor/votianlt/messaging/plugin/PluginConfig.java b/src/main/java/de/assecutor/votianlt/messaging/plugin/PluginConfig.java index cbdef50..9f7dcc1 100644 --- a/src/main/java/de/assecutor/votianlt/messaging/plugin/PluginConfig.java +++ b/src/main/java/de/assecutor/votianlt/messaging/plugin/PluginConfig.java @@ -9,8 +9,8 @@ import java.util.HashMap; import java.util.Map; /** - * Configuration for messaging plugins. - * Provides a flexible key-value store for plugin-specific settings. + * Configuration for messaging plugins. Provides a flexible key-value store for + * plugin-specific settings. */ @Data @Builder @@ -27,7 +27,8 @@ public class PluginConfig { /** * Get a string property. * - * @param key Property key + * @param key + * Property key * @return Property value or null if not found */ public String getString(String key) { @@ -38,8 +39,10 @@ public class PluginConfig { /** * Get a string property with default value. * - * @param key Property key - * @param defaultValue Default value if property not found + * @param key + * Property key + * @param defaultValue + * Default value if property not found * @return Property value or default */ public String getString(String key, String defaultValue) { @@ -50,7 +53,8 @@ public class PluginConfig { /** * Get an integer property. * - * @param key Property key + * @param key + * Property key * @return Property value or null if not found */ public Integer getInt(String key) { @@ -70,8 +74,10 @@ public class PluginConfig { /** * Get an integer property with default value. * - * @param key Property key - * @param defaultValue Default value if property not found + * @param key + * Property key + * @param defaultValue + * Default value if property not found * @return Property value or default */ public int getInt(String key, int defaultValue) { @@ -82,7 +88,8 @@ public class PluginConfig { /** * Get a boolean property. * - * @param key Property key + * @param key + * Property key * @return Property value or null if not found */ public Boolean getBoolean(String key) { @@ -98,8 +105,10 @@ public class PluginConfig { /** * Get a boolean property with default value. * - * @param key Property key - * @param defaultValue Default value if property not found + * @param key + * Property key + * @param defaultValue + * Default value if property not found * @return Property value or default */ public boolean getBoolean(String key, boolean defaultValue) { @@ -110,8 +119,10 @@ public class PluginConfig { /** * Set a property. * - * @param key Property key - * @param value Property value + * @param key + * Property key + * @param value + * Property value */ public void setProperty(String key, Object value) { properties.put(key, value); @@ -120,11 +131,11 @@ public class PluginConfig { /** * Check if a property exists. * - * @param key Property key + * @param key + * Property key * @return true if property exists */ public boolean hasProperty(String key) { return properties.containsKey(key); } } - diff --git a/src/main/java/de/assecutor/votianlt/messaging/plugin/PluginException.java b/src/main/java/de/assecutor/votianlt/messaging/plugin/PluginException.java index 3f19275..1362bbe 100644 --- a/src/main/java/de/assecutor/votianlt/messaging/plugin/PluginException.java +++ b/src/main/java/de/assecutor/votianlt/messaging/plugin/PluginException.java @@ -17,4 +17,3 @@ public class PluginException extends Exception { super(cause); } } - diff --git a/src/main/java/de/assecutor/votianlt/messaging/plugin/PluginManager.java b/src/main/java/de/assecutor/votianlt/messaging/plugin/PluginManager.java index 7a5e272..76a1b2d 100644 --- a/src/main/java/de/assecutor/votianlt/messaging/plugin/PluginManager.java +++ b/src/main/java/de/assecutor/votianlt/messaging/plugin/PluginManager.java @@ -10,8 +10,8 @@ import java.util.Optional; import java.util.concurrent.CompletableFuture; /** - * Manager for messaging plugins. - * Handles plugin lifecycle, registration, and delegation. + * Manager for messaging plugins. Handles plugin lifecycle, registration, and + * delegation. */ @Component @Slf4j @@ -24,9 +24,12 @@ public class PluginManager { /** * Initialize and activate a plugin. * - * @param plugin Plugin to activate - * @param config Plugin configuration - * @throws PluginException if initialization fails + * @param plugin + * Plugin to activate + * @param config + * Plugin configuration + * @throws PluginException + * if initialization fails */ public void activatePlugin(MessagingPlugin plugin, PluginConfig config) throws PluginException { log.info("[PluginManager] Activating plugin: {}", plugin.getPluginName()); @@ -43,11 +46,8 @@ public class PluginManager { // Set connection listener plugin.setConnectionListener(event -> { - String previousState = event.getPreviousState() != null - ? event.getPreviousState().toString() - : "NONE"; - log.info("[PluginManager] Connection state changed: {} -> {}", - previousState, event.getState()); + String previousState = event.getPreviousState() != null ? event.getPreviousState().toString() : "NONE"; + log.info("[PluginManager] Connection state changed: {} -> {}", previousState, event.getState()); connectionHistory.add(event); notifyStateListeners(event); }); @@ -56,8 +56,7 @@ public class PluginManager { plugin.init(config); activePlugin = plugin; - log.info("[PluginManager] Plugin activated: {} v{}", - plugin.getPluginName(), plugin.getPluginVersion()); + log.info("[PluginManager] Plugin activated: {} v{}", plugin.getPluginName(), plugin.getPluginVersion()); } /** @@ -72,14 +71,20 @@ public class PluginManager { /** * Send a message to a specific client via the active plugin. * - * @param clientId Target client identifier - * @param messageType Type of message (e.g., "jobs", "message", "auth", "task") - * @param payload Message payload - * @param options Send options + * @param clientId + * Target client identifier + * @param messageType + * Type of message (e.g., "jobs", "message", "auth", "task") + * @param payload + * Message payload + * @param options + * Send options * @return CompletableFuture that completes when message is sent - * @throws PluginException if no plugin is active or sending fails + * @throws PluginException + * if no plugin is active or sending fails */ - public CompletableFuture sendToClient(String clientId, String messageType, byte[] payload, SendOptions options) throws PluginException { + public CompletableFuture sendToClient(String clientId, String messageType, byte[] payload, + SendOptions options) throws PluginException { if (activePlugin == null) { return CompletableFuture.failedFuture(new PluginException("No active plugin")); } @@ -94,14 +99,20 @@ public class PluginManager { /** * Send an acknowledgment to a specific client via the active plugin. * - * @param clientId Target client identifier - * @param messageId Message ID being acknowledged - * @param payload ACK payload - * @param options Send options + * @param clientId + * Target client identifier + * @param messageId + * Message ID being acknowledged + * @param payload + * ACK payload + * @param options + * Send options * @return CompletableFuture that completes when ACK is sent - * @throws PluginException if no plugin is active or sending fails + * @throws PluginException + * if no plugin is active or sending fails */ - public CompletableFuture sendAckToClient(String clientId, String messageId, byte[] payload, SendOptions options) throws PluginException { + public CompletableFuture sendAckToClient(String clientId, String messageId, byte[] payload, + SendOptions options) throws PluginException { if (activePlugin == null) { return CompletableFuture.failedFuture(new PluginException("No active plugin")); } @@ -116,11 +127,15 @@ public class PluginManager { /** * Register a handler for incoming messages of a specific type from clients. * - * @param messageType Type of message to handle - * @param handler Message handler - * @throws PluginException if no plugin is active or registration fails + * @param messageType + * Type of message to handle + * @param handler + * Message handler + * @throws PluginException + * if no plugin is active or registration fails */ - public void registerMessageHandler(String messageType, MessagingPlugin.ClientMessageHandler handler) throws PluginException { + public void registerMessageHandler(String messageType, MessagingPlugin.ClientMessageHandler handler) + throws PluginException { if (activePlugin == null) { throw new PluginException("No active plugin"); } @@ -131,8 +146,10 @@ public class PluginManager { /** * Register a handler for incoming acknowledgments from clients. * - * @param handler ACK handler - * @throws PluginException if no plugin is active or registration fails + * @param handler + * ACK handler + * @throws PluginException + * if no plugin is active or registration fails */ public void registerAckHandler(MessagingPlugin.AckHandler handler) throws PluginException { if (activePlugin == null) { @@ -184,7 +201,8 @@ public class PluginManager { /** * Add a plugin state listener. * - * @param listener State listener + * @param listener + * State listener */ public void addStateListener(PluginStateListener listener) { stateListeners.add(listener); @@ -193,7 +211,8 @@ public class PluginManager { /** * Remove a plugin state listener. * - * @param listener State listener + * @param listener + * State listener */ public void removeStateListener(PluginStateListener listener) { stateListeners.remove(listener); @@ -202,7 +221,8 @@ public class PluginManager { /** * Notify all state listeners of a connection state change. * - * @param event Connection state event + * @param event + * Connection state event */ private void notifyStateListeners(ConnectionStateEvent event) { for (PluginStateListener listener : stateListeners) { @@ -243,9 +263,9 @@ public class PluginManager { /** * Called when plugin connection state changes. * - * @param event Connection state event + * @param event + * Connection state event */ void onConnectionStateChanged(ConnectionStateEvent event); } } - diff --git a/src/main/java/de/assecutor/votianlt/messaging/plugin/PluginMetadata.java b/src/main/java/de/assecutor/votianlt/messaging/plugin/PluginMetadata.java index b09982b..54a72cc 100644 --- a/src/main/java/de/assecutor/votianlt/messaging/plugin/PluginMetadata.java +++ b/src/main/java/de/assecutor/votianlt/messaging/plugin/PluginMetadata.java @@ -70,7 +70,8 @@ public class PluginMetadata { /** * Check if a feature is supported. * - * @param feature Feature name + * @param feature + * Feature name * @return true if supported */ public boolean supportsFeature(String feature) { @@ -80,7 +81,8 @@ public class PluginMetadata { /** * Add a supported feature. * - * @param feature Feature name + * @param feature + * Feature name */ public void addSupportedFeature(String feature) { if (!supportedFeatures.contains(feature)) { @@ -88,4 +90,3 @@ public class PluginMetadata { } } } - diff --git a/src/main/java/de/assecutor/votianlt/messaging/plugin/ReceivedMessage.java b/src/main/java/de/assecutor/votianlt/messaging/plugin/ReceivedMessage.java index 8035714..a44eba1 100644 --- a/src/main/java/de/assecutor/votianlt/messaging/plugin/ReceivedMessage.java +++ b/src/main/java/de/assecutor/votianlt/messaging/plugin/ReceivedMessage.java @@ -53,7 +53,8 @@ public class ReceivedMessage { /** * Get metadata value. * - * @param key Metadata key + * @param key + * Metadata key * @return Metadata value or null */ public Object getMetadata(String key) { @@ -63,8 +64,10 @@ public class ReceivedMessage { /** * Set metadata value. * - * @param key Metadata key - * @param value Metadata value + * @param key + * Metadata key + * @param value + * Metadata value */ public void setMetadata(String key, Object value) { metadata.put(key, value); @@ -79,4 +82,3 @@ public class ReceivedMessage { return payload != null ? new String(payload, java.nio.charset.StandardCharsets.UTF_8) : null; } } - diff --git a/src/main/java/de/assecutor/votianlt/messaging/plugin/SendOptions.java b/src/main/java/de/assecutor/votianlt/messaging/plugin/SendOptions.java index caa6c91..8bb064c 100644 --- a/src/main/java/de/assecutor/votianlt/messaging/plugin/SendOptions.java +++ b/src/main/java/de/assecutor/votianlt/messaging/plugin/SendOptions.java @@ -9,8 +9,8 @@ import java.util.HashMap; import java.util.Map; /** - * Options for sending messages via plugins. - * Provides transport-agnostic options with extensibility for plugin-specific settings. + * Options for sending messages via plugins. Provides transport-agnostic options + * with extensibility for plugin-specific settings. */ @Data @Builder @@ -50,7 +50,8 @@ public class SendOptions { /** * Get an additional option. * - * @param key Option key + * @param key + * Option key * @return Option value or null */ public Object getAdditionalOption(String key) { @@ -60,8 +61,10 @@ public class SendOptions { /** * Set an additional option. * - * @param key Option key - * @param value Option value + * @param key + * Option key + * @param value + * Option value */ public void setAdditionalOption(String key, Object value) { additionalOptions.put(key, value); @@ -82,10 +85,7 @@ public class SendOptions { * @return Fire-and-forget options */ public static SendOptions fireAndForget() { - return SendOptions.builder() - .qos(0) - .retained(false) - .build(); + return SendOptions.builder().qos(0).retained(false).build(); } /** @@ -94,10 +94,6 @@ public class SendOptions { * @return Reliable delivery options */ public static SendOptions reliable() { - return SendOptions.builder() - .qos(2) - .retained(false) - .build(); + return SendOptions.builder().qos(2).retained(false).build(); } } - diff --git a/src/main/java/de/assecutor/votianlt/messaging/plugin/mqtt/MqttMessagingPlugin.java b/src/main/java/de/assecutor/votianlt/messaging/plugin/mqtt/MqttMessagingPlugin.java index 7000d28..beed775 100644 --- a/src/main/java/de/assecutor/votianlt/messaging/plugin/mqtt/MqttMessagingPlugin.java +++ b/src/main/java/de/assecutor/votianlt/messaging/plugin/mqtt/MqttMessagingPlugin.java @@ -15,14 +15,14 @@ import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; /** - * MQTT implementation of the MessagingPlugin interface. - * Uses HiveMQ MQTT 5 client for communication. + * MQTT implementation of the MessagingPlugin interface. Uses HiveMQ MQTT 5 + * client for communication. * - * Topic Structure (managed internally): - * - Server -> Client: /client/{clientId}/{messageType} - * - Client -> Server: /server/{clientId}/{messageType} - * - ACK Server -> Client: /client/{clientId}/ack (messageId in payload) - * - ACK Client -> Server: /server/{clientId}/ack (messageId in payload) + * Topic Structure (managed internally): - Server -> Client: + * /client/{clientId}/{messageType} - Client -> Server: + * /server/{clientId}/{messageType} - ACK Server -> Client: + * /client/{clientId}/ack (messageId in payload) - ACK Client -> Server: + * /server/{clientId}/ack (messageId in payload) */ @Slf4j public class MqttMessagingPlugin implements MessagingPlugin { @@ -31,12 +31,12 @@ public class MqttMessagingPlugin implements MessagingPlugin { private static final String PLUGIN_VERSION = "2.0.0"; // Topic templates - private static final String TOPIC_TO_CLIENT = "/client/%s/%s"; // /client/{clientId}/{messageType} - private static final String TOPIC_ACK_TO_CLIENT = "/client/%s/ack"; // /client/{clientId}/ack (messageId in payload) + private static final String TOPIC_TO_CLIENT = "/client/%s/%s"; // /client/{clientId}/{messageType} + private static final String TOPIC_ACK_TO_CLIENT = "/client/%s/ack"; // /client/{clientId}/ack (messageId in payload) // Subscription patterns - private static final String PATTERN_FROM_CLIENT = "/server/+/%s"; // /server/+/{messageType} - private static final String PATTERN_ACK_FROM_CLIENT = "/server/+/ack"; // /server/+/ack + private static final String PATTERN_FROM_CLIENT = "/server/+/%s"; // /server/+/{messageType} + private static final String PATTERN_ACK_FROM_CLIENT = "/server/+/ack"; // /server/+/ack private Mqtt5AsyncClient mqttClient; private ConnectionStateListener connectionListener; @@ -71,31 +71,22 @@ public class MqttMessagingPlugin implements MessagingPlugin { int connectionTimeout = config.getInt(CONFIG_CONNECTION_TIMEOUT, 60); int keepAlive = config.getInt(CONFIG_KEEP_ALIVE, 60); - log.info("[MqttPlugin] Connecting to {}:{} with clientId: {} (timeout: {}s, keepAlive: {}s)", - brokerHost, brokerPort, clientId, connectionTimeout, keepAlive); + log.info("[MqttPlugin] Connecting to {}:{} with clientId: {} (timeout: {}s, keepAlive: {}s)", brokerHost, + brokerPort, clientId, connectionTimeout, keepAlive); // Build MQTT client - var clientBuilder = MqttClient.builder() - .useMqttVersion5() - .identifier(clientId) - .serverHost(brokerHost) - .serverPort(brokerPort) - .automaticReconnect() - .initialDelay(1, java.util.concurrent.TimeUnit.SECONDS) - .maxDelay(30, java.util.concurrent.TimeUnit.SECONDS) - .applyAutomaticReconnect(); + var clientBuilder = MqttClient.builder().useMqttVersion5().identifier(clientId).serverHost(brokerHost) + .serverPort(brokerPort).automaticReconnect().initialDelay(1, java.util.concurrent.TimeUnit.SECONDS) + .maxDelay(30, java.util.concurrent.TimeUnit.SECONDS).applyAutomaticReconnect(); mqttClient = clientBuilder.buildAsync(); // Build connect options var connectBuilder = com.hivemq.client.mqtt.mqtt5.message.connect.Mqtt5Connect.builder() - .cleanStart(cleanStart) - .keepAlive(keepAlive); + .cleanStart(cleanStart).keepAlive(keepAlive); if (username != null && password != null) { - connectBuilder.simpleAuth() - .username(username) - .password(password.getBytes(StandardCharsets.UTF_8)) + connectBuilder.simpleAuth().username(username).password(password.getBytes(StandardCharsets.UTF_8)) .applySimpleAuth(); } @@ -108,17 +99,19 @@ public class MqttMessagingPlugin implements MessagingPlugin { .orTimeout(connectionTimeout, java.util.concurrent.TimeUnit.SECONDS) .whenComplete((connAck, throwable) -> { if (throwable != null) { - String errorMsg = String.format("Connection to %s:%d failed: %s", - brokerHost, brokerPort, throwable.getMessage()); + String errorMsg = String.format("Connection to %s:%d failed: %s", brokerHost, brokerPort, + throwable.getMessage()); log.error("[MqttPlugin] {}", errorMsg, throwable); // Check for specific error types if (throwable instanceof java.util.concurrent.TimeoutException) { - log.error("[MqttPlugin] Connection timeout - broker may be unreachable or firewall blocking connection"); + log.error( + "[MqttPlugin] Connection timeout - broker may be unreachable or firewall blocking connection"); } else if (throwable.getCause() instanceof java.net.UnknownHostException) { log.error("[MqttPlugin] Unknown host - DNS resolution failed for {}", brokerHost); } else if (throwable.getCause() instanceof java.net.ConnectException) { - log.error("[MqttPlugin] Connection refused - broker may be down or port {} is blocked", brokerPort); + log.error("[MqttPlugin] Connection refused - broker may be down or port {} is blocked", + brokerPort); } connected = false; @@ -185,7 +178,8 @@ public class MqttMessagingPlugin implements MessagingPlugin { } @Override - public CompletableFuture sendToClient(String clientId, String messageType, byte[] payload, SendOptions options) throws PluginException { + public CompletableFuture sendToClient(String clientId, String messageType, byte[] payload, + SendOptions options) throws PluginException { if (!connected) { return CompletableFuture.failedFuture(new PluginException("MQTT client is not connected")); } @@ -197,7 +191,8 @@ public class MqttMessagingPlugin implements MessagingPlugin { } @Override - public CompletableFuture sendAckToClient(String clientId, String messageId, byte[] payload, SendOptions options) throws PluginException { + public CompletableFuture sendAckToClient(String clientId, String messageId, byte[] payload, + SendOptions options) throws PluginException { if (!connected) { return CompletableFuture.failedFuture(new PluginException("MQTT client is not connected")); } @@ -221,10 +216,7 @@ public class MqttMessagingPlugin implements MessagingPlugin { String loginTopic = "/server/login"; log.info("[MqttPlugin] Registering handler for message type '{}' with topic: {}", messageType, loginTopic); - mqttClient.subscribeWith() - .topicFilter(loginTopic) - .qos(MqttQos.EXACTLY_ONCE) - .send() + mqttClient.subscribeWith().topicFilter(loginTopic).qos(MqttQos.EXACTLY_ONCE).send() .whenComplete((subAck, throwable) -> { if (throwable != null) { log.error("[MqttPlugin] Subscription to {} failed: {}", loginTopic, throwable.getMessage()); @@ -236,15 +228,14 @@ public class MqttMessagingPlugin implements MessagingPlugin { } else { // Standard pattern: /server/+/{messageType} String topicPattern = String.format(PATTERN_FROM_CLIENT, messageType); - log.info("[MqttPlugin] Registering handler for message type '{}' with pattern: {}", messageType, topicPattern); + log.info("[MqttPlugin] Registering handler for message type '{}' with pattern: {}", messageType, + topicPattern); - mqttClient.subscribeWith() - .topicFilter(topicPattern) - .qos(MqttQos.EXACTLY_ONCE) - .send() + mqttClient.subscribeWith().topicFilter(topicPattern).qos(MqttQos.EXACTLY_ONCE).send() .whenComplete((subAck, throwable) -> { if (throwable != null) { - log.error("[MqttPlugin] Subscription to {} failed: {}", topicPattern, throwable.getMessage()); + log.error("[MqttPlugin] Subscription to {} failed: {}", topicPattern, + throwable.getMessage()); messageHandlers.remove(messageType); } else { log.info("[MqttPlugin] Successfully subscribed to: {}", topicPattern); @@ -264,13 +255,11 @@ public class MqttMessagingPlugin implements MessagingPlugin { this.ackHandler = handler; // Subscribe to ACK topic pattern - mqttClient.subscribeWith() - .topicFilter(PATTERN_ACK_FROM_CLIENT) - .qos(MqttQos.EXACTLY_ONCE) - .send() + mqttClient.subscribeWith().topicFilter(PATTERN_ACK_FROM_CLIENT).qos(MqttQos.EXACTLY_ONCE).send() .whenComplete((subAck, throwable) -> { if (throwable != null) { - log.error("[MqttPlugin] Subscription to {} failed: {}", PATTERN_ACK_FROM_CLIENT, throwable.getMessage()); + log.error("[MqttPlugin] Subscription to {} failed: {}", PATTERN_ACK_FROM_CLIENT, + throwable.getMessage()); this.ackHandler = null; } else { log.info("[MqttPlugin] Successfully subscribed to: {}", PATTERN_ACK_FROM_CLIENT); @@ -295,19 +284,14 @@ public class MqttMessagingPlugin implements MessagingPlugin { @Override public PluginMetadata getMetadata() { - return PluginMetadata.builder() - .name(PLUGIN_NAME) - .version(PLUGIN_VERSION) - .description("MQTT v5 messaging plugin using HiveMQ client") - .supportsWildcards(true) - .supportsRetainedMessages(true) - .supportsQos(true) - .maxQosLevel(2) - .build(); + return PluginMetadata.builder().name(PLUGIN_NAME).version(PLUGIN_VERSION) + .description("MQTT v5 messaging plugin using HiveMQ client").supportsWildcards(true) + .supportsRetainedMessages(true).supportsQos(true).maxQosLevel(2).build(); } /** - * Setup global message handler to route incoming messages to registered handlers. + * Setup global message handler to route incoming messages to registered + * handlers. */ private void setupGlobalMessageHandler() { mqttClient.publishes(com.hivemq.client.mqtt.MqttGlobalPublishFilter.ALL, publish -> { @@ -334,8 +318,7 @@ public class MqttMessagingPlugin implements MessagingPlugin { // Check if it's a client message else if (topic.startsWith("/server/")) { handleClientMessage(topic, payload); - } - else { + } else { log.warn("[MqttPlugin] Received message on unexpected topic: {}", topic); } } catch (Exception e) { @@ -343,12 +326,9 @@ public class MqttMessagingPlugin implements MessagingPlugin { } } - - - /** - * Handle ACK message from client. - * Topic format: /server/{clientId}/ack (messageId in payload) + * Handle ACK message from client. Topic format: /server/{clientId}/ack + * (messageId in payload) */ private void handleAckMessage(String topic, byte[] payload) { if (ackHandler == null) { @@ -359,7 +339,7 @@ public class MqttMessagingPlugin implements MessagingPlugin { // Extract clientId from topic: /server/{clientId}/ack String[] parts = topic.split("/"); if (parts.length >= 4) { - String clientId = parts[2]; // clientId is at index 2 + String clientId = parts[2]; // clientId is at index 2 // Extract messageId from payload String payloadStr = new String(payload, StandardCharsets.UTF_8); @@ -377,9 +357,8 @@ public class MqttMessagingPlugin implements MessagingPlugin { } /** - * Extract messageId from ACK payload. - * Expected payload format: JSON with "messageId" field, e.g., {"messageId": "abc-123"} - * or plain messageId string. + * Extract messageId from ACK payload. Expected payload format: JSON with + * "messageId" field, e.g., {"messageId": "abc-123"} or plain messageId string. */ private String extractMessageIdFromPayload(String payload) { if (payload == null || payload.isBlank()) { @@ -418,9 +397,9 @@ public class MqttMessagingPlugin implements MessagingPlugin { } /** - * Handle client message. - * Topic format: /server/{clientId}/{messageType} or /server/{messageType} (for login) - * messageType can contain slashes, e.g., "jobs/assigned" + * Handle client message. Topic format: /server/{clientId}/{messageType} or + * /server/{messageType} (for login) messageType can contain slashes, e.g., + * "jobs/assigned" */ private void handleClientMessage(String topic, byte[] payload) { // Extract clientId and messageType from topic @@ -463,17 +442,13 @@ public class MqttMessagingPlugin implements MessagingPlugin { */ private CompletableFuture sendToTopic(String topic, byte[] payload, SendOptions options) { try { - var publishBuilder = Mqtt5Publish.builder() - .topic(topic) - .payload(payload) - .qos(mapQos(options.getQos())) + var publishBuilder = Mqtt5Publish.builder().topic(topic).payload(payload).qos(mapQos(options.getQos())) .retain(options.isRetained()); - return mqttClient.publish(publishBuilder.build()) - .thenApply(publishResult -> { - log.debug("[MqttPlugin] Message published to topic: {}", topic); - return null; - }); + return mqttClient.publish(publishBuilder.build()).thenApply(publishResult -> { + log.debug("[MqttPlugin] Message published to topic: {}", topic); + return null; + }); } catch (Exception e) { log.error("[MqttPlugin] Failed to publish to topic {}: {}", topic, e.getMessage(), e); return CompletableFuture.failedFuture(new PluginException("Failed to publish message", e)); @@ -485,10 +460,10 @@ public class MqttMessagingPlugin implements MessagingPlugin { */ private MqttQos mapQos(int qos) { return switch (qos) { - case 0 -> MqttQos.AT_MOST_ONCE; - case 1 -> MqttQos.AT_LEAST_ONCE; - case 2 -> MqttQos.EXACTLY_ONCE; - default -> MqttQos.AT_LEAST_ONCE; + case 0 -> MqttQos.AT_MOST_ONCE; + case 1 -> MqttQos.AT_LEAST_ONCE; + case 2 -> MqttQos.EXACTLY_ONCE; + default -> MqttQos.AT_LEAST_ONCE; }; } @@ -496,14 +471,11 @@ public class MqttMessagingPlugin implements MessagingPlugin { * Notify connection state listener. */ private void notifyConnectionState(ConnectionState state, String message) { - log.debug("[MqttPlugin] notifyConnectionState called: state={}, listener={}", state, connectionListener != null ? "present" : "null"); + log.debug("[MqttPlugin] notifyConnectionState called: state={}, listener={}", state, + connectionListener != null ? "present" : "null"); if (connectionListener != null) { - ConnectionStateEvent event = ConnectionStateEvent.builder() - .state(state) - .previousState(null) - .errorMessage(message) - .pluginName(PLUGIN_NAME) - .build(); + ConnectionStateEvent event = ConnectionStateEvent.builder().state(state).previousState(null) + .errorMessage(message).pluginName(PLUGIN_NAME).build(); try { log.debug("[MqttPlugin] Calling connectionListener.onConnectionStateChanged"); connectionListener.onConnectionStateChanged(event); diff --git a/src/main/java/de/assecutor/votianlt/model/AppUser.java b/src/main/java/de/assecutor/votianlt/model/AppUser.java index bd45f6a..e532a1d 100644 --- a/src/main/java/de/assecutor/votianlt/model/AppUser.java +++ b/src/main/java/de/assecutor/votianlt/model/AppUser.java @@ -50,7 +50,6 @@ public class AppUser { @Field("geraet") private String geraet; - @Field("owner") private ObjectId owner; diff --git a/src/main/java/de/assecutor/votianlt/model/Message.java b/src/main/java/de/assecutor/votianlt/model/Message.java index 6b5b5b4..ee8f651 100644 --- a/src/main/java/de/assecutor/votianlt/model/Message.java +++ b/src/main/java/de/assecutor/votianlt/model/Message.java @@ -49,7 +49,8 @@ public class Message { private LocalDateTime createdAt; /** - * Origin of the message: INCOMING (from client), OUTGOING (to client), or SERVER (from server) + * Origin of the message: INCOMING (from client), OUTGOING (to client), or + * SERVER (from server) */ @Field("origin") private MessageOrigin origin; @@ -94,8 +95,7 @@ public class Message { /** * Constructor for general messages with explicit content type */ - public Message(String content, String receiver, MessageOrigin origin, - MessageContentType contentType) { + public Message(String content, String receiver, MessageOrigin origin, MessageContentType contentType) { initializeBaseFields(content, receiver, origin, contentType); this.messageType = MessageType.GENERAL; } @@ -103,16 +103,15 @@ public class Message { /** * Constructor for job-related messages */ - public Message(String content, String receiver, MessageOrigin origin, - ObjectId jobId, String jobNumber) { + public Message(String content, String receiver, MessageOrigin origin, ObjectId jobId, String jobNumber) { this(content, receiver, origin, MessageContentType.TEXT, jobId, jobNumber); } /** * Constructor for job-related messages with explicit content type */ - public Message(String content, String receiver, MessageOrigin origin, - MessageContentType contentType, ObjectId jobId, String jobNumber) { + public Message(String content, String receiver, MessageOrigin origin, MessageContentType contentType, + ObjectId jobId, String jobNumber) { initializeBaseFields(content, receiver, origin, contentType); this.messageType = MessageType.JOB_RELATED; this.jobId = jobId; diff --git a/src/main/java/de/assecutor/votianlt/model/MessageContentType.java b/src/main/java/de/assecutor/votianlt/model/MessageContentType.java index faf756e..daf4bf3 100644 --- a/src/main/java/de/assecutor/votianlt/model/MessageContentType.java +++ b/src/main/java/de/assecutor/votianlt/model/MessageContentType.java @@ -4,6 +4,5 @@ package de.assecutor.votianlt.model; * Supported content variants for chat messages. */ public enum MessageContentType { - TEXT, - IMAGE + TEXT, IMAGE } diff --git a/src/main/java/de/assecutor/votianlt/model/MessageOrigin.java b/src/main/java/de/assecutor/votianlt/model/MessageOrigin.java index 039a325..596fb70 100644 --- a/src/main/java/de/assecutor/votianlt/model/MessageOrigin.java +++ b/src/main/java/de/assecutor/votianlt/model/MessageOrigin.java @@ -8,7 +8,7 @@ public enum MessageOrigin { * Message received from a client (app user) */ CLIENT, - + /** * Message sent from the server */ diff --git a/src/main/java/de/assecutor/votianlt/model/MessageType.java b/src/main/java/de/assecutor/votianlt/model/MessageType.java index 06863d0..c6cd6ad 100644 --- a/src/main/java/de/assecutor/votianlt/model/MessageType.java +++ b/src/main/java/de/assecutor/votianlt/model/MessageType.java @@ -8,7 +8,7 @@ public enum MessageType { * General message not related to a specific job */ GENERAL, - + /** * Message related to a specific job */ diff --git a/src/main/java/de/assecutor/votianlt/model/PriceTable.java b/src/main/java/de/assecutor/votianlt/model/PriceTable.java index 6349ff2..fdaad0b 100644 --- a/src/main/java/de/assecutor/votianlt/model/PriceTable.java +++ b/src/main/java/de/assecutor/votianlt/model/PriceTable.java @@ -17,7 +17,8 @@ public class PriceTable { public PriceTable() { } - public PriceTable(String monthlyBasePackage, String appUsageLicense, String revenueParticipation, String statisticalEvaluation) { + public PriceTable(String monthlyBasePackage, String appUsageLicense, String revenueParticipation, + String statisticalEvaluation) { this.monthlyBasePackage = monthlyBasePackage; this.appUsageLicense = appUsageLicense; this.revenueParticipation = revenueParticipation; diff --git a/src/main/java/de/assecutor/votianlt/model/invoices/CustomerInvoice.java b/src/main/java/de/assecutor/votianlt/model/invoices/CustomerInvoice.java index 9f3c146..1ff902e 100644 --- a/src/main/java/de/assecutor/votianlt/model/invoices/CustomerInvoice.java +++ b/src/main/java/de/assecutor/votianlt/model/invoices/CustomerInvoice.java @@ -13,7 +13,7 @@ public class CustomerInvoice { private String id; // Pflichtangaben nach §14 UStG (German VAT law) - private String invoiceNumber; // Fortlaufende Rechnungsnummer + private String invoiceNumber; // Fortlaufende Rechnungsnummer private LocalDate invoiceDate; // Rechnungsdatum private LocalDate deliveryDate; // Leistungsdatum @@ -24,7 +24,7 @@ public class CustomerInvoice { private String senderCity; private String senderCountry; private String senderTaxNumber; // Steuernummer - private String senderVatId; // USt-IdNr. + private String senderVatId; // USt-IdNr. private String senderPhone; private String senderEmail; private String senderWebsite; @@ -43,20 +43,20 @@ public class CustomerInvoice { private List items; // Beträge - private BigDecimal netAmount; // Nettobetrag - private BigDecimal vatRate; // Steuersatz (z.B. 19% = 0.19) - private BigDecimal vatAmount; // Steuerbetrag + private BigDecimal netAmount; // Nettobetrag + private BigDecimal vatRate; // Steuersatz (z.B. 19% = 0.19) + private BigDecimal vatAmount; // Steuerbetrag private BigDecimal totalAmount; // Bruttobetrag // Zahlungsdetails - private String paymentTerms; // Zahlungsbedingungen + private String paymentTerms; // Zahlungsbedingungen private LocalDate paymentDueDate; // Fälligkeitsdatum - private String bankAccount; // Bankverbindung + private String bankAccount; // Bankverbindung private String iban; private String bic; // Zusätzliche rechtliche Angaben - private String legalNotes; // Rechtliche Hinweise + private String legalNotes; // Rechtliche Hinweise private String reverseChargeNote; // Hinweis auf Reverse Charge (falls zutreffend) // Constructors diff --git a/src/main/java/de/assecutor/votianlt/model/invoices/CustomerInvoiceData.java b/src/main/java/de/assecutor/votianlt/model/invoices/CustomerInvoiceData.java index 6861963..0929aca 100644 --- a/src/main/java/de/assecutor/votianlt/model/invoices/CustomerInvoiceData.java +++ b/src/main/java/de/assecutor/votianlt/model/invoices/CustomerInvoiceData.java @@ -56,7 +56,7 @@ public class CustomerInvoiceData { // Number formatter for German locale private static final NumberFormat CURRENCY_FORMAT = NumberFormat.getCurrencyInstance(Locale.GERMANY); private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("dd.MM.yyyy"); - + // Constructors public CustomerInvoiceData() { } diff --git a/src/main/java/de/assecutor/votianlt/model/invoices/CustomerInvoiceItem.java b/src/main/java/de/assecutor/votianlt/model/invoices/CustomerInvoiceItem.java index 76a2634..e1d3e75 100644 --- a/src/main/java/de/assecutor/votianlt/model/invoices/CustomerInvoiceItem.java +++ b/src/main/java/de/assecutor/votianlt/model/invoices/CustomerInvoiceItem.java @@ -5,19 +5,20 @@ import java.math.BigDecimal; public class CustomerInvoiceItem { private BigDecimal quantity; - private String unit; // Einheit (Stk., h, kg, etc.) + private String unit; // Einheit (Stk., h, kg, etc.) private String description; - private BigDecimal unitPrice; // Einzelpreis netto - private BigDecimal netTotal; // Gesamtpreis netto - private BigDecimal vatRate; // Steuersatz - private BigDecimal vatAmount; // Steuerbetrag + private BigDecimal unitPrice; // Einzelpreis netto + private BigDecimal netTotal; // Gesamtpreis netto + private BigDecimal vatRate; // Steuersatz + private BigDecimal vatAmount; // Steuerbetrag private BigDecimal grossTotal; // Gesamtpreis brutto - + // Constructors public CustomerInvoiceItem() { } - public CustomerInvoiceItem(BigDecimal quantity, String unit, String description, BigDecimal unitPrice, BigDecimal vatRate) { + public CustomerInvoiceItem(BigDecimal quantity, String unit, String description, BigDecimal unitPrice, + BigDecimal vatRate) { this.quantity = quantity; this.unit = unit; this.description = description; diff --git a/src/main/java/de/assecutor/votianlt/model/invoices/SystemInvoiceData.java b/src/main/java/de/assecutor/votianlt/model/invoices/SystemInvoiceData.java index 97aa2f0..bf88841 100644 --- a/src/main/java/de/assecutor/votianlt/model/invoices/SystemInvoiceData.java +++ b/src/main/java/de/assecutor/votianlt/model/invoices/SystemInvoiceData.java @@ -30,79 +30,194 @@ public class SystemInvoiceData { private String paymentTerms = "Zahlungsbedingungen: Gesamtbetrag bis spätestens zum 10. Werktag nach Rechnungserhalt auf unser u. g. Konto."; - private String footerText = "Geschäftsführer: Carsten Annacker, Halstenbek · Gunnar Timm, Geesthacht
" + - "Steuernummer: 22 294 53099 · USt-IdNr.: DE261094748 · Sitz: Geesthacht · Handelsregister: Lübeck HRB 8595
" + - "Bankverbindung: Hamburger Sparkasse · IBAN DE67200505501217139888 · BIC HASPDEHHXXX"; + private String footerText = "Geschäftsführer: Carsten Annacker, Halstenbek · Gunnar Timm, Geesthacht
" + + "Steuernummer: 22 294 53099 · USt-IdNr.: DE261094748 · Sitz: Geesthacht · Handelsregister: Lübeck HRB 8595
" + + "Bankverbindung: Hamburger Sparkasse · IBAN DE67200505501217139888 · BIC HASPDEHHXXX"; public SystemInvoiceData() { } - public String getCompanyName() { return companyName; } - public void setCompanyName(String companyName) { this.companyName = companyName; } + public String getCompanyName() { + return companyName; + } - public String getCompanySubtitle() { return companySubtitle; } - public void setCompanySubtitle(String companySubtitle) { this.companySubtitle = companySubtitle; } + public void setCompanyName(String companyName) { + this.companyName = companyName; + } - public String getCompanyStreet() { return companyStreet; } - public void setCompanyStreet(String companyStreet) { this.companyStreet = companyStreet; } + public String getCompanySubtitle() { + return companySubtitle; + } - public String getCompanyCity() { return companyCity; } - public void setCompanyCity(String companyCity) { this.companyCity = companyCity; } + public void setCompanySubtitle(String companySubtitle) { + this.companySubtitle = companySubtitle; + } - public String getCompanyPhone() { return companyPhone; } - public void setCompanyPhone(String companyPhone) { this.companyPhone = companyPhone; } + public String getCompanyStreet() { + return companyStreet; + } - public String getCompanyFax() { return companyFax; } - public void setCompanyFax(String companyFax) { this.companyFax = companyFax; } + public void setCompanyStreet(String companyStreet) { + this.companyStreet = companyStreet; + } - public String getCompanyEmail() { return companyEmail; } - public void setCompanyEmail(String companyEmail) { this.companyEmail = companyEmail; } + public String getCompanyCity() { + return companyCity; + } - public String getCompanyWebsite() { return companyWebsite; } - public void setCompanyWebsite(String companyWebsite) { this.companyWebsite = companyWebsite; } + public void setCompanyCity(String companyCity) { + this.companyCity = companyCity; + } - public String getInvoiceNumber() { return invoiceNumber; } - public void setInvoiceNumber(String invoiceNumber) { this.invoiceNumber = invoiceNumber; } + public String getCompanyPhone() { + return companyPhone; + } - public String getInvoiceDate() { return invoiceDate; } - public void setInvoiceDate(String invoiceDate) { this.invoiceDate = invoiceDate; } + public void setCompanyPhone(String companyPhone) { + this.companyPhone = companyPhone; + } - public String getInvoiceText() { return invoiceText; } - public void setInvoiceText(String invoiceText) { this.invoiceText = invoiceText; } + public String getCompanyFax() { + return companyFax; + } - public String getSenderLine() { return senderLine; } - public void setSenderLine(String senderLine) { this.senderLine = senderLine; } + public void setCompanyFax(String companyFax) { + this.companyFax = companyFax; + } - public String getRecipientName() { return recipientName; } - public void setRecipientName(String recipientName) { this.recipientName = recipientName; } + public String getCompanyEmail() { + return companyEmail; + } - public String getRecipientDepartment() { return recipientDepartment; } - public void setRecipientDepartment(String recipientDepartment) { this.recipientDepartment = recipientDepartment; } + public void setCompanyEmail(String companyEmail) { + this.companyEmail = companyEmail; + } - public String getRecipientStreet() { return recipientStreet; } - public void setRecipientStreet(String recipientStreet) { this.recipientStreet = recipientStreet; } + public String getCompanyWebsite() { + return companyWebsite; + } - public String getRecipientCity() { return recipientCity; } - public void setRecipientCity(String recipientCity) { this.recipientCity = recipientCity; } + public void setCompanyWebsite(String companyWebsite) { + this.companyWebsite = companyWebsite; + } - public List getInvoiceItems() { return systemInvoiceItems; } - public void setInvoiceItems(List systemInvoiceItems) { this.systemInvoiceItems = systemInvoiceItems; } + public String getInvoiceNumber() { + return invoiceNumber; + } - public String getNetAmount() { return netAmount; } - public void setNetAmount(String netAmount) { this.netAmount = netAmount; } + public void setInvoiceNumber(String invoiceNumber) { + this.invoiceNumber = invoiceNumber; + } - public String getVatRate() { return vatRate; } - public void setVatRate(String vatRate) { this.vatRate = vatRate; } + public String getInvoiceDate() { + return invoiceDate; + } - public String getVatAmount() { return vatAmount; } - public void setVatAmount(String vatAmount) { this.vatAmount = vatAmount; } + public void setInvoiceDate(String invoiceDate) { + this.invoiceDate = invoiceDate; + } - public String getTotalAmount() { return totalAmount; } - public void setTotalAmount(String totalAmount) { this.totalAmount = totalAmount; } + public String getInvoiceText() { + return invoiceText; + } - public String getPaymentTerms() { return paymentTerms; } - public void setPaymentTerms(String paymentTerms) { this.paymentTerms = paymentTerms; } + public void setInvoiceText(String invoiceText) { + this.invoiceText = invoiceText; + } - public String getFooterText() { return footerText; } - public void setFooterText(String footerText) { this.footerText = footerText; } + public String getSenderLine() { + return senderLine; + } + + public void setSenderLine(String senderLine) { + this.senderLine = senderLine; + } + + public String getRecipientName() { + return recipientName; + } + + public void setRecipientName(String recipientName) { + this.recipientName = recipientName; + } + + public String getRecipientDepartment() { + return recipientDepartment; + } + + public void setRecipientDepartment(String recipientDepartment) { + this.recipientDepartment = recipientDepartment; + } + + public String getRecipientStreet() { + return recipientStreet; + } + + public void setRecipientStreet(String recipientStreet) { + this.recipientStreet = recipientStreet; + } + + public String getRecipientCity() { + return recipientCity; + } + + public void setRecipientCity(String recipientCity) { + this.recipientCity = recipientCity; + } + + public List getInvoiceItems() { + return systemInvoiceItems; + } + + public void setInvoiceItems(List systemInvoiceItems) { + this.systemInvoiceItems = systemInvoiceItems; + } + + public String getNetAmount() { + return netAmount; + } + + public void setNetAmount(String netAmount) { + this.netAmount = netAmount; + } + + public String getVatRate() { + return vatRate; + } + + public void setVatRate(String vatRate) { + this.vatRate = vatRate; + } + + public String getVatAmount() { + return vatAmount; + } + + public void setVatAmount(String vatAmount) { + this.vatAmount = vatAmount; + } + + public String getTotalAmount() { + return totalAmount; + } + + public void setTotalAmount(String totalAmount) { + this.totalAmount = totalAmount; + } + + public String getPaymentTerms() { + return paymentTerms; + } + + public void setPaymentTerms(String paymentTerms) { + this.paymentTerms = paymentTerms; + } + + public String getFooterText() { + return footerText; + } + + public void setFooterText(String footerText) { + this.footerText = footerText; + } } \ No newline at end of file diff --git a/src/main/java/de/assecutor/votianlt/model/task/TaskType.java b/src/main/java/de/assecutor/votianlt/model/task/TaskType.java index 36562bb..3f7b494 100644 --- a/src/main/java/de/assecutor/votianlt/model/task/TaskType.java +++ b/src/main/java/de/assecutor/votianlt/model/task/TaskType.java @@ -1,7 +1,12 @@ package de.assecutor.votianlt.model.task; public enum TaskType { - CONFIRMATION("Bestätigung"), SIGNATURE("Unterschrift"), TODOLIST("To-Do Liste"), PHOTO("Foto"), BARCODE("Barcode"), COMMENT("Kommentar"); + CONFIRMATION("Bestätigung"), + SIGNATURE("Unterschrift"), + TODOLIST("To-Do Liste"), + PHOTO("Foto"), + BARCODE("Barcode"), + COMMENT("Kommentar"); private final String displayName; diff --git a/src/main/java/de/assecutor/votianlt/mqtt/MqttPublisher.java b/src/main/java/de/assecutor/votianlt/mqtt/MqttPublisher.java index 95a53de..4a50e1e 100644 --- a/src/main/java/de/assecutor/votianlt/mqtt/MqttPublisher.java +++ b/src/main/java/de/assecutor/votianlt/mqtt/MqttPublisher.java @@ -54,33 +54,28 @@ class MqttPublisherImpl implements MqttPublisher { String messageType = parts[3]; // Use MessageDeliveryService for reliable delivery - DeliveryOptions options = DeliveryOptions.builder() - .requiresAck(true) - .retained(retained) - .build(); + DeliveryOptions options = DeliveryOptions.builder().requiresAck(true).retained(retained).build(); - deliveryService.sendToClient(clientId, messageType, payload, options) - .thenAccept(receipt -> { - log.info("=== MESSAGE DELIVERY SUBMITTED ==="); - log.info("Topic: {}", topic); - log.info("Message ID: {}", receipt.getMessageId()); - log.info("Status: {}", receipt.getStatus()); - log.info("Retained: {}", retained); + deliveryService.sendToClient(clientId, messageType, payload, options).thenAccept(receipt -> { + log.info("=== MESSAGE DELIVERY SUBMITTED ==="); + log.info("Topic: {}", topic); + log.info("Message ID: {}", receipt.getMessageId()); + log.info("Status: {}", receipt.getStatus()); + log.info("Retained: {}", retained); - // Log payload for debugging - try { - String json = (payload instanceof String s) ? s : objectMapper.writeValueAsString(payload); - log.info("Payload: {}", json); - } catch (Exception e) { - log.debug("Could not serialize payload for logging: {}", e.getMessage()); - } + // Log payload for debugging + try { + String json = (payload instanceof String s) ? s : objectMapper.writeValueAsString(payload); + log.info("Payload: {}", json); + } catch (Exception e) { + log.debug("Could not serialize payload for logging: {}", e.getMessage()); + } - log.info("=== END MESSAGE DELIVERY ==="); - }) - .exceptionally(ex -> { - log.error("Failed to submit message for delivery to topic {}: {}", topic, ex.getMessage(), ex); - return null; - }); + log.info("=== END MESSAGE DELIVERY ==="); + }).exceptionally(ex -> { + log.error("Failed to submit message for delivery to topic {}: {}", topic, ex.getMessage(), ex); + return null; + }); } catch (Exception e) { log.error("Failed to publish message for topic {}: {}", topic, e.getMessage(), e); diff --git a/src/main/java/de/assecutor/votianlt/pages/base/ui/view/AdminLayout.java b/src/main/java/de/assecutor/votianlt/pages/base/ui/view/AdminLayout.java index 709317a..022a1ee 100644 --- a/src/main/java/de/assecutor/votianlt/pages/base/ui/view/AdminLayout.java +++ b/src/main/java/de/assecutor/votianlt/pages/base/ui/view/AdminLayout.java @@ -79,16 +79,19 @@ public final class AdminLayout extends AppLayout { SideNavItem dashboard = new SideNavItem("Dashboard", "admin-dashboard", new Icon(VaadinIcon.DASHBOARD)); SideNavItem pdfTest = new SideNavItem("PDF Test", "pdf-test", new Icon(VaadinIcon.FILE_TEXT_O)); SideNavItem priceTable = new SideNavItem("Preis-Tabelle", "admin-price-table", new Icon(VaadinIcon.COG)); - //SideNavItem systemSettings = new SideNavItem("Systemeinstellungen", "admin-settings", new Icon(VaadinIcon.COG)); - //SideNavItem userManagement = new SideNavItem("Benutzerverwaltung", "admin-users", new Icon(VaadinIcon.USERS)); - //SideNavItem systemLogs = new SideNavItem("System-Logs", "admin-logs", new Icon(VaadinIcon.FILE_TEXT)); + // SideNavItem systemSettings = new SideNavItem("Systemeinstellungen", + // "admin-settings", new Icon(VaadinIcon.COG)); + // SideNavItem userManagement = new SideNavItem("Benutzerverwaltung", + // "admin-users", new Icon(VaadinIcon.USERS)); + // SideNavItem systemLogs = new SideNavItem("System-Logs", "admin-logs", new + // Icon(VaadinIcon.FILE_TEXT)); nav.addItem(dashboard); nav.addItem(pdfTest); nav.addItem(priceTable); - //nav.addItem(systemSettings); - //nav.addItem(userManagement); - //nav.addItem(systemLogs); + // nav.addItem(systemSettings); + // nav.addItem(userManagement); + // nav.addItem(systemLogs); // Create a vertical layout to hold menu items VerticalLayout navContainer = new VerticalLayout(); 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 e143b2d..511dc91 100644 --- a/src/main/java/de/assecutor/votianlt/pages/service/AddJobService.java +++ b/src/main/java/de/assecutor/votianlt/pages/service/AddJobService.java @@ -10,10 +10,12 @@ import de.assecutor.votianlt.security.SecurityService; import de.assecutor.votianlt.repository.CargoItemRepository; import de.assecutor.votianlt.service.JobHistoryService; import de.assecutor.votianlt.service.EmailService; +import de.assecutor.votianlt.event.JobCreatedEvent; import java.util.Objects; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.bson.types.ObjectId; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import java.time.LocalDateTime; @@ -32,6 +34,7 @@ public class AddJobService { private final SecurityService securityService; private final JobHistoryService jobHistoryService; private final EmailService emailService; + private final ApplicationEventPublisher eventPublisher; /** * Speichert einen neuen Auftrag samt CargoItems und Tasks @@ -118,6 +121,14 @@ public class AddJobService { e.getMessage()); } + // Publish job created event for real-time UI updates + try { + eventPublisher.publishEvent(new JobCreatedEvent(this, savedJob)); + } catch (Exception e) { + log.warn("Failed to publish job created event for job {}: {}", savedJob.getIdAsString(), + e.getMessage()); + } + log.info("Auftrag erfolgreich gespeichert: {}", savedJob.getJobNumber()); return savedJob; diff --git a/src/main/java/de/assecutor/votianlt/pages/service/UserInvoiceDataService.java b/src/main/java/de/assecutor/votianlt/pages/service/UserInvoiceDataService.java index e28a66a..a979e3e 100644 --- a/src/main/java/de/assecutor/votianlt/pages/service/UserInvoiceDataService.java +++ b/src/main/java/de/assecutor/votianlt/pages/service/UserInvoiceDataService.java @@ -26,8 +26,7 @@ public class UserInvoiceDataService { } public UserInvoiceData createOrUpdate(ObjectId userId, boolean billingEnabled, String prefix, String ustId, - String taxNumber, String bankName, String iban, String taxRate, - String introText, String paymentTerms) { + String taxNumber, String bankName, String iban, String taxRate, String introText, String paymentTerms) { // If billing is disabled, delete any existing record and return null if (!billingEnabled) { deleteByUserId(userId); diff --git a/src/main/java/de/assecutor/votianlt/pages/view/AddAppUserView.java b/src/main/java/de/assecutor/votianlt/pages/view/AddAppUserView.java index 2729961..3ff1d3e 100644 --- a/src/main/java/de/assecutor/votianlt/pages/view/AddAppUserView.java +++ b/src/main/java/de/assecutor/votianlt/pages/view/AddAppUserView.java @@ -102,7 +102,6 @@ public class AddAppUserView extends VerticalLayout { phoneField.setRequiredIndicatorVisible(true); phoneField.addBlurListener(e -> validateField(phoneField, "Telefonnummer ist ein Pflichtfeld")); - emailField.setWidthFull(); emailField.setRequiredIndicatorVisible(true); emailField.addBlurListener(e -> validateEmailField()); @@ -122,7 +121,6 @@ public class AddAppUserView extends VerticalLayout { confirmPasswordField.setRequiredIndicatorVisible(true); confirmPasswordField.addBlurListener(e -> validateConfirmPasswordField()); - // Add fields to form formLayout.add(designationField); formLayout.add(nameLayout); @@ -200,10 +198,12 @@ public class AddAppUserView extends VerticalLayout { emailField.setInvalid(true); emailField.setErrorMessage("E-Mail-Adresse bereits vorhanden"); } else { - Notification.show("Ein Fehler ist aufgetreten: Doppelter Wert gefunden", 5000, Notification.Position.MIDDLE); + Notification.show("Ein Fehler ist aufgetreten: Doppelter Wert gefunden", 5000, + Notification.Position.MIDDLE); } } catch (Exception e) { - Notification.show("Fehler beim Anlegen des App-Nutzers: " + e.getMessage(), 5000, Notification.Position.MIDDLE); + Notification.show("Fehler beim Anlegen des App-Nutzers: " + e.getMessage(), 5000, + Notification.Position.MIDDLE); } } @@ -269,7 +269,6 @@ public class AddAppUserView extends VerticalLayout { } } - private boolean validateAllFields() { validateField(designationField, "Kennung ist ein Pflichtfeld"); validateField(firstnameField, "Vorname ist ein Pflichtfeld"); @@ -280,7 +279,7 @@ public class AddAppUserView extends VerticalLayout { validateConfirmPasswordField(); return !designationField.isInvalid() && !firstnameField.isInvalid() && !lastnameField.isInvalid() - && !phoneField.isInvalid() && !emailField.isInvalid() - && !passwordField.isInvalid() && !confirmPasswordField.isInvalid(); + && !phoneField.isInvalid() && !emailField.isInvalid() && !passwordField.isInvalid() + && !confirmPasswordField.isInvalid(); } } diff --git a/src/main/java/de/assecutor/votianlt/pages/view/AddCustomerView.java b/src/main/java/de/assecutor/votianlt/pages/view/AddCustomerView.java index f0ff864..0de918f 100644 --- a/src/main/java/de/assecutor/votianlt/pages/view/AddCustomerView.java +++ b/src/main/java/de/assecutor/votianlt/pages/view/AddCustomerView.java @@ -264,8 +264,8 @@ public class AddCustomerView extends Main { validateField(city); validateEmail(); - return !companyName.isInvalid() && !firstName.isInvalid() && !lastName.isInvalid() - && !telephone.isInvalid() && !mail.isInvalid() && !street.isInvalid() - && !houseNumber.isInvalid() && !zip.isInvalid() && !city.isInvalid(); + return !companyName.isInvalid() && !firstName.isInvalid() && !lastName.isInvalid() && !telephone.isInvalid() + && !mail.isInvalid() && !street.isInvalid() && !houseNumber.isInvalid() && !zip.isInvalid() + && !city.isInvalid(); } } diff --git a/src/main/java/de/assecutor/votianlt/pages/view/AddJobView.java b/src/main/java/de/assecutor/votianlt/pages/view/AddJobView.java index ba06fad..1bfe7eb 100644 --- a/src/main/java/de/assecutor/votianlt/pages/view/AddJobView.java +++ b/src/main/java/de/assecutor/votianlt/pages/view/AddJobView.java @@ -132,6 +132,7 @@ public class AddJobView extends Main { private Span cargoError; private VerticalLayout cargoList; private VerticalLayout tasksList; + private ComboBox templateComboBox; private TextArea remarkArea; private VerticalLayout pickupSection; private VerticalLayout deliverySection; @@ -145,8 +146,8 @@ public class AddJobView extends Main { private List availableAppUsers; public AddJobView(AddJobService addJobService, AddCustomerService addCustomerService, - CustomerService customerService, AppUserService appUserService, - TaskTemplateService taskTemplateService, SecurityService securityService) { + CustomerService customerService, AppUserService appUserService, TaskTemplateService taskTemplateService, + SecurityService securityService) { this.addJobService = addJobService; this.addCustomerService = addCustomerService; this.customerService = customerService; @@ -367,28 +368,22 @@ public class AddJobView extends Main { pickupDate = new DatePicker("Datum"); pickupDate.setRequiredIndicatorVisible(true); pickupDate.setLocale(java.util.Locale.GERMANY); // Monday as first day of week - pickupDate.setI18n(new DatePicker.DatePickerI18n() - .setFirstDayOfWeek(1) // 1 = Monday - .setMonthNames(java.util.Arrays.asList( - "Januar", "Februar", "März", "April", "Mai", "Juni", - "Juli", "August", "September", "Oktober", "November", "Dezember")) - .setWeekdays(java.util.Arrays.asList( - "Sonntag", "Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag")) - .setWeekdaysShort(java.util.Arrays.asList( - "So", "Mo", "Di", "Mi", "Do", "Fr", "Sa"))); + pickupDate.setI18n(new DatePicker.DatePickerI18n().setFirstDayOfWeek(1) // 1 = Monday + .setMonthNames(java.util.Arrays.asList("Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", + "August", "September", "Oktober", "November", "Dezember")) + .setWeekdays(java.util.Arrays.asList("Sonntag", "Montag", "Dienstag", "Mittwoch", "Donnerstag", + "Freitag", "Samstag")) + .setWeekdaysShort(java.util.Arrays.asList("So", "Mo", "Di", "Mi", "Do", "Fr", "Sa"))); deliveryDate = new DatePicker("Datum"); deliveryDate.setRequiredIndicatorVisible(true); deliveryDate.setLocale(java.util.Locale.GERMANY); // Monday as first day of week - deliveryDate.setI18n(new DatePicker.DatePickerI18n() - .setFirstDayOfWeek(1) // 1 = Monday - .setMonthNames(java.util.Arrays.asList( - "Januar", "Februar", "März", "April", "Mai", "Juni", - "Juli", "August", "September", "Oktober", "November", "Dezember")) - .setWeekdays(java.util.Arrays.asList( - "Sonntag", "Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag")) - .setWeekdaysShort(java.util.Arrays.asList( - "So", "Mo", "Di", "Mi", "Do", "Fr", "Sa"))); + deliveryDate.setI18n(new DatePicker.DatePickerI18n().setFirstDayOfWeek(1) // 1 = Monday + .setMonthNames(java.util.Arrays.asList("Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", + "August", "September", "Oktober", "November", "Dezember")) + .setWeekdays(java.util.Arrays.asList("Sonntag", "Montag", "Dienstag", "Mittwoch", "Donnerstag", + "Freitag", "Samstag")) + .setWeekdaysShort(java.util.Arrays.asList("So", "Mo", "Di", "Mi", "Do", "Fr", "Sa"))); // Submit button - initially disabled until all required fields are valid submitButton = new Button("Auftrag anlegen", event -> submit()); @@ -423,6 +418,15 @@ public class AddJobView extends Main { // Tab 4: Tasks tasksTab = tabSheet.add("Aufgaben", createTasksTab()); + // Disable tasks tab initially if digital processing is off + if (!Boolean.TRUE.equals(digitalProcessing.getValue())) { + tasksTab.setEnabled(false); + tasksState.clear(); + if (tasksList != null) { + tasksList.removeAll(); + } + } + // Tab 5: Price & Submit priceTab = tabSheet.add("Preis & Abschluss", createPriceAndSubmitTab()); @@ -853,8 +857,7 @@ public class AddJobView extends Main { binder.bind(deliveryPhone, Job::getDeliveryPhone, Job::setDeliveryPhone); binder.bind(deliveryAddressAddition, Job::getDeliveryAddressAddition, Job::setDeliveryAddressAddition); - binder.forField(digitalProcessing).bind( - Job::isDigitalProcessing, + binder.forField(digitalProcessing).bind(Job::isDigitalProcessing, (job, value) -> job.setDigitalProcessing(Boolean.TRUE.equals(value))); // Bind appUser with converter: AppUser object <-> String ID @@ -876,10 +879,31 @@ public class AddJobView extends Main { }, "Bitte App-Nutzer auswählen, wenn Digitale Abwicklung aktiv ist") .bind(Job::getAppUser, Job::setAppUser); - // Toggle required indicator for App-Nutzer based on digitalProcessing + // Toggle required indicator and visibility for App-Nutzer based on + // digitalProcessing digitalProcessing.addValueChangeListener(e -> { boolean required = Boolean.TRUE.equals(e.getValue()); appUser.setRequiredIndicatorVisible(required); + appUser.setVisible(required); + + // Enable/disable tasks tab based on digital processing + if (tasksTab != null) { + tasksTab.setEnabled(required); + } + if (!required) { + // Clear app user and all tasks when digital processing is disabled + appUser.clear(); + tasksState.clear(); + if (tasksList != null) { + tasksList.removeAll(); + } + } else { + // Add an empty task row when digital processing is re-enabled + if (tasksState.isEmpty() && tasksList != null) { + createTaskRow(); + } + } + triggerValidation(); updateTabLabels(); }); @@ -888,8 +912,10 @@ public class AddJobView extends Main { triggerValidation(); updateTabLabels(); }); - // Initialize required indicator state - appUser.setRequiredIndicatorVisible(Boolean.TRUE.equals(digitalProcessing.getValue())); + // Initialize required indicator and visibility state + boolean digitalInitial = Boolean.TRUE.equals(digitalProcessing.getValue()); + appUser.setRequiredIndicatorVisible(digitalInitial); + appUser.setVisible(digitalInitial); // Set up validation triggers and visual styling setupValidationTriggers(); @@ -956,11 +982,8 @@ public class AddJobView extends Main { // Update submit button state based on all validation checks if (submitButton != null) { - boolean hasErrors = hasAddressValidationErrors() - || hasAppointmentValidationErrors() - || hasCargoValidationErrors() - || hasPriceValidationErrors() - || hasTasksValidationErrors(); + boolean hasErrors = hasAddressValidationErrors() || hasAppointmentValidationErrors() + || hasCargoValidationErrors() || hasPriceValidationErrors() || hasTasksValidationErrors(); submitButton.setEnabled(!hasErrors); } } @@ -1073,8 +1096,7 @@ public class AddJobView extends Main { return true; } // Check if at least one todo item is non-empty - boolean hasValidTodoItem = todoItems.stream() - .anyMatch(item -> item != null && !item.trim().isEmpty()); + boolean hasValidTodoItem = todoItems.stream().anyMatch(item -> item != null && !item.trim().isEmpty()); if (!hasValidTodoItem) { return true; } @@ -1164,9 +1186,10 @@ public class AddJobView extends Main { addCustomerService.addCustomer(deliveryCustomer); } - // All validations passed, save the job with cargo items and tasks (tasks may be - // empty) - Job savedJob = addJobService.addJobWithCargo(job, cargoFilled, tasksState); + // All validations passed, save the job with cargo items and tasks + // If digital processing is disabled, don't save any tasks + List tasksToSave = job.isDigitalProcessing() ? tasksState : List.of(); + Job savedJob = addJobService.addJobWithCargo(job, cargoFilled, tasksToSave); // Erfolgsmeldung und Navigation zur Zusammenfassung Notification successNotification = Notification @@ -1426,7 +1449,7 @@ public class AddJobView extends Main { tasksTitle.getStyle().set("margin", "0"); tasksTitle.getStyle().set("white-space", "nowrap"); - ComboBox templateComboBox = new ComboBox<>(); + templateComboBox = new ComboBox<>(); templateComboBox.setPlaceholder("Template auswählen..."); templateComboBox.setItemLabelGenerator(TaskTemplate::getTemplateName); templateComboBox.setClearButtonVisible(true); @@ -1688,24 +1711,31 @@ public class AddJobView extends Main { BaseTask newTask = createTaskByType(selectedType); BaseTask oldTask = currentTask[0]; + newTask.setDescription(oldTask.getDescription()); newTask.setCompleted(oldTask.isCompleted()); newTask.setCompletedAt(oldTask.getCompletedAt()); newTask.setCompletedBy(oldTask.getCompletedBy()); // Preserve task-specific properties switch (oldTask) { - case ConfirmationTask oldConfirmationTask when newTask instanceof ConfirmationTask newConfirmationTask -> newConfirmationTask.setButtonText(oldConfirmationTask.getButtonText()); - case TodoListTask oldTodoTask when newTask instanceof TodoListTask newTodoTask -> newTodoTask.setTodoItems(oldTodoTask.getTodoItems()); - case PhotoTask oldPhotoTask when newTask instanceof PhotoTask newPhotoTask -> { - newPhotoTask.setMinPhotoCount(oldPhotoTask.getMinPhotoCount()); - newPhotoTask.setMaxPhotoCount(oldPhotoTask.getMaxPhotoCount()); - } - case BarcodeTask oldBarcodeTask when newTask instanceof BarcodeTask newBarcodeTask -> { - newBarcodeTask.setMinBarcodeCount(oldBarcodeTask.getMinBarcodeCount()); - newBarcodeTask.setMaxBarcodeCount(oldBarcodeTask.getMaxBarcodeCount()); - } - default -> { - } + case ConfirmationTask oldConfirmationTask when newTask instanceof ConfirmationTask newConfirmationTask -> + newConfirmationTask.setButtonText(oldConfirmationTask.getButtonText()); + case TodoListTask oldTodoTask when newTask instanceof TodoListTask newTodoTask -> + newTodoTask.setTodoItems(oldTodoTask.getTodoItems()); + case PhotoTask oldPhotoTask when newTask instanceof PhotoTask newPhotoTask -> { + newPhotoTask.setMinPhotoCount(oldPhotoTask.getMinPhotoCount()); + newPhotoTask.setMaxPhotoCount(oldPhotoTask.getMaxPhotoCount()); + } + case BarcodeTask oldBarcodeTask when newTask instanceof BarcodeTask newBarcodeTask -> { + newBarcodeTask.setMinBarcodeCount(oldBarcodeTask.getMinBarcodeCount()); + newBarcodeTask.setMaxBarcodeCount(oldBarcodeTask.getMaxBarcodeCount()); + } + case CommentTask oldCommentTask when newTask instanceof CommentTask newCommentTask -> { + newCommentTask.setCommentText(oldCommentTask.getCommentText()); + newCommentTask.setRequired(oldCommentTask.isRequired()); + } + default -> { + } } // Replace in state and preserve order @@ -1974,6 +2004,27 @@ public class AddJobView extends Main { configContainer.add(barcodeLayout); break; + case COMMENT: + CommentTask commentTask = (CommentTask) task; + + TextField commentTextField = new TextField("Kommentar-Platzhalter"); + commentTextField.setPlaceholder("Hinweis für den Benutzer..."); + commentTextField.setWidthFull(); + commentTextField.setValue(commentTask.getCommentText() != null ? commentTask.getCommentText() : ""); + commentTextField.addValueChangeListener(ev -> { + commentTask.setCommentText(ev.getValue()); + }); + + com.vaadin.flow.component.checkbox.Checkbox requiredCheckbox = new com.vaadin.flow.component.checkbox.Checkbox( + "Pflichtfeld"); + requiredCheckbox.setValue(commentTask.isRequired()); + requiredCheckbox.addValueChangeListener(ev -> { + commentTask.setRequired(ev.getValue()); + }); + + configContainer.add(commentTextField, requiredCheckbox); + break; + default: throw new IllegalArgumentException("Unbekannter TaskType: " + taskType); } @@ -2028,7 +2079,8 @@ public class AddJobView extends Main { saveButton.addClickListener(e -> { String templateName = templateNameField.getValue(); if (templateName == null || templateName.trim().isEmpty()) { - Notification.show("Bitte geben Sie einen Template-Namen ein", 3000, Notification.Position.BOTTOM_END); + Notification.show("Bitte geben Sie einen Template-Namen ein", 3000, + Notification.Position.BOTTOM_END); return; } @@ -2042,20 +2094,20 @@ public class AddJobView extends Main { } // Save template with task type information and specific data - taskTemplateService.createTemplate( - securityService.getCurrentDatabaseUser().getId(), - templateName.trim(), - tasksCopy - ); + taskTemplateService.createTemplate(securityService.getCurrentDatabaseUser().getId(), + templateName.trim(), tasksCopy); dialog.close(); - Notification.show("Template '" + templateName + "' erfolgreich gespeichert", 3000, Notification.Position.BOTTOM_END); + loadTemplatesIntoComboBox(templateComboBox); + Notification.show("Template '" + templateName + "' erfolgreich gespeichert", 3000, + Notification.Position.BOTTOM_END); } catch (RuntimeException ex) { Notification.show(ex.getMessage(), 4000, Notification.Position.MIDDLE); } catch (Exception ex) { log.error("Error saving task template", ex); - Notification.show("Fehler beim Speichern des Templates: " + ex.getMessage(), 4000, Notification.Position.MIDDLE); + Notification.show("Fehler beim Speichern des Templates: " + ex.getMessage(), 4000, + Notification.Position.MIDDLE); } }); @@ -2076,8 +2128,8 @@ public class AddJobView extends Main { } /** - * Creates a deep copy of a task to avoid reference issues in templates - * Saves all task-specific data including type and specific properties + * Creates a deep copy of a task to avoid reference issues in templates Saves + * all task-specific data including type and specific properties */ private BaseTask createTaskCopy(BaseTask original) { BaseTask copy = null; @@ -2102,28 +2154,23 @@ public class AddJobView extends Main { } else if (original instanceof PhotoTask) { PhotoTask origTask = (PhotoTask) original; // Copy with all photo-specific parameters - copy = new PhotoTask( - origTask.getMinPhotoCount() != null ? origTask.getMinPhotoCount() : 1, - origTask.getMaxPhotoCount() != null ? origTask.getMaxPhotoCount() : 10 - ); + copy = new PhotoTask(origTask.getMinPhotoCount() != null ? origTask.getMinPhotoCount() : 1, + origTask.getMaxPhotoCount() != null ? origTask.getMaxPhotoCount() : 10); } else if (original instanceof BarcodeTask) { BarcodeTask origTask = (BarcodeTask) original; // Copy with all barcode-specific parameters - copy = new BarcodeTask( - origTask.getMinBarcodeCount() != null ? origTask.getMinBarcodeCount() : 1, - origTask.getMaxBarcodeCount() != null ? origTask.getMaxBarcodeCount() : 10 - ); + copy = new BarcodeTask(origTask.getMinBarcodeCount() != null ? origTask.getMinBarcodeCount() : 1, + origTask.getMaxBarcodeCount() != null ? origTask.getMaxBarcodeCount() : 10); } else if (original instanceof CommentTask) { CommentTask origTask = (CommentTask) original; // Copy with all comment-specific parameters - copy = new CommentTask( - origTask.getCommentText() != null ? origTask.getCommentText() : "", - origTask.isRequired() - ); + copy = new CommentTask(origTask.getCommentText() != null ? origTask.getCommentText() : "", + origTask.isRequired()); } if (copy != null) { // Copy all base task properties + copy.setDescription(original.getDescription()); copy.setTaskOrder(original.getTaskOrder() != null ? original.getTaskOrder() : 0); copy.setCompleted(original.isCompleted()); copy.setCompletedAt(original.getCompletedAt()); @@ -2138,9 +2185,8 @@ public class AddJobView extends Main { */ private void loadTemplatesIntoComboBox(ComboBox templateComboBox) { try { - List templates = taskTemplateService.findByUserId( - securityService.getCurrentDatabaseUser().getId() - ); + List templates = taskTemplateService + .findByUserId(securityService.getCurrentDatabaseUser().getId()); templateComboBox.setItems(templates); } catch (Exception e) { log.error("Error loading templates", e); @@ -2154,8 +2200,8 @@ public class AddJobView extends Main { private void loadTasksFromTemplate(TaskTemplate template, ComboBox templateComboBox) { ConfirmDialog confirmDialog = new ConfirmDialog(); confirmDialog.setHeader("Template laden"); - confirmDialog.setText("Möchten Sie wirklich das Template '" + template.getTemplateName() + - "' laden? Alle aktuellen Aufgaben werden ersetzt."); + confirmDialog.setText("Möchten Sie wirklich das Template '" + template.getTemplateName() + + "' laden? Alle aktuellen Aufgaben werden ersetzt."); confirmDialog.setCancelable(true); confirmDialog.setCancelText("Abbrechen"); confirmDialog.setConfirmText("Laden"); @@ -2181,13 +2227,17 @@ public class AddJobView extends Main { // Clear the combobox selection templateComboBox.clear(); - Notification.show("Template '" + template.getTemplateName() + "' erfolgreich geladen", - 3000, Notification.Position.BOTTOM_END); + // Re-validate to enable submit button if all fields are valid + triggerValidation(); + updateTabLabels(); + + Notification.show("Template '" + template.getTemplateName() + "' erfolgreich geladen", 3000, + Notification.Position.BOTTOM_END); } catch (Exception ex) { log.error("Error loading template tasks", ex); - Notification.show("Fehler beim Laden des Templates: " + ex.getMessage(), - 4000, Notification.Position.MIDDLE); + Notification.show("Fehler beim Laden des Templates: " + ex.getMessage(), 4000, + Notification.Position.MIDDLE); } }); @@ -2200,11 +2250,12 @@ public class AddJobView extends Main { } /** - * Creates a task row from an existing task (used when loading templates) - * This creates a UI row and populates it with the task's specific data + * Creates a task row from an existing task (used when loading templates) This + * creates a UI row and populates it with the task's specific data */ private void createTaskRowFromTask(BaseTask task) { - // Don't call createTaskRow() directly, as it would create a default ConfirmationTask + // Don't call createTaskRow() directly, as it would create a default + // ConfirmationTask // Instead, create the UI components and set them up with the loaded task VerticalLayout taskContainer = new VerticalLayout(); @@ -2251,36 +2302,46 @@ public class AddJobView extends Main { final BaseTask[] currentTask = { task }; - // Set up the value change listener for the combo box + // Set the combo value BEFORE registering the listener so the listener does + // NOT fire during initialization. The loaded task object is already correct + // and is already in tasksState — no replacement needed. + TaskType taskType = getTaskTypeFromTask(task); + if (taskType != null) { + taskTypeCombo.setValue(taskType); + } + + // Register the listener for user-initiated type changes only taskTypeCombo.addValueChangeListener(ev -> { TaskType selectedType = ev.getValue(); if (selectedType != null) { BaseTask newTask = createTaskByType(selectedType); BaseTask oldTask = currentTask[0]; + newTask.setDescription(oldTask.getDescription()); newTask.setCompleted(oldTask.isCompleted()); newTask.setCompletedAt(oldTask.getCompletedAt()); newTask.setCompletedBy(oldTask.getCompletedBy()); // Preserve task-specific properties switch (oldTask) { - case ConfirmationTask oldConfirmationTask when newTask instanceof ConfirmationTask newConfirmationTask -> - newConfirmationTask.setButtonText(oldConfirmationTask.getButtonText()); - case TodoListTask oldTodoTask when newTask instanceof TodoListTask newTodoTask -> - newTodoTask.setTodoItems(oldTodoTask.getTodoItems()); - case PhotoTask oldPhotoTask when newTask instanceof PhotoTask newPhotoTask -> { - newPhotoTask.setMinPhotoCount(oldPhotoTask.getMinPhotoCount()); - newPhotoTask.setMaxPhotoCount(oldPhotoTask.getMaxPhotoCount()); - } - case BarcodeTask oldBarcodeTask when newTask instanceof BarcodeTask newBarcodeTask -> { - newBarcodeTask.setMinBarcodeCount(oldBarcodeTask.getMinBarcodeCount()); - newBarcodeTask.setMaxBarcodeCount(oldBarcodeTask.getMaxBarcodeCount()); - } - case CommentTask oldCommentTask when newTask instanceof CommentTask newCommentTask -> { - newCommentTask.setCommentText(oldCommentTask.getCommentText()); - newCommentTask.setRequired(oldCommentTask.isRequired()); - } - default -> {} + case ConfirmationTask oldConfirmationTask when newTask instanceof ConfirmationTask newConfirmationTask -> + newConfirmationTask.setButtonText(oldConfirmationTask.getButtonText()); + case TodoListTask oldTodoTask when newTask instanceof TodoListTask newTodoTask -> + newTodoTask.setTodoItems(oldTodoTask.getTodoItems()); + case PhotoTask oldPhotoTask when newTask instanceof PhotoTask newPhotoTask -> { + newPhotoTask.setMinPhotoCount(oldPhotoTask.getMinPhotoCount()); + newPhotoTask.setMaxPhotoCount(oldPhotoTask.getMaxPhotoCount()); + } + case BarcodeTask oldBarcodeTask when newTask instanceof BarcodeTask newBarcodeTask -> { + newBarcodeTask.setMinBarcodeCount(oldBarcodeTask.getMinBarcodeCount()); + newBarcodeTask.setMaxBarcodeCount(oldBarcodeTask.getMaxBarcodeCount()); + } + case CommentTask oldCommentTask when newTask instanceof CommentTask newCommentTask -> { + newCommentTask.setCommentText(oldCommentTask.getCommentText()); + newCommentTask.setRequired(oldCommentTask.isRequired()); + } + default -> { + } } // Replace in state and preserve order @@ -2297,14 +2358,10 @@ public class AddJobView extends Main { } }); - // Set the correct task type based on the loaded task - TaskType taskType = getTaskTypeFromTask(task); - if (taskType != null) { - taskTypeCombo.setValue(taskType); - updateTaskConfiguration(configContainer, task); - triggerValidation(); - updateTabLabels(); - } + // Render the UI with the loaded task directly (which IS in tasksState) + updateTaskConfiguration(configContainer, task); + triggerValidation(); + updateTabLabels(); tasksList.add(taskContainer); } @@ -2313,14 +2370,19 @@ public class AddJobView extends Main { * Gets the TaskType enum value from a BaseTask instance */ private TaskType getTaskTypeFromTask(BaseTask task) { - if (task instanceof ConfirmationTask) return TaskType.CONFIRMATION; - if (task instanceof SignatureTask) return TaskType.SIGNATURE; - if (task instanceof TodoListTask) return TaskType.TODOLIST; - if (task instanceof PhotoTask) return TaskType.PHOTO; - if (task instanceof BarcodeTask) return TaskType.BARCODE; - if (task instanceof CommentTask) return TaskType.COMMENT; + if (task instanceof ConfirmationTask) + return TaskType.CONFIRMATION; + if (task instanceof SignatureTask) + return TaskType.SIGNATURE; + if (task instanceof TodoListTask) + return TaskType.TODOLIST; + if (task instanceof PhotoTask) + return TaskType.PHOTO; + if (task instanceof BarcodeTask) + return TaskType.BARCODE; + if (task instanceof CommentTask) + return TaskType.COMMENT; return TaskType.CONFIRMATION; // fallback } - } diff --git a/src/main/java/de/assecutor/votianlt/pages/view/AdminDashboardView.java b/src/main/java/de/assecutor/votianlt/pages/view/AdminDashboardView.java index f173e69..1032ec3 100644 --- a/src/main/java/de/assecutor/votianlt/pages/view/AdminDashboardView.java +++ b/src/main/java/de/assecutor/votianlt/pages/view/AdminDashboardView.java @@ -16,12 +16,12 @@ import com.vaadin.flow.router.Route; import com.vaadin.flow.theme.lumo.LumoUtility; import de.assecutor.votianlt.model.JobStatus; import de.assecutor.votianlt.repository.*; +import de.assecutor.votianlt.util.DateTimeFormatUtil; import jakarta.annotation.security.RolesAllowed; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import java.time.LocalDateTime; -import java.time.format.DateTimeFormatter; import java.util.concurrent.CompletableFuture; @Route(value = "admin-dashboard", layout = de.assecutor.votianlt.pages.base.ui.view.AdminLayout.class) @@ -43,19 +43,12 @@ public class AdminDashboardView extends Main { private final PendingMqttMessageRepository pendingMqttMessageRepository; private final Div statisticsContainer; - private final DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm"); @Autowired - public AdminDashboardView( - JobRepository jobRepository, - TaskRepository taskRepository, - UserRepository userRepository, - AppUserRepository appUserRepository, - CargoItemRepository cargoItemRepository, - PhotoRepository photoRepository, - BarcodeRepository barcodeRepository, - SignatureRepository signatureRepository, - CommentRepository commentRepository, + public AdminDashboardView(JobRepository jobRepository, TaskRepository taskRepository, UserRepository userRepository, + AppUserRepository appUserRepository, CargoItemRepository cargoItemRepository, + PhotoRepository photoRepository, BarcodeRepository barcodeRepository, + SignatureRepository signatureRepository, CommentRepository commentRepository, PendingMqttMessageRepository pendingMqttMessageRepository) { this.jobRepository = jobRepository; @@ -70,8 +63,8 @@ public class AdminDashboardView extends Main { this.pendingMqttMessageRepository = pendingMqttMessageRepository; setSizeFull(); - addClassNames(LumoUtility.BoxSizing.BORDER, LumoUtility.Display.FLEX, - LumoUtility.FlexDirection.COLUMN, LumoUtility.Padding.MEDIUM); + addClassNames(LumoUtility.BoxSizing.BORDER, LumoUtility.Display.FLEX, LumoUtility.FlexDirection.COLUMN, + LumoUtility.Padding.MEDIUM); // Header H1 title = new H1("Administrator Dashboard"); @@ -170,7 +163,7 @@ public class AdminDashboardView extends Main { cards.add(createStatCard("App-Benutzer", String.valueOf(totalAppUsers), VaadinIcon.MOBILE, "purple")); // Current time - String currentTime = LocalDateTime.now().format(dateTimeFormatter); + String currentTime = DateTimeFormatUtil.formatDateTime(LocalDateTime.now()); cards.add(createStatCard("Letzte Aktualisierung", currentTime, VaadinIcon.CLOCK, "gray")); section.add(title, cards); @@ -240,7 +233,8 @@ public class AdminDashboardView extends Main { // Completion rate double completionRate = totalTasks > 0 ? (completedTasks * 100.0 / totalTasks) : 0; - cards.add(createStatCard("Erfolgsquote", String.format("%.1f%%", completionRate), VaadinIcon.TRENDING_UP, "purple")); + cards.add(createStatCard("Erfolgsquote", String.format("%.1f%%", completionRate), VaadinIcon.TRENDING_UP, + "purple")); section.add(title, cards); return section; @@ -321,11 +315,8 @@ public class AdminDashboardView extends Main { private Div createStatCard(String title, String value, VaadinIcon icon, String color) { Div card = new Div(); card.addClassName(LumoUtility.Background.BASE); - card.getStyle() - .set("border-radius", "8px") - .set("padding", "1rem") - .set("box-shadow", "0 2px 4px rgba(0,0,0,0.1)") - .set("min-width", "200px") + card.getStyle().set("border-radius", "8px").set("padding", "1rem") + .set("box-shadow", "0 2px 4px rgba(0,0,0,0.1)").set("min-width", "200px") .set("border-left", "4px solid var(--lumo-" + color + "-color, #007bff)"); HorizontalLayout header = new HorizontalLayout(); diff --git a/src/main/java/de/assecutor/votianlt/pages/view/AdminPricetableView.java b/src/main/java/de/assecutor/votianlt/pages/view/AdminPricetableView.java index b544e60..c1433aa 100644 --- a/src/main/java/de/assecutor/votianlt/pages/view/AdminPricetableView.java +++ b/src/main/java/de/assecutor/votianlt/pages/view/AdminPricetableView.java @@ -29,10 +29,10 @@ public class AdminPricetableView extends VerticalLayout { setPadding(false); getStyle().set("margin", "14px"); setWidth("90%"); - + H2 title = new H2("Preis-Tabelle"); add(title); - + VerticalLayout fieldsLayout = new VerticalLayout(); fieldsLayout.setSpacing(true); fieldsLayout.setPadding(false); @@ -66,9 +66,7 @@ public class AdminPricetableView extends VerticalLayout { private void savePriceTable() { try { // Get first entry or create new one - PriceTable priceTable = priceTableRepository.findAll().stream() - .findFirst() - .orElse(new PriceTable()); + PriceTable priceTable = priceTableRepository.findAll().stream().findFirst().orElse(new PriceTable()); priceTable.setMonthlyBasePackage(monthlyBasePackage.getValue()); priceTable.setAppUsageLicense(appUsageLicense.getValue()); @@ -83,17 +81,19 @@ public class AdminPricetableView extends VerticalLayout { private void loadPriceTable() { try { - PriceTable priceTable = priceTableRepository.findAll().stream() - .findFirst() - .orElse(null); + PriceTable priceTable = priceTableRepository.findAll().stream().findFirst().orElse(null); if (priceTable != null) { - monthlyBasePackage.setValue(priceTable.getMonthlyBasePackage() != null ? priceTable.getMonthlyBasePackage() : ""); - appUsageLicense.setValue(priceTable.getAppUsageLicense() != null ? priceTable.getAppUsageLicense() : ""); - revenueParticipation.setValue(priceTable.getRevenueParticipation() != null ? priceTable.getRevenueParticipation() : ""); + monthlyBasePackage + .setValue(priceTable.getMonthlyBasePackage() != null ? priceTable.getMonthlyBasePackage() : ""); + appUsageLicense + .setValue(priceTable.getAppUsageLicense() != null ? priceTable.getAppUsageLicense() : ""); + revenueParticipation.setValue( + priceTable.getRevenueParticipation() != null ? priceTable.getRevenueParticipation() : ""); } } catch (Exception ex) { - Notification.show("Fehler beim Laden der Daten: " + ex.getMessage(), 5000, Notification.Position.BOTTOM_CENTER); + Notification.show("Fehler beim Laden der Daten: " + ex.getMessage(), 5000, + Notification.Position.BOTTOM_CENTER); } } } diff --git a/src/main/java/de/assecutor/votianlt/pages/view/AuthenticatedStartView.java b/src/main/java/de/assecutor/votianlt/pages/view/AuthenticatedStartView.java index d3f6852..83379db 100644 --- a/src/main/java/de/assecutor/votianlt/pages/view/AuthenticatedStartView.java +++ b/src/main/java/de/assecutor/votianlt/pages/view/AuthenticatedStartView.java @@ -15,7 +15,7 @@ import jakarta.annotation.security.RolesAllowed; @Route(value = "dashboard", layout = MainLayout.class) @PageTitle("VotianLT - Dashboard") -@RolesAllowed({"USER"}) +@RolesAllowed({ "USER" }) public class AuthenticatedStartView extends VerticalLayout { private final SecurityService securityService; diff --git a/src/main/java/de/assecutor/votianlt/pages/view/EditAppUserView.java b/src/main/java/de/assecutor/votianlt/pages/view/EditAppUserView.java index 724b34d..2048be2 100644 --- a/src/main/java/de/assecutor/votianlt/pages/view/EditAppUserView.java +++ b/src/main/java/de/assecutor/votianlt/pages/view/EditAppUserView.java @@ -24,7 +24,6 @@ import jakarta.annotation.security.RolesAllowed; import org.bson.types.ObjectId; import org.springframework.beans.factory.annotation.Autowired; - @PageTitle("App-Nutzer bearbeiten") @Route(value = "edit-app-user", layout = de.assecutor.votianlt.pages.base.ui.view.MainLayout.class) @RolesAllowed({ "USER", "ADMIN" }) @@ -194,7 +193,6 @@ public class EditAppUserView extends VerticalLayout implements HasUrlParameter user.setDiffInvoiceAddress(Boolean.TRUE.equals(value))); binder.forField(invCompanyField).bind(User::getInvCompany, User::setInvCompany); binder.forField(invCompanyAddField).bind(User::getInvCompanyAddition, User::setInvCompanyAddition); @@ -364,16 +365,14 @@ public class EditProfileView extends HorizontalLayout { Span digitalProcessInfo = new Span("Aktiviert die digitale Auftragsabwicklung über die mobile App"); digitalProcessInfo.getStyle().set("font-size", "var(--lumo-font-size-s)") - .set("color", "var(--lumo-secondary-text-color)") - .set("margin-left", "var(--lumo-space-xl)"); + .set("color", "var(--lumo-secondary-text-color)").set("margin-left", "var(--lumo-space-xl)"); Checkbox locateAppUser = new Checkbox("App-Nutzer orten"); locateAppUser.setValue(currentUser.isLocationTrackingEnabled()); Span locateAppUserInfo = new Span("Ermöglicht die Ortung von App-Nutzern während der Auftragsausführung"); locateAppUserInfo.getStyle().set("font-size", "var(--lumo-font-size-s)") - .set("color", "var(--lumo-secondary-text-color)") - .set("margin-left", "var(--lumo-space-xl)"); + .set("color", "var(--lumo-secondary-text-color)").set("margin-left", "var(--lumo-space-xl)"); // Save checkbox states when changed digitalProcess.addValueChangeListener(e -> { @@ -403,8 +402,7 @@ public class EditProfileView extends HorizontalLayout { Span twoFactorDescription = new Span("Bei Aktivierung wird bei jeder Anmeldung ein Code per E-Mail gesendet"); twoFactorDescription.getStyle().set("font-size", "var(--lumo-font-size-s)") - .set("color", "var(--lumo-secondary-text-color)") - .set("margin-left", "var(--lumo-space-xl)"); + .set("color", "var(--lumo-secondary-text-color)").set("margin-left", "var(--lumo-space-xl)"); securityTab.add(twoFactorLayout, twoFactorDescription); @@ -426,10 +424,11 @@ public class EditProfileView extends HorizontalLayout { saveProfile.addClickListener(e -> { // Validate all required fields first boolean isValid = validateAllProfileFields(companyField, firstnameField, lastnameField, phoneField, - emailField, streetField, houseNumberField, zipField, cityField); + emailField, streetField, houseNumberField, zipField, cityField); if (!isValid) { - Notification.show("Bitte füllen Sie alle Pflichtfelder korrekt aus", 3000, Notification.Position.MIDDLE); + Notification.show("Bitte füllen Sie alle Pflichtfelder korrekt aus", 3000, + Notification.Position.MIDDLE); return; } @@ -571,21 +570,18 @@ public class EditProfileView extends HorizontalLayout { List items = new ArrayList<>(); BigDecimal vatRate = parseVatRate(safe(taxRateField)); - CustomerInvoiceItem item1 = new CustomerInvoiceItem( - new BigDecimal("1"), "Stk.", "Beispiel-Dienstleistung 1", - new BigDecimal("100.00"), vatRate); - CustomerInvoiceItem item2 = new CustomerInvoiceItem( - new BigDecimal("2"), "Std.", "Beispiel-Dienstleistung 2", - new BigDecimal("50.00"), vatRate); + CustomerInvoiceItem item1 = new CustomerInvoiceItem(new BigDecimal("1"), "Stk.", + "Beispiel-Dienstleistung 1", new BigDecimal("100.00"), vatRate); + CustomerInvoiceItem item2 = new CustomerInvoiceItem(new BigDecimal("2"), "Std.", + "Beispiel-Dienstleistung 2", new BigDecimal("50.00"), vatRate); items.add(item1); items.add(item2); invoiceData.setItems(items); // Calculate amounts - BigDecimal netAmount = items.stream() - .map(CustomerInvoiceItem::getNetTotal) - .reduce(BigDecimal.ZERO, BigDecimal::add); + BigDecimal netAmount = items.stream().map(CustomerInvoiceItem::getNetTotal).reduce(BigDecimal.ZERO, + BigDecimal::add); BigDecimal vatAmount = netAmount.multiply(vatRate); BigDecimal totalAmount = netAmount.add(vatAmount); @@ -664,18 +660,9 @@ public class EditProfileView extends HorizontalLayout { } private void saveInvoiceData() { - currentInvoiceData = userInvoiceDataService.createOrUpdate( - currentUser.getId(), - billingEnabled.getValue(), - prefixField.getValue(), - ustIdField.getValue(), - taxNumberField.getValue(), - bankNameField.getValue(), - ibanField.getValue(), - taxRateField.getValue(), - introTextArea.getValue(), - termsTextArea.getValue() - ); + currentInvoiceData = userInvoiceDataService.createOrUpdate(currentUser.getId(), billingEnabled.getValue(), + prefixField.getValue(), ustIdField.getValue(), taxNumberField.getValue(), bankNameField.getValue(), + ibanField.getValue(), taxRateField.getValue(), introTextArea.getValue(), termsTextArea.getValue()); } private String safe(String value) { @@ -708,8 +695,8 @@ public class EditProfileView extends HorizontalLayout { } private boolean validateAllProfileFields(TextField companyField, TextField firstnameField, TextField lastnameField, - TextField phoneField, EmailField emailField, TextField streetField, - TextField houseNumberField, TextField zipField, TextField cityField) { + TextField phoneField, EmailField emailField, TextField streetField, TextField houseNumberField, + TextField zipField, TextField cityField) { validateField(companyField, "Firma ist ein Pflichtfeld"); validateField(firstnameField, "Vorname ist ein Pflichtfeld"); validateField(lastnameField, "Nachname ist ein Pflichtfeld"); diff --git a/src/main/java/de/assecutor/votianlt/pages/view/InvoicesView.java b/src/main/java/de/assecutor/votianlt/pages/view/InvoicesView.java index c7ebe5b..ecbbaf9 100644 --- a/src/main/java/de/assecutor/votianlt/pages/view/InvoicesView.java +++ b/src/main/java/de/assecutor/votianlt/pages/view/InvoicesView.java @@ -12,12 +12,12 @@ import de.assecutor.votianlt.model.invoices.SystemInvoice; import de.assecutor.votianlt.model.invoices.SystemInvoiceData; import de.assecutor.votianlt.model.invoices.SystemInvoiceItem; import de.assecutor.votianlt.service.SystemInvoiceService; +import de.assecutor.votianlt.util.DateTimeFormatUtil; import jakarta.annotation.security.RolesAllowed; import java.io.ByteArrayInputStream; import java.text.NumberFormat; import java.time.LocalDate; -import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.List; import java.util.Locale; @@ -94,12 +94,11 @@ public class InvoicesView extends VerticalLayout { } private byte[] generateSystemInvoicePdf(SystemInvoice systemInvoice) throws Exception { - DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("dd.MM.yyyy"); NumberFormat CURRENCY_FMT = NumberFormat.getCurrencyInstance(Locale.GERMANY); SystemInvoiceData data = new SystemInvoiceData(); data.setInvoiceNumber(systemInvoice.getId()); - data.setInvoiceDate(DATE_FMT.format(systemInvoice.getDatum())); + data.setInvoiceDate(DateTimeFormatUtil.formatDate(systemInvoice.getDatum())); data.setInvoiceText(systemInvoice.getBeschreibung()); // Empfänger aus der Zeile (nur Name in den Testdaten vorhanden) diff --git a/src/main/java/de/assecutor/votianlt/pages/view/JobHistoryView.java b/src/main/java/de/assecutor/votianlt/pages/view/JobHistoryView.java index 20c3bbd..45bd133 100644 --- a/src/main/java/de/assecutor/votianlt/pages/view/JobHistoryView.java +++ b/src/main/java/de/assecutor/votianlt/pages/view/JobHistoryView.java @@ -26,6 +26,7 @@ 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 de.assecutor.votianlt.util.DateTimeFormatUtil; import jakarta.annotation.security.RolesAllowed; import lombok.extern.slf4j.Slf4j; import org.bson.types.ObjectId; @@ -294,9 +295,7 @@ public class JobHistoryView extends Main implements HasUrlParameter { if (dateTime == null) return ""; try { - java.time.format.DateTimeFormatter formatter = java.time.format.DateTimeFormatter - .ofPattern("dd.MM.yyyy HH:mm"); - return dateTime.format(formatter); + return DateTimeFormatUtil.formatDateTime(dateTime); } catch (Exception e) { return dateTime.toString(); } 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 61cda53..42870c6 100644 --- a/src/main/java/de/assecutor/votianlt/pages/view/JobSummaryView.java +++ b/src/main/java/de/assecutor/votianlt/pages/view/JobSummaryView.java @@ -42,12 +42,18 @@ import de.assecutor.votianlt.model.Signature; import de.assecutor.votianlt.model.Barcode; import de.assecutor.votianlt.model.Photo; 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.MessageService; +import de.assecutor.votianlt.util.DateTimeFormatUtil; +import com.vaadin.flow.component.confirmdialog.ConfirmDialog; import jakarta.annotation.security.RolesAllowed; import org.bson.types.ObjectId; import org.springframework.beans.factory.annotation.Value; +import java.time.LocalDateTime; + import java.util.ArrayList; import java.util.List; import java.util.Locale; @@ -66,6 +72,7 @@ public class JobSummaryView extends Main implements HasUrlParameter { 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; @@ -76,7 +83,7 @@ public class JobSummaryView extends Main implements HasUrlParameter { public JobSummaryView(JobRepository jobRepository, CargoItemRepository cargoItemRepository, TaskRepository taskRepository, SignatureRepository signatureRepository, BarcodeRepository barcodeRepository, PhotoRepository photoRepository, CommentRepository commentRepository, AppUserService appUserService, - MessageService messageService) { + MessageService messageService, JobHistoryService jobHistoryService) { this.jobRepository = jobRepository; this.cargoItemRepository = cargoItemRepository; this.taskRepository = taskRepository; @@ -85,6 +92,7 @@ public class JobSummaryView extends Main implements HasUrlParameter { this.photoRepository = photoRepository; this.commentRepository = commentRepository; this.appUserService = appUserService; + this.jobHistoryService = jobHistoryService; setSizeFull(); addClassNames(LumoUtility.BoxSizing.BORDER, LumoUtility.Display.FLEX, LumoUtility.FlexDirection.COLUMN, @@ -132,17 +140,14 @@ public class JobSummaryView extends Main implements HasUrlParameter { 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); + 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; @@ -270,6 +275,51 @@ public class JobSummaryView extends Main implements HasUrlParameter { // 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() { @@ -284,9 +334,7 @@ public class JobSummaryView extends Main implements HasUrlParameter { private String formatLocalDate(java.time.LocalDate date) { try { - java.time.format.DateTimeFormatter fmt = java.time.format.DateTimeFormatter.ofPattern("dd.MM.yyyy") - .withLocale(Locale.GERMANY); - return date.format(fmt); + return DateTimeFormatUtil.formatDate(date); } catch (Exception e) { return ""; } @@ -406,8 +454,9 @@ public class JobSummaryView extends Main implements HasUrlParameter { + " }" + " });" + " 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(); }" + "})();"); + + " 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()); } @@ -636,13 +685,9 @@ public class JobSummaryView extends Main implements HasUrlParameter { 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") + 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()); @@ -658,13 +703,7 @@ public class JobSummaryView extends Main implements HasUrlParameter { } private String formatDateTime(java.time.LocalDateTime dateTime) { - try { - java.time.format.DateTimeFormatter fmt = java.time.format.DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm") - .withLocale(Locale.GERMANY); - return dateTime.format(fmt); - } catch (Exception e) { - return dateTime.toString(); - } + return DateTimeFormatUtil.formatDateTime(dateTime); } private Div createTaskCard(BaseTask task, String displayName) { diff --git a/src/main/java/de/assecutor/votianlt/pages/view/LoginView.java b/src/main/java/de/assecutor/votianlt/pages/view/LoginView.java index 0735fb8..9749366 100644 --- a/src/main/java/de/assecutor/votianlt/pages/view/LoginView.java +++ b/src/main/java/de/assecutor/votianlt/pages/view/LoginView.java @@ -93,8 +93,7 @@ public class LoginView extends VerticalLayout implements BeforeEnterObserver, Af // Version display - will be set in @PostConstruct versionSpan = new Span(""); versionSpan.getStyle().set("color", "var(--lumo-secondary-text-color)") - .set("font-size", "var(--lumo-font-size-s)") - .set("margin-top", "var(--lumo-space-m)"); + .set("font-size", "var(--lumo-font-size-s)").set("margin-top", "var(--lumo-space-m)"); // Inline flash message box (hidden by default) flashBox.getStyle().set("background", "var(--lumo-error-color-10pct)") @@ -139,8 +138,7 @@ public class LoginView extends VerticalLayout implements BeforeEnterObserver, Af .authenticate(new UsernamePasswordAuthenticationToken(username, password)); // Prüfe ob 2FA für diesen Nutzer aktiviert ist (global UND nutzer-spezifisch) - boolean userTwoFactorEnabled = userRepository.findByEmail(username) - .map(User::isTwoFactorEnabled) + boolean userTwoFactorEnabled = userRepository.findByEmail(username).map(User::isTwoFactorEnabled) .orElse(true); // Standardmäßig aktiviert falls Nutzer nicht gefunden if (twoFactorEnabledGlobal && userTwoFactorEnabled) { diff --git a/src/main/java/de/assecutor/votianlt/pages/view/MessageDetailsView.java b/src/main/java/de/assecutor/votianlt/pages/view/MessageDetailsView.java index 506027c..1810c83 100644 --- a/src/main/java/de/assecutor/votianlt/pages/view/MessageDetailsView.java +++ b/src/main/java/de/assecutor/votianlt/pages/view/MessageDetailsView.java @@ -37,6 +37,7 @@ import de.assecutor.votianlt.model.MessageType; import de.assecutor.votianlt.pages.service.AppUserService; import de.assecutor.votianlt.service.MessageBroadcaster; import de.assecutor.votianlt.service.MessageService; +import de.assecutor.votianlt.util.DateTimeFormatUtil; import de.assecutor.votianlt.event.MessageReadStatusChangedEvent; import org.springframework.context.ApplicationEventPublisher; import jakarta.annotation.security.RolesAllowed; @@ -58,7 +59,6 @@ import javax.imageio.ImageWriter; import javax.imageio.stream.ImageOutputStream; import java.time.LocalDate; import java.time.LocalDateTime; -import java.time.format.DateTimeFormatter; import java.util.Base64; import java.util.List; import java.util.Objects; @@ -89,15 +89,12 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver { private List currentMessages; // Current messages being displayed for redrawing private Registration broadcasterRegistration; // Track listener registration private TextArea messageInput; - private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("HH:mm"); - private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("dd.MM.yyyy"); private static final int MAX_IMAGE_FILE_SIZE = 32 * 1024 * 1024; // 32 MB aligns with Spring settings private static final int TARGET_IMAGE_WIDTH = 1920; private static final float JPEG_COMPRESSION_QUALITY = 0.8f; public MessageDetailsView(AppUserService appUserService, MessageService messageService, - MessageBroadcaster messageBroadcaster, - ApplicationEventPublisher eventPublisher) { + MessageBroadcaster messageBroadcaster, ApplicationEventPublisher eventPublisher) { this.appUserService = appUserService; this.messageService = messageService; this.messageBroadcaster = messageBroadcaster; @@ -128,7 +125,8 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver { log.info("MessageDetailsView - participant: {}, conversationId: {}", participantKey, conversationId); if (participantKey == null || conversationId == null) { - log.warn("Missing required route parameters: participantKey={}, conversationId={}", participantKey, conversationId); + log.warn("Missing required route parameters: participantKey={}, conversationId={}", participantKey, + conversationId); event.rerouteToError(IllegalArgumentException.class, "Missing required parameters"); return; } @@ -175,10 +173,8 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver { messagesContainer.setPadding(false); messagesContainer.setSpacing(false); messagesContainer.setWidthFull(); - messagesContainer.getStyle() - .set("background-color", "#f0f0f0") - .set("border-radius", "8px") - .set("padding", "15px"); + messagesContainer.getStyle().set("background-color", "#f0f0f0").set("border-radius", "8px").set("padding", + "15px"); // Wrap messages container in scroller for vertical scrolling messagesScroller = new Scroller(messagesContainer); @@ -229,14 +225,15 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver { messagesContainer.add(createMessageBubble(message, timestamp)); } - // After rendering, mark any unread messages directed to the current user as read + // After rendering, mark any unread messages directed to the current user as + // read markVisibleMessagesAsRead(); } /** - * Marks all currently visible messages that are addressed to the logged-in user as read. - * This is triggered after (re)rendering the conversation and will also update the in-memory - * message objects to keep UI state consistent. + * Marks all currently visible messages that are addressed to the logged-in user + * as read. This is triggered after (re)rendering the conversation and will also + * update the in-memory message objects to keep UI state consistent. */ private void markVisibleMessagesAsRead() { try { @@ -278,27 +275,19 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver { upload.setWidthFull(); Span helper = new Span("Unterstützte Formate: PNG, JPG, GIF, WebP (max. 32 MB)"); - helper.getStyle() - .set("font-size", "12px") - .set("color", "#666666"); + helper.getStyle().set("font-size", "12px").set("color", "#666666"); Image preview = new Image(); preview.setAlt("Vorschau des ausgewählten Bildes"); preview.setVisible(false); preview.setWidth(null); preview.setHeight(null); - preview.getStyle() - .set("max-width", "100%") - .set("max-height", "320px") - .set("height", "auto") - .set("border-radius", "12px") - .set("display", "inline-block"); + preview.getStyle().set("max-width", "100%").set("max-height", "320px").set("height", "auto") + .set("border-radius", "12px").set("display", "inline-block"); Div previewWrapper = new Div(preview); previewWrapper.setWidthFull(); - previewWrapper.getStyle() - .set("text-align", "center") - .set("margin-top", "10px"); + previewWrapper.getStyle().set("text-align", "center").set("margin-top", "10px"); AtomicReference base64Ref = new AtomicReference<>(); @@ -325,8 +314,8 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver { base64Ref.set(null); preview.setVisible(false); confirmButton.setEnabled(false); - Notification.show("Das Bild konnte nicht verarbeitet werden.", 3000, - Notification.Position.MIDDLE).addThemeVariants(NotificationVariant.LUMO_ERROR); + Notification.show("Das Bild konnte nicht verarbeitet werden.", 3000, Notification.Position.MIDDLE) + .addThemeVariants(NotificationVariant.LUMO_ERROR); return; } @@ -400,22 +389,14 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver { */ private Div createDateSeparator(LocalDate date) { Div separator = new Div(); - separator.getStyle() - .set("display", "flex") - .set("justify-content", "center") - .set("text-align", "center") - .set("margin", "20px 0"); + separator.getStyle().set("display", "flex").set("justify-content", "center").set("text-align", "center") + .set("margin", "20px 0"); separator.setWidthFull(); - Span dateLabel = new Span(date.format(DATE_FORMATTER)); - dateLabel.getStyle() - .set("background-color", "#d0d0d0") - .set("padding", "4px 10px") - .set("border-radius", "12px") - .set("font-size", "12px") - .set("font-weight", "500") - .set("color", "#333333") - .set("display", "inline-block"); + Span dateLabel = new Span(DateTimeFormatUtil.formatDate(date)); + dateLabel.getStyle().set("background-color", "#d0d0d0").set("padding", "4px 10px").set("border-radius", "12px") + .set("font-size", "12px").set("font-weight", "500").set("color", "#333333") + .set("display", "inline-block"); separator.add(dateLabel); return separator; @@ -426,41 +407,31 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver { */ private Div createMessageBubble(Message message, LocalDateTime timestamp) { // Determine alignment based on message origin - // CLIENT origin = client messages (left), SERVER origin = server messages (right) + // CLIENT origin = client messages (left), SERVER origin = server messages + // (right) boolean isServerMessage = message.getOrigin() == MessageOrigin.SERVER; // Container for the message (aligns left or right) Div messageWrapper = new Div(); String alignment = isServerMessage ? "right" : "left"; - messageWrapper.getStyle() - .set("display", "flex") - .set("justify-content", isServerMessage ? "flex-end" : "flex-start") - .set("margin", "5px 0") - .set("width", "100%"); + messageWrapper.getStyle().set("display", "flex") + .set("justify-content", isServerMessage ? "flex-end" : "flex-start").set("margin", "5px 0") + .set("width", "100%"); // Message bubble Div bubble = new Div(); - bubble.getStyle() - .set("background-color", isServerMessage ? "#dcf8c6" : "#ffffff") - .set("padding", "10px 15px") - .set("border-radius", "18px") - .set("max-width", "70%") - .set("box-shadow", "0 1px 2px rgba(0,0,0,0.1)") - .set("word-wrap", "break-word") - .set("white-space", "pre-wrap") - .set("text-align", alignment); + bubble.getStyle().set("background-color", isServerMessage ? "#dcf8c6" : "#ffffff").set("padding", "10px 15px") + .set("border-radius", "18px").set("max-width", "70%").set("box-shadow", "0 1px 2px rgba(0,0,0,0.1)") + .set("word-wrap", "break-word").set("white-space", "pre-wrap").set("text-align", alignment); // Message content component (text or media) Component contentComponent = createContentComponent(message, alignment); // Timestamp - Span timeSpan = new Span(timestamp.format(TIME_FORMATTER)); - timeSpan.getStyle() - .set("font-size", "11px") - .set("color", isServerMessage ? "#666666" : "#999999") - .set("display", "block") - .set("text-align", alignment); + Span timeSpan = new Span(DateTimeFormatUtil.formatTime(timestamp)); + timeSpan.getStyle().set("font-size", "11px").set("color", isServerMessage ? "#666666" : "#999999") + .set("display", "block").set("text-align", alignment); bubble.add(contentComponent, timeSpan); messageWrapper.add(bubble); @@ -471,30 +442,24 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver { private Component createContentComponent(Message message, String alignment) { MessageContentType contentType = message.getContentType(); return switch (contentType) { - case IMAGE -> createImageContent(message.getContent(), alignment); - case TEXT -> createTextContent(message.getContent(), alignment); + case IMAGE -> createImageContent(message.getContent(), alignment); + case TEXT -> createTextContent(message.getContent(), alignment); }; } private Component createTextContent(String contentValue, String alignment) { Div contentDiv = new Div(); - String content = Optional.ofNullable(contentValue).filter(value -> !value.isBlank()) - .orElse("(kein Inhalt)"); + String content = Optional.ofNullable(contentValue).filter(value -> !value.isBlank()).orElse("(kein Inhalt)"); contentDiv.setText(content); - contentDiv.getStyle() - .set("font-size", "14px") - .set("color", "#000000") - .set("margin-bottom", "5px") - .set("text-align", alignment); + contentDiv.getStyle().set("font-size", "14px").set("color", "#000000").set("margin-bottom", "5px") + .set("text-align", alignment); return contentDiv; } private Component createImageContent(String base64Value, String alignment) { Div wrapper = new Div(); - wrapper.getStyle() - .set("margin-bottom", "5px") - .set("display", "flex") - .set("justify-content", "right".equals(alignment) ? "flex-end" : "flex-start"); + wrapper.getStyle().set("margin-bottom", "5px").set("display", "flex").set("justify-content", + "right".equals(alignment) ? "flex-end" : "flex-start"); String trimmed = Optional.ofNullable(base64Value).map(String::trim).orElse(""); if (trimmed.isEmpty()) { @@ -509,12 +474,8 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver { } Image image = new Image(dataUrl, "Nachrichtenbild"); - image.getStyle() - .set("max-width", "100%") - .set("border-radius", "12px") - .set("display", "block") - .set("max-height", "320px") - .set("height", "auto"); + image.getStyle().set("max-width", "100%").set("border-radius", "12px").set("display", "block") + .set("max-height", "320px").set("height", "auto"); image.getElement().setAttribute("loading", "lazy"); wrapper.add(image); @@ -676,18 +637,12 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver { i18n.setError(error); UploadI18N.Uploading uploading = new UploadI18N.Uploading(); - uploading.setStatus(new UploadI18N.Uploading.Status() - .setConnecting("Verbindung wird aufgebaut...") - .setStalled("Upload pausiert") - .setProcessing("Verarbeitung...") - .setHeld("Warten auf Upload...")); - uploading.setRemainingTime(new UploadI18N.Uploading.RemainingTime() - .setPrefix("Verbleibende Zeit: ") + uploading.setStatus(new UploadI18N.Uploading.Status().setConnecting("Verbindung wird aufgebaut...") + .setStalled("Upload pausiert").setProcessing("Verarbeitung...").setHeld("Warten auf Upload...")); + uploading.setRemainingTime(new UploadI18N.Uploading.RemainingTime().setPrefix("Verbleibende Zeit: ") .setUnknown("Verbleibende Zeit unbekannt")); - uploading.setError(new UploadI18N.Uploading.Error() - .setServerUnavailable("Server nicht erreichbar") - .setUnexpectedServerError("Unerwarteter Serverfehler") - .setForbidden("Upload nicht erlaubt")); + uploading.setError(new UploadI18N.Uploading.Error().setServerUnavailable("Server nicht erreichbar") + .setUnexpectedServerError("Unerwarteter Serverfehler").setForbidden("Upload nicht erlaubt")); i18n.setUploading(uploading); return i18n; @@ -704,9 +659,7 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver { if (scrollAnchor == null) { scrollAnchor = new Div(); scrollAnchor.setId("scroll-anchor"); - scrollAnchor.getStyle() - .set("height", "1px") - .set("width", "100%"); + scrollAnchor.getStyle().set("height", "1px").set("width", "100%"); } if (scrollAnchor.getParent().isEmpty()) { @@ -748,7 +701,6 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver { return layout; } - private HorizontalLayout createMessageInputArea() { messageInput = new TextArea(); messageInput.setPlaceholder("Nachricht eingeben..."); @@ -803,12 +755,11 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver { Message saved; if (jobConversation) { // participantKey = AppUser ID (receiver) - saved = messageService.sendJobMessageToClient(payload, participantKey, - contentType, jobIdContext, jobNumberContext); + saved = messageService.sendJobMessageToClient(payload, participantKey, contentType, jobIdContext, + jobNumberContext); } else { // participantKey = AppUser ID (receiver) - saved = messageService.sendGeneralMessageToClient(payload, participantKey, - contentType); + saved = messageService.sendGeneralMessageToClient(payload, participantKey, contentType); } // Mark own outgoing message as read immediately @@ -831,8 +782,7 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver { } catch (Exception ex) { log.error("Failed to send message to {}: {}", participantKey, ex.getMessage(), ex); Notification.show("Nachricht konnte nicht gesendet werden: " + ex.getMessage(), 4000, - Notification.Position.MIDDLE) - .addThemeVariants(NotificationVariant.LUMO_ERROR); + Notification.Position.MIDDLE).addThemeVariants(NotificationVariant.LUMO_ERROR); } } @@ -846,8 +796,7 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver { } catch (IllegalArgumentException ex) { return appUserService.findAll().stream() .filter(user -> participantKey.equals(user.getEmail()) || participantKey.equals(user.getAppCode())) - .findFirst() - .orElse(null); + .findFirst().orElse(null); } } @@ -856,14 +805,15 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver { return List.of(); } if ("general".equalsIgnoreCase(conversationId)) { - return messages.stream() - .filter(msg -> Optional.ofNullable(msg.getMessageType()).orElse(MessageType.GENERAL) == MessageType.GENERAL) + return messages.stream().filter( + msg -> Optional.ofNullable(msg.getMessageType()).orElse(MessageType.GENERAL) == MessageType.GENERAL) .collect(Collectors.toList()); } if (conversationId.startsWith("job-")) { String token = conversationId.substring(4); return messages.stream() - .filter(msg -> Optional.ofNullable(msg.getMessageType()).orElse(MessageType.GENERAL) == MessageType.JOB_RELATED + .filter(msg -> Optional.ofNullable(msg.getMessageType()) + .orElse(MessageType.GENERAL) == MessageType.JOB_RELATED && matchesJobConversation(msg, token)) .collect(Collectors.toList()); } @@ -879,8 +829,7 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver { String jobId = Optional.ofNullable(message.getJobIdAsString()).orElse(""); return sanitize(jobNumber).equalsIgnoreCase(normalizedToken) - || sanitize(jobId).equalsIgnoreCase(normalizedToken) - || jobNumber.equalsIgnoreCase(token) + || sanitize(jobId).equalsIgnoreCase(normalizedToken) || jobNumber.equalsIgnoreCase(token) || jobId.equalsIgnoreCase(token); } @@ -912,11 +861,8 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver { } if (messages != null && (jobNumberContext == null || jobNumberContext.isBlank())) { - jobNumberContext = messages.stream() - .map(Message::getJobNumber) - .filter(value -> value != null && !value.isBlank()) - .findFirst() - .orElse(jobNumberContext); + jobNumberContext = messages.stream().map(Message::getJobNumber) + .filter(value -> value != null && !value.isBlank()).findFirst().orElse(jobNumberContext); } if (jobIdContext == null || jobNumberContext == null || jobNumberContext.isBlank()) { @@ -927,8 +873,7 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver { if (jobOptional.isPresent()) { Job job = jobOptional.get(); jobIdContext = Optional.ofNullable(job.getId()).orElse(jobIdContext); - jobNumberContext = Optional.ofNullable(job.getJobNumber()) - .filter(value -> !value.isBlank()) + jobNumberContext = Optional.ofNullable(job.getJobNumber()).filter(value -> !value.isBlank()) .orElse(jobNumberContext); } } @@ -955,11 +900,13 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver { if (conversationId.startsWith("job-")) { if (messages != null && !messages.isEmpty()) { for (Message message : messages) { - String jobNumber = Optional.ofNullable(message.getJobNumber()).filter(s -> !s.isBlank()).orElse(null); + String jobNumber = Optional.ofNullable(message.getJobNumber()).filter(s -> !s.isBlank()) + .orElse(null); if (jobNumber != null) { return "Auftrag " + jobNumber; } - String jobId = Optional.ofNullable(message.getJobIdAsString()).filter(s -> !s.isBlank()).orElse(null); + String jobId = Optional.ofNullable(message.getJobIdAsString()).filter(s -> !s.isBlank()) + .orElse(null); if (jobId != null) { return "Auftrag " + jobId; } @@ -975,8 +922,8 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver { } /** - * Called when the view is attached to the UI - * Registers listener for incoming messages + * Called when the view is attached to the UI Registers listener for incoming + * messages */ @Override protected void onAttach(AttachEvent attachEvent) { @@ -992,8 +939,8 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver { } /** - * Called when the view is detached from the UI - * Unregisters listener to prevent memory leaks + * Called when the view is detached from the UI Unregisters listener to prevent + * memory leaks */ @Override protected void onDetach(DetachEvent detachEvent) { @@ -1006,8 +953,8 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver { } /** - * Handle incoming message broadcast - * Filters messages to only show those belonging to the current conversation + * Handle incoming message broadcast Filters messages to only show those + * belonging to the current conversation */ private void handleIncomingMessage(UI ui, Message message) { if (message == null || participantKey == null || conversationId == null) { @@ -1057,6 +1004,12 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver { ensureScrollAnchor(); scrollToBottom(); + // Play notification sound and show browser notification for incoming client messages + if (message.getOrigin() == MessageOrigin.CLIENT) { + playNotificationSound(ui); + showBrowserNotification(ui, message); + } + log.info("Messages re-rendered with new message"); } } catch (Exception e) { @@ -1064,4 +1017,62 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver { } }); } + + /** + * Play a notification sound when a new message arrives + */ + private void playNotificationSound(UI ui) { + ui.getPage().executeJs( + "const audio = new Audio('data:audio/wav;base64,UklGRnoGAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQoGAACBhYqFbF1fdJivrJBhNjVgodDbq2EcBj+a2/LDciUFLIHO8tiJNwgZaLvt559NEAxQp+PwtmMcBjiR1/LMeSwFJHfH8N2QQAoUXrTp66hVFApGn+DyvmwhBTGH0fPTgjMGHm7A7+OZSA0PVK3m8LRjHAU7k9nzyn0tBSd5ye/glEIKEl206O2oVhQLSKHi8r5uIgU0idT01IMzByBwwvHjmEgNDlWs5PCzYhsFO5TY88p+Kwcme8jw4JVCChNdt+jvp1QVDEih4vK+bSIGNIrV9dODMggib8Lx5JdIDQ9VrObws2IbBT6U2PXKfi0IJnzH8OCVQgoVXbfp76dVFQ5IouLyvW0jCDSL1fTSgTQJJG7C8eSWSA8RVq3m8LJgGwg/lNj0yn4tCSV7x+/glUILFl237++nVhYOSKPi8rxtIwo0i9X00oE1CiNuwvDklkkREVat5u+yXxwJP5PY9Ml+Lgoge8fv4JVCDBVct+7vqFYYEUij4vG8bSQKNIvV89GBNQshbcLw5JZJERFV\u003d\u003d'); audio.play().catch(err => console.log('Audio play failed:', err));"); + } + + /** + * Show a browser notification when a new message arrives + */ + private void showBrowserNotification(UI ui, Message message) { + String senderName = resolveAppUserName(); + String preview = resolveNotificationPreview(message); + String title = senderName != null ? senderName : "Neue Nachricht"; + + ui.getPage().executeJs( + "if (!('Notification' in window)) {" + " console.log('Browser does not support notifications');" + + "} else if (Notification.permission === 'granted') {" + " new Notification($0, { body: $1 });" + + "} else if (Notification.permission !== 'denied') {" + " Notification.requestPermission().then(permission => {" + + " if (permission === 'granted') {" + " new Notification($0, { body: $1 });" + " }" + " });" + "}", + title, preview); + } + + /** + * Resolve the AppUser name for the current conversation + */ + private String resolveAppUserName() { + if (participantKey == null) { + return null; + } + try { + ObjectId appUserId = new ObjectId(participantKey); + AppUser appUser = appUserService.findById(appUserId); + return appUser != null ? appUser.getBezeichnung() : null; + } catch (Exception e) { + return null; + } + } + + /** + * Resolve a short preview text for the notification + */ + private String resolveNotificationPreview(Message message) { + if (message == null) { + return ""; + } + if (message.getContentType() == MessageContentType.IMAGE) { + return "📷 Bild"; + } + String content = message.getContent(); + if (content == null || content.isBlank()) { + return "(kein Inhalt)"; + } + // Limit preview to 100 characters + return content.length() > 100 ? content.substring(0, 97) + "..." : content; + } } diff --git a/src/main/java/de/assecutor/votianlt/pages/view/MessagesView.java b/src/main/java/de/assecutor/votianlt/pages/view/MessagesView.java index b095119..e70fe7d 100644 --- a/src/main/java/de/assecutor/votianlt/pages/view/MessagesView.java +++ b/src/main/java/de/assecutor/votianlt/pages/view/MessagesView.java @@ -21,11 +21,13 @@ import de.assecutor.votianlt.model.Message; import de.assecutor.votianlt.model.MessageContentType; import de.assecutor.votianlt.model.MessageOrigin; import de.assecutor.votianlt.pages.service.AppUserService; +import de.assecutor.votianlt.service.MessageBroadcaster; import de.assecutor.votianlt.service.MessageService; +import de.assecutor.votianlt.util.DateTimeFormatUtil; import jakarta.annotation.security.RolesAllowed; import lombok.extern.slf4j.Slf4j; +import org.bson.types.ObjectId; -import java.time.format.DateTimeFormatter; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.Comparator; @@ -47,15 +49,19 @@ public class MessagesView extends Main { private final MessageService messageService; private final AppUserService appUserService; - + private final MessageBroadcaster messageBroadcaster; + private Grid clientGrid; - private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm"); private final AtomicBoolean loading = new AtomicBoolean(false); private Registration pollRegistration; + private Registration broadcasterRegistration; + private int lastMessageCount = 0; - public MessagesView(MessageService messageService, AppUserService appUserService) { + public MessagesView(MessageService messageService, AppUserService appUserService, + MessageBroadcaster messageBroadcaster) { this.messageService = messageService; this.appUserService = appUserService; + this.messageBroadcaster = messageBroadcaster; // Create main layout VerticalLayout layout = new VerticalLayout(); @@ -65,16 +71,16 @@ public class MessagesView extends Main { // Add title and action buttons HorizontalLayout headerLayout = createHeaderLayout(); - + // Create client grid clientGrid = createClientGrid(); - + // Add components to layout layout.add(headerLayout, clientGrid); // Add layout to main view add(layout); - + // Load client summaries loadClientSummaries(); } @@ -91,7 +97,7 @@ public class MessagesView extends Main { Grid grid = new Grid<>(ClientMessageSummary.class, false); grid.setWidthFull(); grid.setHeight("600px"); - + // Add columns grid.addColumn(new ComponentRenderer<>(summary -> { Span span = new Span(summary.getUnreadCount() > 0 ? "●" : "○"); @@ -101,15 +107,15 @@ public class MessagesView extends Main { } return span; })).setHeader("Status").setWidth("80px").setFlexGrow(0); - + grid.addColumn(ClientMessageSummary::getClientName).setHeader("Client").setAutoWidth(true); grid.addColumn(ClientMessageSummary::getClientEmail).setHeader("E-Mail").setAutoWidth(true); - + grid.addColumn(new ComponentRenderer<>(summary -> { Span span = new Span(String.valueOf(summary.getTotalMessages())); return span; })).setHeader("Nachrichten").setWidth("120px").setFlexGrow(0); - + grid.addColumn(new ComponentRenderer<>(summary -> { if (summary.getUnreadCount() > 0) { Span span = new Span(String.valueOf(summary.getUnreadCount())); @@ -119,11 +125,11 @@ public class MessagesView extends Main { } return new Span("0"); })).setHeader("Ungelesen").setWidth("100px").setFlexGrow(0); - - grid.addColumn(summary -> - summary.getLastMessageDate() != null ? summary.getLastMessageDate().format(DATE_FORMATTER) : "-" - ).setHeader("Letzte Nachricht").setAutoWidth(true); - + + grid.addColumn(summary -> summary.getLastMessageDate() != null + ? DateTimeFormatUtil.formatDateTime(summary.getLastMessageDate()) + : "-").setHeader("Letzte Nachricht").setAutoWidth(true); + grid.addColumn(new ComponentRenderer<>(summary -> { String preview = summary.getLastMessagePreview(); if (preview != null && preview.length() > 50) { @@ -131,13 +137,13 @@ public class MessagesView extends Main { } return new Span(preview != null ? preview : "-"); })).setHeader("Vorschau").setAutoWidth(true); - + // Add click listener to navigate to UserMessagesView grid.addItemClickListener(event -> { ClientMessageSummary summary = event.getItem(); UI.getCurrent().navigate("user-messages/" + summary.getClientId()); }); - + return grid; } @@ -157,7 +163,7 @@ public class MessagesView extends Main { } catch (Exception e) { log.error("Error loading client summaries: {}", e.getMessage(), e); Notification.show("Fehler beim Laden der Nachrichten", 3000, Notification.Position.MIDDLE) - .addThemeVariants(NotificationVariant.LUMO_ERROR); + .addThemeVariants(NotificationVariant.LUMO_ERROR); } finally { loading.set(false); } @@ -199,20 +205,17 @@ public class MessagesView extends Main { continue; } - conversation.sort(Comparator.comparing(Message::getCreatedAt, - Comparator.nullsLast(LocalDateTime::compareTo)).reversed()); + conversation.sort(Comparator + .comparing(Message::getCreatedAt, Comparator.nullsLast(LocalDateTime::compareTo)).reversed()); - Message latest = conversation.stream() - .filter(msg -> msg.getCreatedAt() != null) - .findFirst() + Message latest = conversation.stream().filter(msg -> msg.getCreatedAt() != null).findFirst() .orElse(conversation.get(0)); LocalDateTime lastDate = latest.getCreatedAt(); String preview = resolvePreview(latest); int totalMessages = conversation.size(); int unreadCount = (int) conversation.stream() - .filter(msg -> msg.getOrigin() == MessageOrigin.CLIENT && !msg.isRead()) - .count(); + .filter(msg -> msg.getOrigin() == MessageOrigin.CLIENT && !msg.isRead()).count(); summary.setTotalMessages(summary.getTotalMessages() + totalMessages); summary.setUnreadCount(summary.getUnreadCount() + unreadCount); @@ -235,8 +238,9 @@ public class MessagesView extends Main { } List summaries = new ArrayList<>(summaryMap.values()); - summaries.sort(Comparator.comparing(ClientMessageSummary::getLastMessageDate, - Comparator.nullsLast(LocalDateTime::compareTo)).reversed()); + summaries.sort(Comparator + .comparing(ClientMessageSummary::getLastMessageDate, Comparator.nullsLast(LocalDateTime::compareTo)) + .reversed()); return summaries; } @@ -249,9 +253,7 @@ public class MessagesView extends Main { return "[Bildnachricht]"; } - return Optional.ofNullable(message.getContent()) - .filter(content -> !content.isBlank()) - .orElse("(kein Inhalt)"); + return Optional.ofNullable(message.getContent()).filter(content -> !content.isBlank()).orElse("(kein Inhalt)"); } private String resolveParticipantKey(Message message) { @@ -326,6 +328,12 @@ public class MessagesView extends Main { UI ui = attachEvent.getUI(); ui.setPollInterval(POLL_INTERVAL_MS); pollRegistration = ui.addPollListener(event -> loadClientSummaries()); + + // Register broadcaster for real-time notifications + broadcasterRegistration = messageBroadcaster.register(message -> { + handleIncomingMessage(ui, message); + }); + loadClientSummaries(); } @@ -336,6 +344,96 @@ public class MessagesView extends Main { pollRegistration.remove(); pollRegistration = null; } + if (broadcasterRegistration != null) { + broadcasterRegistration.remove(); + broadcasterRegistration = null; + } detachEvent.getUI().setPollInterval(-1); } + + /** + * Handle incoming message for notification purposes + */ + private void handleIncomingMessage(UI ui, Message message) { + if (message == null || message.getOrigin() != MessageOrigin.CLIENT) { + return; + } + + ui.access(() -> { + try { + // Count total messages to detect new ones + int currentMessageCount = messageService.countAllMessages(); + if (currentMessageCount > lastMessageCount) { + lastMessageCount = currentMessageCount; + + // Play notification sound and show browser notification + playNotificationSound(ui); + showBrowserNotification(ui, message); + + // Refresh the grid + loadClientSummaries(); + } + } catch (Exception e) { + log.error("Error handling incoming message notification", e); + } + }); + } + + /** + * Play a notification sound when a new message arrives + */ + private void playNotificationSound(UI ui) { + ui.getPage().executeJs( + "const audio = new Audio('data:audio/wav;base64,UklGRnoGAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQoGAACBhYqFbF1fdJivrJBhNjVgodDbq2EcBj+a2/LDciUFLIHO8tiJNwgZaLvt559NEAxQp+PwtmMcBjiR1/LMeSwFJHfH8N2QQAoUXrTp66hVFApGn+DyvmwhBTGH0fPTgjMGHm7A7+OZSA0PVK3m8LRjHAU7k9nzyn0tBSd5ye/glEIKEl206O2oVhQLSKHi8r5uIgU0idT01IMzByBwwvHjmEgNDlWs5PCzYhsFO5TY88p+Kwcme8jw4JVCChNdt+jvp1QVDEih4vK+bSIGNIrV9dODMggib8Lx5JdIDQ9VrObws2IbBT6U2PXKfi0IJnzH8OCVQgoVXbfp76dVFQ5IouLyvW0jCDSL1fTSgTQJJG7C8eSWSA8RVq3m8LJgGwg/lNj0yn4tCSV7x+/glUILFl237++nVhYOSKPi8rxtIwo0i9X00oE1CiNuwvDklkkREVat5u+yXxwJP5PY9Ml+Lgoge8fv4JVCDBVct+7vqFYYEUij4vG8bSQKNIvV89GBNQshbcLw5JZJERFV\u003d\u003d'); audio.play().catch(err => console.log('Audio play failed:', err));"); + } + + /** + * Show a browser notification when a new message arrives + */ + private void showBrowserNotification(UI ui, Message message) { + String senderName = resolveAppUserName(message.getReceiver()); + String preview = resolveNotificationPreview(message); + String title = senderName != null ? ("Nachricht von " + senderName) : "Neue Nachricht"; + + ui.getPage().executeJs( + "if (!('Notification' in window)) {" + " console.log('Browser does not support notifications');" + + "} else if (Notification.permission === 'granted') {" + " new Notification($0, { body: $1 });" + + "} else if (Notification.permission !== 'denied') {" + " Notification.requestPermission().then(permission => {" + + " if (permission === 'granted') {" + " new Notification($0, { body: $1 });" + " }" + " });" + "}", + title, preview); + } + + /** + * Resolve the AppUser name by ID + */ + private String resolveAppUserName(String appUserId) { + if (appUserId == null) { + return null; + } + try { + ObjectId id = new ObjectId(appUserId); + AppUser appUser = appUserService.findById(id); + return appUser != null ? appUser.getBezeichnung() : null; + } catch (Exception e) { + return null; + } + } + + /** + * Resolve a short preview text for the notification + */ + private String resolveNotificationPreview(Message message) { + if (message == null) { + return ""; + } + if (message.getContentType() == MessageContentType.IMAGE) { + return "📷 Bild"; + } + String content = message.getContent(); + if (content == null || content.isBlank()) { + return "(kein Inhalt)"; + } + // Limit preview to 100 characters + return content.length() > 100 ? content.substring(0, 97) + "..." : content; + } } diff --git a/src/main/java/de/assecutor/votianlt/pages/view/MyInvoicesView.java b/src/main/java/de/assecutor/votianlt/pages/view/MyInvoicesView.java index 8177268..1ced6e7 100644 --- a/src/main/java/de/assecutor/votianlt/pages/view/MyInvoicesView.java +++ b/src/main/java/de/assecutor/votianlt/pages/view/MyInvoicesView.java @@ -23,13 +23,13 @@ import com.vaadin.flow.component.UI; import com.vaadin.flow.server.StreamResource; import com.vaadin.flow.server.StreamRegistration; import de.assecutor.votianlt.service.SystemInvoiceService; +import de.assecutor.votianlt.util.DateTimeFormatUtil; import de.assecutor.votianlt.model.invoices.SystemInvoiceData; import de.assecutor.votianlt.model.invoices.SystemInvoiceItem; import java.io.ByteArrayInputStream; import java.text.NumberFormat; import java.time.LocalDate; -import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.List; import java.util.Locale; @@ -50,7 +50,6 @@ public class MyInvoicesView extends Main { private final Div emptyState = new Div(); private final SystemInvoiceService systemInvoiceService; - private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("dd.MM.yyyy"); private static final NumberFormat CURRENCY_FMT = NumberFormat.getCurrencyInstance(Locale.GERMANY); public MyInvoicesView(SystemInvoiceService systemInvoiceService) { @@ -76,9 +75,7 @@ public class MyInvoicesView extends Main { private Component createTopCards() { // Container mit responsiven Spalten Div container = new Div(); - container.getStyle() - .set("display", "grid") - .set("grid-template-columns", "repeat(auto-fit, minmax(280px, 1fr))") + container.getStyle().set("display", "grid").set("grid-template-columns", "repeat(auto-fit, minmax(280px, 1fr))") .set("gap", "var(--lumo-space-m)"); // Karte: Offene Rechnungen @@ -143,7 +140,8 @@ public class MyInvoicesView extends Main { grid.addColumn(new ComponentRenderer<>(row -> statusBadge(row.status()))).setHeader("Status").setAutoWidth(true) .setFlexGrow(0); grid.addColumn(MyInvoicesView::formatInvoiceNumber).setHeader("Rechnungsnummer").setAutoWidth(true); - grid.addColumn(row -> DATE_FMT.format(row.date())).setHeader("Datum").setAutoWidth(true).setFlexGrow(0); + grid.addColumn(row -> DateTimeFormatUtil.formatDate(row.date())).setHeader("Datum").setAutoWidth(true) + .setFlexGrow(0); grid.addColumn(row -> CURRENCY_FMT.format(row.amount())).setHeader("Betrag").setAutoWidth(true) .setTextAlign(ColumnTextAlign.END).setFlexGrow(0); grid.setAllRowsVisible(true); @@ -276,7 +274,7 @@ public class MyInvoicesView extends Main { private byte[] generateSystemInvoicePdf(MyInvoiceRow row) throws Exception { SystemInvoiceData data = new SystemInvoiceData(); data.setInvoiceNumber(row.invoiceNumber()); - data.setInvoiceDate(DATE_FMT.format(row.date())); + data.setInvoiceDate(DateTimeFormatUtil.formatDate(row.date())); data.setInvoiceText("Rechnung " + row.invoiceNumber()); // Minimal recipient information diff --git a/src/main/java/de/assecutor/votianlt/pages/view/PdfTestView.java b/src/main/java/de/assecutor/votianlt/pages/view/PdfTestView.java index a116f35..a5ebeac 100644 --- a/src/main/java/de/assecutor/votianlt/pages/view/PdfTestView.java +++ b/src/main/java/de/assecutor/votianlt/pages/view/PdfTestView.java @@ -61,8 +61,7 @@ public class PdfTestView extends VerticalLayout { try { byte[] pdfBytes = systemInvoiceService.generateInvoicePdfFromHtml(); - StreamResource resource = new StreamResource("vlt-invoice.pdf", - () -> new ByteArrayInputStream(pdfBytes)); + StreamResource resource = new StreamResource("vlt-invoice.pdf", () -> new ByteArrayInputStream(pdfBytes)); resource.setContentType("application/pdf"); getUI().ifPresent(ui -> { @@ -72,8 +71,8 @@ public class PdfTestView extends VerticalLayout { Notification.show("PDF aus HTML erfolgreich generiert!", 3000, Notification.Position.BOTTOM_CENTER); } catch (Exception ex) { - Notification.show("Fehler beim Generieren des PDFs aus HTML: " + ex.getMessage(), - 5000, Notification.Position.BOTTOM_CENTER); + Notification.show("Fehler beim Generieren des PDFs aus HTML: " + ex.getMessage(), 5000, + Notification.Position.BOTTOM_CENTER); } } @@ -92,8 +91,8 @@ public class PdfTestView extends VerticalLayout { Notification.show("Customer PDF erfolgreich generiert!", 3000, Notification.Position.BOTTOM_CENTER); } catch (Exception ex) { - Notification.show("Fehler beim Generieren des Customer PDFs: " + ex.getMessage(), - 5000, Notification.Position.BOTTOM_CENTER); + Notification.show("Fehler beim Generieren des Customer PDFs: " + ex.getMessage(), 5000, + Notification.Position.BOTTOM_CENTER); } } } 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 b5de6f0..5ed802f 100644 --- a/src/main/java/de/assecutor/votianlt/pages/view/ShowJobsView.java +++ b/src/main/java/de/assecutor/votianlt/pages/view/ShowJobsView.java @@ -1,26 +1,43 @@ package de.assecutor.votianlt.pages.view; -import com.vaadin.flow.component.datepicker.DatePicker; +import com.vaadin.flow.component.AttachEvent; +import com.vaadin.flow.component.DetachEvent; +import com.vaadin.flow.component.UI; import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.button.ButtonVariant; import com.vaadin.flow.component.combobox.ComboBox; +import com.vaadin.flow.component.confirmdialog.ConfirmDialog; +import com.vaadin.flow.component.datepicker.DatePicker; 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.icon.Icon; +import com.vaadin.flow.component.icon.VaadinIcon; +import com.vaadin.flow.component.notification.Notification; +import com.vaadin.flow.component.notification.NotificationVariant; 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.shared.Registration; import com.vaadin.flow.router.PageTitle; import com.vaadin.flow.router.Route; import de.assecutor.votianlt.model.Job; import de.assecutor.votianlt.model.JobStatus; import de.assecutor.votianlt.repository.JobRepository; +import de.assecutor.votianlt.security.SecurityService; +import de.assecutor.votianlt.service.JobBroadcaster; +import de.assecutor.votianlt.service.JobHistoryService; import jakarta.annotation.security.RolesAllowed; +import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; +import java.time.LocalDateTime; + @PageTitle("Aufträge") @Route(value = "jobs", layout = de.assecutor.votianlt.pages.base.ui.view.MainLayout.class) @RolesAllowed({ "USER" }) +@Slf4j public class ShowJobsView extends VerticalLayout { private final DatePicker startDate = new DatePicker("Startdatum"); @@ -28,11 +45,19 @@ public class ShowJobsView extends VerticalLayout { private final TextField searchField = new TextField("Auftragsnummer suchen"); private final ComboBox statusFilter = new ComboBox<>("Status"); private final JobRepository jobRepository; + private final JobHistoryService jobHistoryService; + private final SecurityService securityService; + private final JobBroadcaster jobBroadcaster; private final Grid grid = new Grid<>(Job.class, false); + private Registration broadcasterRegistration; @Autowired - public ShowJobsView(JobRepository jobRepository) { + public ShowJobsView(JobRepository jobRepository, JobHistoryService jobHistoryService, + SecurityService securityService, JobBroadcaster jobBroadcaster) { this.jobRepository = jobRepository; + this.jobHistoryService = jobHistoryService; + this.securityService = securityService; + this.jobBroadcaster = jobBroadcaster; setSizeFull(); setPadding(true); setSpacing(true); @@ -78,11 +103,28 @@ public class ShowJobsView extends VerticalLayout { endDate.addValueChangeListener(e -> loadData()); // Configure grid columns: Auftraggeber, Auftragsnummer, Auftragsdatum, Zielort - grid.addColumn(job -> extractCompanyName(job.getCustomerSelection())).setHeader("Auftraggeber").setAutoWidth(true).setFlexGrow(1).setSortable(true); + grid.addColumn(job -> extractCompanyName(job.getCustomerSelection())).setHeader("Auftraggeber") + .setAutoWidth(true).setFlexGrow(1).setSortable(true); grid.addColumn(Job::getJobNumber).setHeader("Auftragsnummer").setAutoWidth(true).setSortable(true); grid.addColumn(Job::getCreatedAt).setHeader("Auftragsdatum").setAutoWidth(true).setSortable(true); grid.addColumn(Job::getDeliveryCity).setHeader("Zielort").setAutoWidth(true).setFlexGrow(1).setSortable(true); + // Action column: manual completion for jobs without digital processing + grid.addComponentColumn(job -> { + if (!job.isDigitalProcessing() && job.getStatus() != JobStatus.COMPLETED + && job.getStatus() != JobStatus.CANCELLED) { + Button completeBtn = new Button(new Icon(VaadinIcon.CHECK_CIRCLE)); + completeBtn.addThemeVariants(ButtonVariant.LUMO_TERTIARY, ButtonVariant.LUMO_SUCCESS); + completeBtn.setTooltipText("Auftrag manuell abschließen"); + completeBtn.addClickListener(e -> { + e.getSource().getElement().getNode(); // prevent row click + showCompleteJobDialog(job); + }); + return completeBtn; + } + return new com.vaadin.flow.component.html.Span(); + }).setHeader("").setAutoWidth(true).setFlexGrow(0); + grid.setMultiSort(true); grid.setSizeFull(); @@ -103,6 +145,32 @@ public class ShowJobsView extends VerticalLayout { loadData(); } + private void showCompleteJobDialog(Job job) { + 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(e -> { + 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); + loadData(); + } catch (Exception ex) { + Notification.show("Fehler beim Abschließen: " + ex.getMessage(), 5000, Notification.Position.BOTTOM_END) + .addThemeVariants(NotificationVariant.LUMO_ERROR); + } + }); + dialog.open(); + } + private void loadData() { var start = startDate.getValue(); var end = endDate.getValue(); @@ -132,8 +200,10 @@ public class ShowJobsView extends VerticalLayout { // wenn // leer - // Verwende die erweiterte Suchmethode - var filteredJobs = jobRepository.findWithFilters(startDt, endDt, jobNumberPattern, statusList); + // Verwende die erweiterte Suchmethode, gefiltert nach aktuellem Benutzer + String currentUserId = securityService.getCurrentUserId().toHexString(); + var filteredJobs = jobRepository.findWithFiltersByCreatedBy(currentUserId, startDt, endDt, jobNumberPattern, + statusList); grid.setItems(filteredJobs); } @@ -193,4 +263,75 @@ public class ShowJobsView extends VerticalLayout { } return customerSelection.trim(); } + + @Override + protected void onAttach(AttachEvent attachEvent) { + super.onAttach(attachEvent); + UI ui = attachEvent.getUI(); + + // Register broadcaster for real-time job notifications + broadcasterRegistration = jobBroadcaster.register(job -> { + handleNewJob(ui, job); + }); + + log.info("ShowJobsView attached and job listener registered"); + } + + @Override + protected void onDetach(DetachEvent detachEvent) { + super.onDetach(detachEvent); + if (broadcasterRegistration != null) { + broadcasterRegistration.remove(); + broadcasterRegistration = null; + } + log.info("ShowJobsView detached and job listener unregistered"); + } + + /** + * Handle new job notification + */ + private void handleNewJob(UI ui, Job job) { + if (job == null) { + return; + } + + ui.access(() -> { + try { + // Play notification sound and show browser notification + playNotificationSound(ui); + showBrowserNotification(ui, job); + + // Refresh the grid + loadData(); + + log.info("New job notification displayed for job {}", job.getJobNumber()); + } catch (Exception e) { + log.error("Error handling new job notification", e); + } + }); + } + + /** + * Play a notification sound when a new job is created + */ + private void playNotificationSound(UI ui) { + ui.getPage().executeJs( + "const audio = new Audio('data:audio/wav;base64,UklGRnoGAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQoGAACBhYqFbF1fdJivrJBhNjVgodDbq2EcBj+a2/LDciUFLIHO8tiJNwgZaLvt559NEAxQp+PwtmMcBjiR1/LMeSwFJHfH8N2QQAoUXrTp66hVFApGn+DyvmwhBTGH0fPTgjMGHm7A7+OZSA0PVK3m8LRjHAU7k9nzyn0tBSd5ye/glEIKEl206O2oVhQLSKHi8r5uIgU0idT01IMzByBwwvHjmEgNDlWs5PCzYhsFO5TY88p+Kwcme8jw4JVCChNdt+jvp1QVDEih4vK+bSIGNIrV9dODMggib8Lx5JdIDQ9VrObws2IbBT6U2PXKfi0IJnzH8OCVQgoVXbfp76dVFQ5IouLyvW0jCDSL1fTSgTQJJG7C8eSWSA8RVq3m8LJgGwg/lNj0yn4tCSV7x+/glUILFl237++nVhYOSKPi8rxtIwo0i9X00oE1CiNuwvDklkkREVat5u+yXxwJP5PY9Ml+Lgoge8fv4JVCDBVct+7vqFYYEUij4vG8bSQKNIvV89GBNQshbcLw5JZJERFV\u003d\u003d'); audio.play().catch(err => console.log('Audio play failed:', err));"); + } + + /** + * Show a browser notification when a new job is created + */ + private void showBrowserNotification(UI ui, Job job) { + String jobNumber = job.getJobNumber() != null ? job.getJobNumber() : "Neuer Auftrag"; + String company = extractCompanyName(job.getCustomerSelection()); + String message = company != null && !company.isBlank() ? ("Kunde: " + company) : "Neuer Auftrag erstellt"; + + ui.getPage().executeJs( + "if (!('Notification' in window)) {" + " console.log('Browser does not support notifications');" + + "} else if (Notification.permission === 'granted') {" + " new Notification($0, { body: $1 });" + + "} else if (Notification.permission !== 'denied') {" + " Notification.requestPermission().then(permission => {" + + " if (permission === 'granted') {" + " new Notification($0, { body: $1 });" + " }" + " });" + "}", + jobNumber, message); + } } diff --git a/src/main/java/de/assecutor/votianlt/pages/view/StartView.java b/src/main/java/de/assecutor/votianlt/pages/view/StartView.java index f71aa60..3f3d1d6 100644 --- a/src/main/java/de/assecutor/votianlt/pages/view/StartView.java +++ b/src/main/java/de/assecutor/votianlt/pages/view/StartView.java @@ -330,8 +330,7 @@ public class StartView extends VerticalLayout implements BeforeEnterObserver { // Versionsnummer Span versionSpan = new Span("Version " + appVersion); versionSpan.getStyle().set("color", "var(--lumo-secondary-text-color)") - .set("font-size", "var(--lumo-font-size-s)") - .set("margin-top", "var(--lumo-space-l)"); + .set("font-size", "var(--lumo-font-size-s)").set("margin-top", "var(--lumo-space-l)"); footer.add(companyTitle, companyInfo, ctaText, slogan, versionSpan); return footer; diff --git a/src/main/java/de/assecutor/votianlt/pages/view/StatisticsView.java b/src/main/java/de/assecutor/votianlt/pages/view/StatisticsView.java index 0e1f5ff..47f7f94 100644 --- a/src/main/java/de/assecutor/votianlt/pages/view/StatisticsView.java +++ b/src/main/java/de/assecutor/votianlt/pages/view/StatisticsView.java @@ -20,11 +20,11 @@ import com.vaadin.flow.component.textfield.TextField; import com.vaadin.flow.router.PageTitle; import com.vaadin.flow.router.Route; import de.assecutor.votianlt.ai.service.AiStatisticsService; +import de.assecutor.votianlt.util.DateTimeFormatUtil; import jakarta.annotation.security.RolesAllowed; import lombok.extern.slf4j.Slf4j; import java.time.LocalDateTime; -import java.time.format.DateTimeFormatter; import java.util.UUID; @PageTitle("KI-Statistiken") @@ -37,7 +37,6 @@ public class StatisticsView extends VerticalLayout { private final AiStatisticsService aiStatisticsService; private final VerticalLayout chatContainer; private final TextField promptField; - private final DateTimeFormatter timeFormatter = DateTimeFormatter.ofPattern("HH:mm"); public StatisticsView(AiStatisticsService aiStatisticsService) { this.aiStatisticsService = aiStatisticsService; @@ -69,7 +68,6 @@ public class StatisticsView extends VerticalLayout { scroller.setScrollDirection(Scroller.ScrollDirection.VERTICAL); scroller.getStyle().set("background", "var(--lumo-contrast-5pct)"); - add(scroller); setFlexGrow(1, scroller); @@ -83,9 +81,8 @@ public class StatisticsView extends VerticalLayout { header.setWidthFull(); header.setPadding(true); header.setAlignItems(FlexComponent.Alignment.CENTER); - header.getStyle() - .set("background", "var(--lumo-base-color)") - .set("border-bottom", "1px solid var(--lumo-contrast-10pct)"); + header.getStyle().set("background", "var(--lumo-base-color)").set("border-bottom", + "1px solid var(--lumo-contrast-10pct)"); Icon aiIcon = VaadinIcon.MAGIC.create(); aiIcon.getStyle().set("color", "var(--lumo-primary-color)"); @@ -94,9 +91,7 @@ public class StatisticsView extends VerticalLayout { title.getStyle().set("margin", "0").set("font-size", "var(--lumo-font-size-xl)"); Span subtitle = new Span("Frage mich zu Aufträgen, Umsätzen und Statistiken"); - subtitle.getStyle() - .set("color", "var(--lumo-secondary-text-color)") - .set("font-size", "var(--lumo-font-size-s)") + subtitle.getStyle().set("color", "var(--lumo-secondary-text-color)").set("font-size", "var(--lumo-font-size-s)") .set("margin-left", "var(--lumo-space-m)"); header.add(aiIcon, title, subtitle); @@ -109,9 +104,8 @@ public class StatisticsView extends VerticalLayout { inputArea.setPadding(true); inputArea.setSpacing(true); inputArea.setAlignItems(FlexComponent.Alignment.CENTER); - inputArea.getStyle() - .set("background", "var(--lumo-base-color)") - .set("border-top", "1px solid var(--lumo-contrast-10pct)"); + inputArea.getStyle().set("background", "var(--lumo-base-color)").set("border-top", + "1px solid var(--lumo-contrast-10pct)"); Button sendButton = new Button(VaadinIcon.PAPERPLANE.create()); sendButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY); @@ -119,9 +113,11 @@ public class StatisticsView extends VerticalLayout { sendButton.getStyle().set("min-width", "50px"); // Quick Action Buttons - Button jobCountBtn = createQuickActionButton("Aufträge zählen", "Wie viele Aufträge gibt es insgesamt und nach Status?"); + Button jobCountBtn = createQuickActionButton("Aufträge zählen", + "Wie viele Aufträge gibt es insgesamt und nach Status?"); Button revenueBtn = createQuickActionButton("Umsatz", "Zeige mir den Umsatz pro Kunde."); - Button trendBtn = createQuickActionButton("Monatstrend", "Zeige mir den Monatstrend der Aufträge für dieses Jahr."); + Button trendBtn = createQuickActionButton("Monatstrend", + "Zeige mir den Monatstrend der Aufträge für dieses Jahr."); HorizontalLayout quickActions = new HorizontalLayout(jobCountBtn, revenueBtn, trendBtn); quickActions.setSpacing(true); @@ -194,30 +190,22 @@ public class StatisticsView extends VerticalLayout { Div messageDiv = new Div(); messageDiv.addClassName("chat-message"); messageDiv.addClassName("user-message"); - messageDiv.getStyle() - .set("display", "flex") - .set("justify-content", "flex-end") - .set("margin-bottom", "var(--lumo-space-m)"); + messageDiv.getStyle().set("display", "flex").set("justify-content", "flex-end").set("margin-bottom", + "var(--lumo-space-m)"); Div bubble = new Div(); - bubble.getStyle() - .set("background", "var(--lumo-primary-color)") + bubble.getStyle().set("background", "var(--lumo-primary-color)") .set("color", "var(--lumo-primary-contrast-color)") .set("padding", "var(--lumo-space-s) var(--lumo-space-m)") - .set("border-radius", "var(--lumo-border-radius-l)") - .set("max-width", "70%") + .set("border-radius", "var(--lumo-border-radius-l)").set("max-width", "70%") .set("word-wrap", "break-word"); Paragraph text = new Paragraph(message); text.getStyle().set("margin", "0"); - Span time = new Span(LocalDateTime.now().format(timeFormatter)); - time.getStyle() - .set("font-size", "var(--lumo-font-size-xs)") - .set("opacity", "0.7") - .set("display", "block") - .set("text-align", "right") - .set("margin-top", "var(--lumo-space-xs)"); + Span time = new Span(DateTimeFormatUtil.formatTime(LocalDateTime.now())); + time.getStyle().set("font-size", "var(--lumo-font-size-xs)").set("opacity", "0.7").set("display", "block") + .set("text-align", "right").set("margin-top", "var(--lumo-space-xs)"); bubble.add(text, time); messageDiv.add(bubble); @@ -228,18 +216,13 @@ public class StatisticsView extends VerticalLayout { Div messageDiv = new Div(); messageDiv.addClassName("chat-message"); messageDiv.addClassName("ai-message"); - messageDiv.getStyle() - .set("display", "flex") - .set("justify-content", "flex-start") - .set("margin-bottom", "var(--lumo-space-m)"); + messageDiv.getStyle().set("display", "flex").set("justify-content", "flex-start").set("margin-bottom", + "var(--lumo-space-m)"); Div bubble = new Div(); - bubble.getStyle() - .set("background", "var(--lumo-base-color)") - .set("border", "1px solid var(--lumo-contrast-10pct)") - .set("padding", "var(--lumo-space-m)") - .set("border-radius", "var(--lumo-border-radius-l)") - .set("max-width", "85%") + bubble.getStyle().set("background", "var(--lumo-base-color)") + .set("border", "1px solid var(--lumo-contrast-10pct)").set("padding", "var(--lumo-space-m)") + .set("border-radius", "var(--lumo-border-radius-l)").set("max-width", "85%") .set("box-shadow", "var(--lumo-box-shadow-xs)"); // AI Icon @@ -252,9 +235,7 @@ public class StatisticsView extends VerticalLayout { aiIcon.getStyle().set("color", "var(--lumo-primary-color)"); Span aiLabel = new Span("KI-Assistent"); - aiLabel.getStyle() - .set("font-weight", "bold") - .set("font-size", "var(--lumo-font-size-s)"); + aiLabel.getStyle().set("font-weight", "bold").set("font-size", "var(--lumo-font-size-s)"); header.add(aiIcon, aiLabel); bubble.add(header); @@ -273,20 +254,15 @@ public class StatisticsView extends VerticalLayout { if (response.chartData() != null && !response.chartData().isEmpty()) { Div chartContainer = createChart(response.chartType(), response.chartData()); if (chartContainer != null) { - chartContainer.getStyle() - .set("margin-top", "var(--lumo-space-m)") - .set("height", "300px"); + chartContainer.getStyle().set("margin-top", "var(--lumo-space-m)").set("height", "300px"); bubble.add(chartContainer); } } // Timestamp - Span time = new Span(LocalDateTime.now().format(timeFormatter)); - time.getStyle() - .set("font-size", "var(--lumo-font-size-xs)") - .set("color", "var(--lumo-secondary-text-color)") - .set("display", "block") - .set("margin-top", "var(--lumo-space-s)"); + Span time = new Span(DateTimeFormatUtil.formatTime(LocalDateTime.now())); + time.getStyle().set("font-size", "var(--lumo-font-size-xs)").set("color", "var(--lumo-secondary-text-color)") + .set("display", "block").set("margin-top", "var(--lumo-space-s)"); bubble.add(time); messageDiv.add(bubble); @@ -295,18 +271,14 @@ public class StatisticsView extends VerticalLayout { private void addErrorMessage(String message) { Div messageDiv = new Div(); - messageDiv.getStyle() - .set("display", "flex") - .set("justify-content", "flex-start") - .set("margin-bottom", "var(--lumo-space-m)"); + messageDiv.getStyle().set("display", "flex").set("justify-content", "flex-start").set("margin-bottom", + "var(--lumo-space-m)"); Div bubble = new Div(); - bubble.getStyle() - .set("background", "var(--lumo-error-color-10pct)") + bubble.getStyle().set("background", "var(--lumo-error-color-10pct)") .set("border", "1px solid var(--lumo-error-color)") .set("padding", "var(--lumo-space-s) var(--lumo-space-m)") - .set("border-radius", "var(--lumo-border-radius-l)") - .set("max-width", "70%"); + .set("border-radius", "var(--lumo-border-radius-l)").set("max-width", "70%"); Icon errorIcon = VaadinIcon.EXCLAMATION_CIRCLE.create(); errorIcon.setSize("16px"); @@ -326,22 +298,17 @@ public class StatisticsView extends VerticalLayout { private Div createLoadingMessage() { Div messageDiv = new Div(); - messageDiv.getStyle() - .set("display", "flex") - .set("justify-content", "flex-start") - .set("margin-bottom", "var(--lumo-space-m)"); + messageDiv.getStyle().set("display", "flex").set("justify-content", "flex-start").set("margin-bottom", + "var(--lumo-space-m)"); Div bubble = new Div(); - bubble.getStyle() - .set("background", "var(--lumo-base-color)") + bubble.getStyle().set("background", "var(--lumo-base-color)") .set("border", "1px solid var(--lumo-contrast-10pct)") .set("padding", "var(--lumo-space-s) var(--lumo-space-m)") .set("border-radius", "var(--lumo-border-radius-l)"); Span dots = new Span("Analysiere..."); - dots.getStyle() - .set("color", "var(--lumo-secondary-text-color)") - .set("font-style", "italic"); + dots.getStyle().set("color", "var(--lumo-secondary-text-color)").set("font-style", "italic"); bubble.add(dots); messageDiv.add(bubble); @@ -356,10 +323,8 @@ public class StatisticsView extends VerticalLayout { String canvasId = "chart-" + UUID.randomUUID().toString().substring(0, 8); Div chartContainer = new Div(); chartContainer.addClassName("chart-wrapper"); - chartContainer.getStyle() - .set("background", "var(--lumo-contrast-5pct)") - .set("border-radius", "var(--lumo-border-radius-m)") - .set("padding", "var(--lumo-space-s)"); + chartContainer.getStyle().set("background", "var(--lumo-contrast-5pct)") + .set("border-radius", "var(--lumo-border-radius-m)").set("padding", "var(--lumo-space-s)"); chartContainer.getElement().setProperty("innerHTML", ""); @@ -410,277 +375,274 @@ public class StatisticsView extends VerticalLayout { private String getChartOptions(String chartType) { // Gradient und moderne Farben für verschiedene Chart-Typen return switch (chartType) { - case "line" -> """ - { - responsive: true, - maintainAspectRatio: false, - interaction: { - intersect: false, - mode: 'index' - }, - plugins: { - legend: { - position: 'bottom', - labels: { - usePointStyle: true, - padding: 20 - } - }, - tooltip: { - backgroundColor: 'rgba(0,0,0,0.8)', - titleFont: { size: 14, weight: 'bold' }, - bodyFont: { size: 13 }, - padding: 12, - cornerRadius: 8 + case "line" -> """ + { + responsive: true, + maintainAspectRatio: false, + interaction: { + intersect: false, + mode: 'index' + }, + plugins: { + legend: { + position: 'bottom', + labels: { + usePointStyle: true, + padding: 20 } }, - scales: { - y: { - beginAtZero: true, - grid: { - color: 'rgba(0,0,0,0.05)' - } - }, - x: { - grid: { - display: false - } - } - }, - elements: { - line: { - tension: 0.4, - borderWidth: 3 - }, - point: { - radius: 4, - hoverRadius: 6, - hitRadius: 10 - } - }, - animation: { - duration: 1000, - easing: 'easeOutQuart' + tooltip: { + backgroundColor: 'rgba(0,0,0,0.8)', + titleFont: { size: 14, weight: 'bold' }, + bodyFont: { size: 13 }, + padding: 12, + cornerRadius: 8 } - } - """; - case "bar" -> """ - { - responsive: true, - maintainAspectRatio: false, - plugins: { - legend: { - position: 'bottom', - labels: { - usePointStyle: true, - padding: 20 - } - }, - tooltip: { - backgroundColor: 'rgba(0,0,0,0.8)', - titleFont: { size: 14, weight: 'bold' }, - bodyFont: { size: 13 }, - padding: 12, - cornerRadius: 8 + }, + scales: { + y: { + beginAtZero: true, + grid: { + color: 'rgba(0,0,0,0.05)' } }, - scales: { - y: { - beginAtZero: true, - grid: { - color: 'rgba(0,0,0,0.05)' - } - }, - x: { - grid: { - display: false - } + x: { + grid: { + display: false } - }, - elements: { - bar: { - borderRadius: 6, - borderSkipped: false - } - }, - animation: { - duration: 800, - easing: 'easeOutQuart' } - } - """; - case "doughnut" -> """ - { - responsive: true, - maintainAspectRatio: false, - plugins: { - legend: { - position: 'right', - labels: { - usePointStyle: true, - padding: 15, - font: { size: 12 } - } - }, - tooltip: { - backgroundColor: 'rgba(0,0,0,0.8)', - titleFont: { size: 14, weight: 'bold' }, - bodyFont: { size: 13 }, - padding: 12, - cornerRadius: 8 - } + }, + elements: { + line: { + tension: 0.4, + borderWidth: 3 }, - cutout: '60%', - animation: { - animateRotate: true, - animateScale: true, - duration: 1000, - easing: 'easeOutQuart' + point: { + radius: 4, + hoverRadius: 6, + hitRadius: 10 } + }, + animation: { + duration: 1000, + easing: 'easeOutQuart' } - """; - case "pie" -> """ - { - responsive: true, - maintainAspectRatio: false, - plugins: { - legend: { - position: 'right', - labels: { - usePointStyle: true, - padding: 15, - font: { size: 12 } - } - }, - tooltip: { - backgroundColor: 'rgba(0,0,0,0.8)', - titleFont: { size: 14, weight: 'bold' }, - bodyFont: { size: 13 }, - padding: 12, - cornerRadius: 8 + } + """; + case "bar" -> """ + { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + position: 'bottom', + labels: { + usePointStyle: true, + padding: 20 } }, - animation: { - animateRotate: true, - animateScale: true, - duration: 1000, - easing: 'easeOutQuart' + tooltip: { + backgroundColor: 'rgba(0,0,0,0.8)', + titleFont: { size: 14, weight: 'bold' }, + bodyFont: { size: 13 }, + padding: 12, + cornerRadius: 8 } - } - """; - case "radar" -> """ - { - responsive: true, - maintainAspectRatio: false, - plugins: { - legend: { - position: 'top', - labels: { - usePointStyle: true, - padding: 15 - } - }, - tooltip: { - backgroundColor: 'rgba(0,0,0,0.8)', - titleFont: { size: 14, weight: 'bold' }, - bodyFont: { size: 13 }, - padding: 12, - cornerRadius: 8 + }, + scales: { + y: { + beginAtZero: true, + grid: { + color: 'rgba(0,0,0,0.05)' } }, - scales: { - r: { - beginAtZero: true, - grid: { - color: 'rgba(0,0,0,0.1)' - }, - pointLabels: { - font: { size: 12 } - } + x: { + grid: { + display: false } - }, - elements: { - line: { - borderWidth: 2 - }, - point: { - radius: 4, - hoverRadius: 6 - } - }, - animation: { - duration: 1000, - easing: 'easeOutQuart' } + }, + elements: { + bar: { + borderRadius: 6, + borderSkipped: false + } + }, + animation: { + duration: 800, + easing: 'easeOutQuart' } - """; - case "polarArea" -> """ - { - responsive: true, - maintainAspectRatio: false, - plugins: { - legend: { - position: 'right', - labels: { - usePointStyle: true, - padding: 15, - font: { size: 12 } - } + } + """; + case "doughnut" -> """ + { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + position: 'right', + labels: { + usePointStyle: true, + padding: 15, + font: { size: 12 } + } + }, + tooltip: { + backgroundColor: 'rgba(0,0,0,0.8)', + titleFont: { size: 14, weight: 'bold' }, + bodyFont: { size: 13 }, + padding: 12, + cornerRadius: 8 + } + }, + cutout: '60%', + animation: { + animateRotate: true, + animateScale: true, + duration: 1000, + easing: 'easeOutQuart' + } + } + """; + case "pie" -> """ + { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + position: 'right', + labels: { + usePointStyle: true, + padding: 15, + font: { size: 12 } + } + }, + tooltip: { + backgroundColor: 'rgba(0,0,0,0.8)', + titleFont: { size: 14, weight: 'bold' }, + bodyFont: { size: 13 }, + padding: 12, + cornerRadius: 8 + } + }, + animation: { + animateRotate: true, + animateScale: true, + duration: 1000, + easing: 'easeOutQuart' + } + } + """; + case "radar" -> """ + { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + position: 'top', + labels: { + usePointStyle: true, + padding: 15 + } + }, + tooltip: { + backgroundColor: 'rgba(0,0,0,0.8)', + titleFont: { size: 14, weight: 'bold' }, + bodyFont: { size: 13 }, + padding: 12, + cornerRadius: 8 + } + }, + scales: { + r: { + beginAtZero: true, + grid: { + color: 'rgba(0,0,0,0.1)' }, - tooltip: { - backgroundColor: 'rgba(0,0,0,0.8)', - titleFont: { size: 14, weight: 'bold' }, - bodyFont: { size: 13 }, - padding: 12, - cornerRadius: 8 + pointLabels: { + font: { size: 12 } } - }, - scales: { - r: { - beginAtZero: true, - grid: { - color: 'rgba(0,0,0,0.1)' - } - } - }, - animation: { - animateRotate: true, - animateScale: true, - duration: 1000, - easing: 'easeOutQuart' } + }, + elements: { + line: { + borderWidth: 2 + }, + point: { + radius: 4, + hoverRadius: 6 + } + }, + animation: { + duration: 1000, + easing: 'easeOutQuart' } - """; - default -> """ - { - responsive: true, - maintainAspectRatio: false, - plugins: { - legend: { - position: 'bottom' + } + """; + case "polarArea" -> """ + { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + position: 'right', + labels: { + usePointStyle: true, + padding: 15, + font: { size: 12 } } }, - animation: { - duration: 800 + tooltip: { + backgroundColor: 'rgba(0,0,0,0.8)', + titleFont: { size: 14, weight: 'bold' }, + bodyFont: { size: 13 }, + padding: 12, + cornerRadius: 8 } + }, + scales: { + r: { + beginAtZero: true, + grid: { + color: 'rgba(0,0,0,0.1)' + } + } + }, + animation: { + animateRotate: true, + animateScale: true, + duration: 1000, + easing: 'easeOutQuart' } - """; + } + """; + default -> """ + { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + position: 'bottom' + } + }, + animation: { + duration: 800 + } + } + """; }; } private String formatMarkdown(String text) { - if (text == null) return ""; + if (text == null) + return ""; // Einfache Markdown-Formatierung - return text - .replace("\n", "
") - .replaceAll("\\*\\*(.+?)\\*\\*", "$1") - .replaceAll("\\*(.+?)\\*", "$1") - .replaceAll("`(.+?)`", "$1"); + return text.replace("\n", "
").replaceAll("\\*\\*(.+?)\\*\\*", "$1") + .replaceAll("\\*(.+?)\\*", "$1").replaceAll("`(.+?)`", "$1"); } private void scrollToBottom() { - chatContainer.getElement().executeJs( - "this.parentElement.scrollTop = this.parentElement.scrollHeight"); + chatContainer.getElement().executeJs("this.parentElement.scrollTop = this.parentElement.scrollHeight"); } @Override diff --git a/src/main/java/de/assecutor/votianlt/pages/view/UserMessagesView.java b/src/main/java/de/assecutor/votianlt/pages/view/UserMessagesView.java index b89a124..6871cb1 100644 --- a/src/main/java/de/assecutor/votianlt/pages/view/UserMessagesView.java +++ b/src/main/java/de/assecutor/votianlt/pages/view/UserMessagesView.java @@ -21,12 +21,12 @@ import de.assecutor.votianlt.model.MessageOrigin; import de.assecutor.votianlt.model.MessageType; import de.assecutor.votianlt.pages.service.AppUserService; import de.assecutor.votianlt.service.MessageService; +import de.assecutor.votianlt.util.DateTimeFormatUtil; import jakarta.annotation.security.RolesAllowed; import lombok.extern.slf4j.Slf4j; import org.bson.types.ObjectId; import java.time.LocalDateTime; -import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.Comparator; import java.util.LinkedHashMap; @@ -46,18 +46,17 @@ public class UserMessagesView extends Main implements HasUrlParameter { private String participantKey; private VerticalLayout contentLayout; - private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm"); public UserMessagesView(AppUserService appUserService, MessageService messageService) { this.appUserService = appUserService; this.messageService = messageService; - + // Create main layout contentLayout = new VerticalLayout(); contentLayout.setPadding(true); contentLayout.setSpacing(true); contentLayout.setWidthFull(); - + add(contentLayout); } @@ -79,15 +78,15 @@ public class UserMessagesView extends Main implements HasUrlParameter { log.debug("Could not resolve AppUser for participant key {}: {}", participantKey, e.getMessage()); } - String clientName = client != null ? - client.getVorname() + " " + client.getNachname() : Optional.ofNullable(participantKey).orElse("Unbekannter Teilnehmer"); + String clientName = client != null ? client.getVorname() + " " + client.getNachname() + : Optional.ofNullable(participantKey).orElse("Unbekannter Teilnehmer"); HorizontalLayout headerLayout = createHeaderLayout(clientName); contentLayout.add(headerLayout); List conversation = messageService.getMessagesForAppUserAscending(participantKey); - Map> messagesByType = conversation.stream() - .collect(Collectors.groupingBy(message -> Optional.ofNullable(message.getMessageType()).orElse(MessageType.GENERAL))); + Map> messagesByType = conversation.stream().collect(Collectors + .groupingBy(message -> Optional.ofNullable(message.getMessageType()).orElse(MessageType.GENERAL))); VerticalLayout generalSection = createGeneralMessagesSection(messagesByType.get(MessageType.GENERAL)); @@ -99,14 +98,14 @@ public class UserMessagesView extends Main implements HasUrlParameter { private HorizontalLayout createHeaderLayout(String clientName) { Button backButton = new Button("Zurück", VaadinIcon.ARROW_LEFT.create()); backButton.addClickListener(e -> UI.getCurrent().navigate("messages")); - + H2 title = new H2("Nachrichten mit " + clientName); - + HorizontalLayout layout = new HorizontalLayout(backButton, title); layout.setWidthFull(); layout.setAlignItems(com.vaadin.flow.component.orderedlayout.FlexComponent.Alignment.CENTER); layout.setSpacing(true); - + return layout; } @@ -127,24 +126,18 @@ public class UserMessagesView extends Main implements HasUrlParameter { sortedMessages.addAll(generalMessages); } - sortedMessages.sort(Comparator.comparing(Message::getCreatedAt, Comparator.nullsLast(LocalDateTime::compareTo))); + sortedMessages + .sort(Comparator.comparing(Message::getCreatedAt, Comparator.nullsLast(LocalDateTime::compareTo))); Message latest = sortedMessages.isEmpty() ? null : sortedMessages.get(sortedMessages.size() - 1); int unreadCount = (int) sortedMessages.stream() - .filter(message -> message.getOrigin() == MessageOrigin.CLIENT && !message.isRead()) - .count(); + .filter(message -> message.getOrigin() == MessageOrigin.CLIENT && !message.isRead()).count(); int messageCount = sortedMessages.size(); LocalDateTime lastMessageTime = latest != null ? latest.getCreatedAt() : null; String preview = resolvePreview(latest); - section.add(createMessageCard( - "Allgemeine Unterhaltung", - preview, - lastMessageTime, - messageCount, - unreadCount, - "general" - )); + section.add(createMessageCard("Allgemeine Unterhaltung", preview, lastMessageTime, messageCount, unreadCount, + "general")); return section; } @@ -173,18 +166,11 @@ public class UserMessagesView extends Main implements HasUrlParameter { messages.sort(Comparator.comparing(Message::getCreatedAt, Comparator.nullsLast(LocalDateTime::compareTo))); Message latest = messages.get(messages.size() - 1); int unreadCount = (int) messages.stream() - .filter(message -> message.getOrigin() == MessageOrigin.CLIENT && !message.isRead()) - .count(); + .filter(message -> message.getOrigin() == MessageOrigin.CLIENT && !message.isRead()).count(); String conversationTitle = "Auftrag " + jobKey; - section.add(createMessageCard( - conversationTitle, - resolvePreview(latest), - latest.getCreatedAt(), - messages.size(), - unreadCount, - "job-" + sanitizeConversationId(jobKey) - )); + section.add(createMessageCard(conversationTitle, resolvePreview(latest), latest.getCreatedAt(), + messages.size(), unreadCount, "job-" + sanitizeConversationId(jobKey))); }); return section; @@ -199,14 +185,11 @@ public class UserMessagesView extends Main implements HasUrlParameter { return "[Bildnachricht]"; } - return Optional.ofNullable(message.getContent()) - .map(String::trim) - .orElse(""); + return Optional.ofNullable(message.getContent()).map(String::trim).orElse(""); } - private Div createMessageCard(String conversationTitle, String lastMessagePreview, - LocalDateTime lastMessageTime, int messageCount, - int unreadCount, String conversationId) { + private Div createMessageCard(String conversationTitle, String lastMessagePreview, LocalDateTime lastMessageTime, + int messageCount, int unreadCount, String conversationId) { Div card = new Div(); card.setWidthFull(); card.getStyle().set("padding", "15px"); @@ -217,7 +200,7 @@ public class UserMessagesView extends Main implements HasUrlParameter { card.getStyle().set("margin-bottom", "10px"); card.getStyle().set("max-width", "97.5%"); card.addClassName("message-card"); - + // Hover effect card.getElement().addEventListener("mouseenter", e -> { card.getStyle().set("background-color", "#f5f5f5"); @@ -225,16 +208,16 @@ public class UserMessagesView extends Main implements HasUrlParameter { card.getElement().addEventListener("mouseleave", e -> { card.getStyle().set("background-color", "#ffffff"); }); - + // Title row with unread indicator HorizontalLayout titleRow = new HorizontalLayout(); titleRow.setWidthFull(); titleRow.setAlignItems(com.vaadin.flow.component.orderedlayout.FlexComponent.Alignment.CENTER); - + Span titleSpan = new Span(conversationTitle); titleSpan.getStyle().set("font-weight", "bold"); titleSpan.getStyle().set("font-size", "16px"); - + if (unreadCount > 0) { Span unreadBadge = new Span(String.valueOf(unreadCount)); unreadBadge.getStyle().set("background-color", "var(--lumo-primary-color)"); @@ -247,38 +230,40 @@ public class UserMessagesView extends Main implements HasUrlParameter { } else { titleRow.add(titleSpan); } - + titleRow.expand(titleSpan); - + // Preview text - Span preview = new Span(Optional.ofNullable(lastMessagePreview).filter(s -> !s.isBlank()).orElse("(kein Inhalt)")); + Span preview = new Span( + Optional.ofNullable(lastMessagePreview).filter(s -> !s.isBlank()).orElse("(kein Inhalt)")); preview.getStyle().set("color", "#666666"); preview.getStyle().set("font-size", "14px"); - + // Metadata row HorizontalLayout metaRow = new HorizontalLayout(); metaRow.setWidthFull(); - - Span timeSpan = new Span(lastMessageTime != null ? lastMessageTime.format(DATE_FORMATTER) : "-"); + + Span timeSpan = new Span(lastMessageTime != null ? DateTimeFormatUtil.formatDateTime(lastMessageTime) : "-"); timeSpan.getStyle().set("color", "#999999"); timeSpan.getStyle().set("font-size", "12px"); - + Span countSpan = new Span(messageCount + " Nachrichten"); countSpan.getStyle().set("color", "#999999"); countSpan.getStyle().set("font-size", "12px"); - + metaRow.add(timeSpan, countSpan); metaRow.expand(timeSpan); - + // Add all elements to card VerticalLayout cardContent = new VerticalLayout(titleRow, preview, metaRow); cardContent.setWidthFull(); cardContent.setPadding(false); cardContent.setSpacing(false); card.add(cardContent); - + // Click listener to navigate to message details - card.addClickListener(e -> UI.getCurrent().navigate("message-details/" + participantKey + "/" + conversationId)); + card.addClickListener( + e -> UI.getCurrent().navigate("message-details/" + participantKey + "/" + conversationId)); return card; } diff --git a/src/main/java/de/assecutor/votianlt/repository/JobRepository.java b/src/main/java/de/assecutor/votianlt/repository/JobRepository.java index 76640a8..38dca41 100644 --- a/src/main/java/de/assecutor/votianlt/repository/JobRepository.java +++ b/src/main/java/de/assecutor/votianlt/repository/JobRepository.java @@ -92,10 +92,10 @@ public interface JobRepository extends MongoRepository { long countByIsDraftTrue(); /** - * Findet alle nicht abgeschlossenen Aufträge, die einem bestimmten App-Nutzer zugewiesen sind. - * Excludes jobs with status COMPLETED or CANCELLED. - * Uses explicit query because @Field("app_user") annotation is not always - * respected by Spring Data MongoDB query derivation. + * Findet alle nicht abgeschlossenen Aufträge, die einem bestimmten App-Nutzer + * zugewiesen sind. Excludes jobs with status COMPLETED or CANCELLED. Uses + * explicit query because @Field("app_user") annotation is not always respected + * by Spring Data MongoDB query derivation. */ @Query("{'app_user': ?0, 'status': {'$nin': ['COMPLETED', 'CANCELLED']}}") List findByAppUser(String appUser); @@ -109,8 +109,17 @@ public interface JobRepository extends MongoRepository { /** * Erweiterte Suche: Zeitraum, Auftragsnummer und Status kombiniert */ - @Query("{'createdAt': {'$gte': ?0, '$lte': ?1}, " - + "'jobNumber': {'$regex': ?2, '$options': 'i'}, " + "'status': {'$in': ?3}}") + @Query("{'createdAt': {'$gte': ?0, '$lte': ?1}, " + "'jobNumber': {'$regex': ?2, '$options': 'i'}, " + + "'status': {'$in': ?3}}") List findWithFilters(LocalDateTime startDate, LocalDateTime endDate, String jobNumberPattern, List statusList); + + /** + * Erweiterte Suche mit Benutzerfilter: Zeitraum, Auftragsnummer, Status und + * Ersteller + */ + @Query("{'created_by': ?0, 'createdAt': {'$gte': ?1, '$lte': ?2}, " + + "'jobNumber': {'$regex': ?3, '$options': 'i'}, " + "'status': {'$in': ?4}}") + List findWithFiltersByCreatedBy(String createdBy, LocalDateTime startDate, LocalDateTime endDate, + String jobNumberPattern, List statusList); } diff --git a/src/main/java/de/assecutor/votianlt/repository/MessageRepository.java b/src/main/java/de/assecutor/votianlt/repository/MessageRepository.java index cd3d5d0..3727993 100644 --- a/src/main/java/de/assecutor/votianlt/repository/MessageRepository.java +++ b/src/main/java/de/assecutor/votianlt/repository/MessageRepository.java @@ -13,12 +13,14 @@ import java.util.List; public interface MessageRepository extends MongoRepository { /** - * Find all messages for a specific receiver (AppUser ID), ordered by creation time ascending (oldest first) + * Find all messages for a specific receiver (AppUser ID), ordered by creation + * time ascending (oldest first) */ List findByReceiverOrderByCreatedAtAsc(String receiver); /** - * Find all messages for a specific receiver (AppUser ID), ordered by creation time descending (newest first) + * Find all messages for a specific receiver (AppUser ID), ordered by creation + * time descending (newest first) */ List findByReceiverOrderByCreatedAtDesc(String receiver); diff --git a/src/main/java/de/assecutor/votianlt/repository/PendingDeliveryRepository.java b/src/main/java/de/assecutor/votianlt/repository/PendingDeliveryRepository.java index 7bed118..5310f6c 100644 --- a/src/main/java/de/assecutor/votianlt/repository/PendingDeliveryRepository.java +++ b/src/main/java/de/assecutor/votianlt/repository/PendingDeliveryRepository.java @@ -27,7 +27,8 @@ public interface PendingDeliveryRepository extends MongoRepository findByStatus(DeliveryStatus status); /** - * Find deliveries ready for retry (status = SENT and nextRetryAt is in the past) + * Find deliveries ready for retry (status = SENT and nextRetryAt is in the + * past) */ List findByStatusAndNextRetryAtBefore(DeliveryStatus status, LocalDateTime dateTime); @@ -66,4 +67,3 @@ public interface PendingDeliveryRepository extends MongoRepository s.connected() && id.equals(s.userId())); + return connectedClients.values().stream().anyMatch(s -> s.connected() && id.equals(s.userId())); } /** @@ -175,16 +173,15 @@ public class ClientConnectionService { * @return Set of connected client IDs */ public Set getConnectedClientIds() { - return connectedClients.entrySet().stream() - .filter(e -> e.getValue().connected()) - .map(Map.Entry::getKey) + return connectedClients.entrySet().stream().filter(e -> e.getValue().connected()).map(Map.Entry::getKey) .collect(java.util.stream.Collectors.toSet()); } /** * Gets the connection state for a specific client. * - * @param clientId The client identifier + * @param clientId + * The client identifier * @return ClientState or null if not found */ public ClientState getClientState(String clientId) { @@ -192,8 +189,8 @@ public class ClientConnectionService { } /** - * Scheduled task to send pings to all connected clients. - * Runs based on the configured interval (app.client.ping.interval-seconds). + * Scheduled task to send pings to all connected clients. Runs based on the + * configured interval (app.client.ping.interval-seconds). */ @Scheduled(fixedRateString = "${app.client.ping.interval-seconds:15}000") public void sendPingsToAllClients() { @@ -228,8 +225,8 @@ public class ClientConnectionService { // Client did not respond in time - mark as disconnected ClientState disconnectedState = state.withConnected(false); connectedClients.put(clientId, disconnectedState); - log.warn("Client timed out, marking as disconnected: clientId={}, userId={}", - clientId, state.userId()); + log.warn("Client timed out, marking as disconnected: clientId={}, userId={}", clientId, + state.userId()); continue; } } @@ -246,22 +243,17 @@ public class ClientConnectionService { /** * Sends a ping message to a specific user. * - * @param userId The target user ID (MongoDB ObjectId) + * @param userId + * The target user ID (MongoDB ObjectId) */ private void sendPing(String userId) { try { - Map pingPayload = Map.of( - "type", "ping", - "timestamp", Instant.now().toEpochMilli() - ); + Map pingPayload = Map.of("type", "ping", "timestamp", Instant.now().toEpochMilli()); String json = objectMapper.writeValueAsString(pingPayload); byte[] payload = json.getBytes(StandardCharsets.UTF_8); - SendOptions options = SendOptions.builder() - .qos(1) - .retained(false) - .build(); + SendOptions options = SendOptions.builder().qos(1).retained(false).build(); pluginManager.sendToClient(userId, "ping", payload, options); @@ -276,9 +268,7 @@ public class ClientConnectionService { * @return Number of connected clients */ public int getConnectedClientCount() { - return (int) connectedClients.values().stream() - .filter(ClientState::connected) - .count(); + return (int) connectedClients.values().stream().filter(ClientState::connected).count(); } /** diff --git a/src/main/java/de/assecutor/votianlt/service/CustomerInvoiceService.java b/src/main/java/de/assecutor/votianlt/service/CustomerInvoiceService.java index a24f71a..b50842a 100644 --- a/src/main/java/de/assecutor/votianlt/service/CustomerInvoiceService.java +++ b/src/main/java/de/assecutor/votianlt/service/CustomerInvoiceService.java @@ -85,15 +85,12 @@ public class CustomerInvoiceService { List items = new ArrayList<>(); BigDecimal vatRate = new BigDecimal("0.19"); // 19% MwSt. - CustomerInvoiceItem item1 = new CustomerInvoiceItem( - new BigDecimal("2"), "Std.", "Transportdienstleistung", - new BigDecimal("85.00"), vatRate); - CustomerInvoiceItem item2 = new CustomerInvoiceItem( - new BigDecimal("1"), "Stk.", "Logistikkoordination", - new BigDecimal("120.00"), vatRate); - CustomerInvoiceItem item3 = new CustomerInvoiceItem( - new BigDecimal("50"), "km", "Kilometergebühr", - new BigDecimal("0.60"), vatRate); + CustomerInvoiceItem item1 = new CustomerInvoiceItem(new BigDecimal("2"), "Std.", "Transportdienstleistung", + new BigDecimal("85.00"), vatRate); + CustomerInvoiceItem item2 = new CustomerInvoiceItem(new BigDecimal("1"), "Stk.", "Logistikkoordination", + new BigDecimal("120.00"), vatRate); + CustomerInvoiceItem item3 = new CustomerInvoiceItem(new BigDecimal("50"), "km", "Kilometergebühr", + new BigDecimal("0.60"), vatRate); items.add(item1); items.add(item2); @@ -101,9 +98,8 @@ public class CustomerInvoiceService { invoiceData.setItems(items); // Beträge berechnen - BigDecimal netAmount = items.stream() - .map(CustomerInvoiceItem::getNetTotal) - .reduce(BigDecimal.ZERO, BigDecimal::add); + BigDecimal netAmount = items.stream().map(CustomerInvoiceItem::getNetTotal).reduce(BigDecimal.ZERO, + BigDecimal::add); BigDecimal vatAmount = netAmount.multiply(vatRate); BigDecimal totalAmount = netAmount.add(vatAmount); @@ -124,7 +120,7 @@ public class CustomerInvoiceService { return invoiceData; } - + public byte[] generateCustomerInvoicePdf() throws Exception { // Backward-compatible sample generation CustomerInvoiceData sampleData = createCustomerInvoiceData("customerId", "jobId"); @@ -190,17 +186,13 @@ public class CustomerInvoiceService { StringBuilder itemRows = new StringBuilder(); for (CustomerInvoiceItem item : data.getItems()) { itemRows.append(""); - itemRows.append("") - .append(formatDecimal(item.getQuantity())) - .append(" ").append(nvl(item.getUnit())) - .append(""); + itemRows.append("").append(formatDecimal(item.getQuantity())) + .append(" ").append(nvl(item.getUnit())).append(""); itemRows.append("").append(nvl(item.getDescription())).append(""); - itemRows.append("") - .append(formatCurrency(item.getUnitPrice())) - .append(""); - itemRows.append("") - .append(formatCurrency(item.getNetTotal())) - .append(""); + itemRows.append("").append(formatCurrency(item.getUnitPrice())) + .append(""); + itemRows.append("").append(formatCurrency(item.getNetTotal())) + .append(""); itemRows.append(""); } filledHtml = filledHtml.replace("", itemRows.toString()); @@ -231,12 +223,14 @@ public class CustomerInvoiceService { } private String formatCurrency(BigDecimal amount) { - if (amount == null) return "0,00 €"; + if (amount == null) + return "0,00 €"; return NumberFormat.getCurrencyInstance(Locale.GERMANY).format(amount); } private String formatDecimal(BigDecimal value) { - if (value == null) return "0"; + if (value == null) + return "0"; return NumberFormat.getNumberInstance(Locale.GERMANY).format(value); } diff --git a/src/main/java/de/assecutor/votianlt/service/EmailService.java b/src/main/java/de/assecutor/votianlt/service/EmailService.java index 46b7841..85483ea 100644 --- a/src/main/java/de/assecutor/votianlt/service/EmailService.java +++ b/src/main/java/de/assecutor/votianlt/service/EmailService.java @@ -285,8 +285,8 @@ public class EmailService { body.append("ein neuer Job wurde erfolgreich erstellt:\n\n"); body.append("Job: ").append(job.getJobNumber() != null ? job.getJobNumber() : "Unbekannt").append("\n"); - if (job.getDeliveryCompany() != null) { - body.append("Kunde: ").append(job.getDeliveryCompany()).append("\n"); + if (job.getCustomerSelection() != null && !job.getCustomerSelection().isBlank()) { + body.append("Auftraggeber: ").append(job.getCustomerSelection()).append("\n"); } if (job.getPickupCity() != null || job.getDeliveryCity() != null) { diff --git a/src/main/java/de/assecutor/votianlt/service/JobBroadcaster.java b/src/main/java/de/assecutor/votianlt/service/JobBroadcaster.java new file mode 100644 index 0000000..102abc8 --- /dev/null +++ b/src/main/java/de/assecutor/votianlt/service/JobBroadcaster.java @@ -0,0 +1,71 @@ +package de.assecutor.votianlt.service; + +import com.vaadin.flow.shared.Registration; +import de.assecutor.votianlt.event.JobCreatedEvent; +import de.assecutor.votianlt.model.Job; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Service; + +import java.util.LinkedHashSet; +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; +import java.util.function.Consumer; + +/** + * Broadcaster service that manages listeners for newly created jobs and + * notifies UI components in a thread-safe manner + */ +@Service +@Slf4j +public class JobBroadcaster { + + private final Executor executor = Executors.newSingleThreadExecutor(); + private final LinkedHashSet> listeners = new LinkedHashSet<>(); + + /** + * Register a listener for newly created jobs + * + * @param listener + * Consumer that will be called when a new job is created + * @return Registration object that can be used to unregister the listener + */ + public synchronized Registration register(Consumer listener) { + listeners.add(listener); + log.debug("Registered job listener. Total listeners: {}", listeners.size()); + + return () -> { + synchronized (JobBroadcaster.this) { + listeners.remove(listener); + log.debug("Unregistered job listener. Total listeners: {}", listeners.size()); + } + }; + } + + /** + * Broadcast a job creation to all registered listeners This is called + * asynchronously to avoid blocking the job creation + */ + private synchronized void broadcast(Job job) { + log.debug("Broadcasting job creation to {} listeners", listeners.size()); + for (Consumer listener : listeners) { + executor.execute(() -> { + try { + listener.accept(job); + } catch (Exception e) { + log.error("Error broadcasting job to listener", e); + } + }); + } + } + + /** + * Spring event listener that gets called when a JobCreatedEvent is published + */ + @EventListener + public void onJobCreated(JobCreatedEvent event) { + Job job = event.getJob(); + log.info("JobBroadcaster received event for job {}", job.getJobNumber()); + broadcast(job); + } +} diff --git a/src/main/java/de/assecutor/votianlt/service/JobStatisticsService.java b/src/main/java/de/assecutor/votianlt/service/JobStatisticsService.java index ad6fc82..72fca38 100644 --- a/src/main/java/de/assecutor/votianlt/service/JobStatisticsService.java +++ b/src/main/java/de/assecutor/votianlt/service/JobStatisticsService.java @@ -19,8 +19,8 @@ import java.util.Map; import java.util.Set; /** - * Service for job statistics and aggregations. - * Provides data for MCP tools and reporting. + * Service for job statistics and aggregations. Provides data for MCP tools and + * reporting. */ @Service @Slf4j @@ -28,7 +28,7 @@ public class JobStatisticsService { private final JobRepository jobRepository; private final TaskRepository taskRepository; - + public JobStatisticsService(JobRepository jobRepository, TaskRepository taskRepository) { this.jobRepository = jobRepository; this.taskRepository = taskRepository; @@ -83,10 +83,8 @@ public class JobStatisticsService { */ public BigDecimal getTotalRevenue() { List allJobs = jobRepository.findAll(); - return allJobs.stream() - .map(Job::getPrice) - .filter(price -> price != null) - .reduce(BigDecimal.ZERO, BigDecimal::add); + return allJobs.stream().map(Job::getPrice).filter(price -> price != null).reduce(BigDecimal.ZERO, + BigDecimal::add); } /** @@ -110,10 +108,8 @@ public class JobStatisticsService { * Get top customers by revenue. */ public List> getTopCustomersByRevenue(int limit) { - return getRevenueByCustomer().entrySet().stream() - .sorted((a, b) -> b.getValue().compareTo(a.getValue())) - .limit(limit) - .toList(); + return getRevenueByCustomer().entrySet().stream().sorted((a, b) -> b.getValue().compareTo(a.getValue())) + .limit(limit).toList(); } /** @@ -162,7 +158,8 @@ public class JobStatisticsService { if (jobs.isEmpty()) { String containsRegex = ".*" + escapedCustomer + ".*"; jobs = jobRepository.findByCustomerSelectionIgnoreCase(containsRegex); - log.debug("getJobsByCustomer('{}') - contains regex: '{}' - found {} jobs", customer, containsRegex, jobs.size()); + log.debug("getJobsByCustomer('{}') - contains regex: '{}' - found {} jobs", customer, containsRegex, + jobs.size()); } return jobs; @@ -227,12 +224,8 @@ public class JobStatisticsService { * Get all available customer names for autocomplete/filtering. */ public List getAllCustomerNames() { - return jobRepository.findAll().stream() - .map(Job::getCustomerSelection) - .filter(c -> c != null && !c.isBlank()) - .distinct() - .sorted() - .toList(); + return jobRepository.findAll().stream().map(Job::getCustomerSelection).filter(c -> c != null && !c.isBlank()) + .distinct().sorted().toList(); } /** @@ -261,9 +254,7 @@ public class JobStatisticsService { * Get total revenue for a customer. */ public BigDecimal getTotalRevenueForCustomer(String customer) { - return getJobsByCustomer(customer).stream() - .map(Job::getPrice) - .filter(price -> price != null) + return getJobsByCustomer(customer).stream().map(Job::getPrice).filter(price -> price != null) .reduce(BigDecimal.ZERO, BigDecimal::add); } @@ -275,9 +266,7 @@ public class JobStatisticsService { if (customerJobs.isEmpty()) { return 0.0; } - long completed = customerJobs.stream() - .filter(j -> j.getStatus() == JobStatus.COMPLETED) - .count(); + long completed = customerJobs.stream().filter(j -> j.getStatus() == JobStatus.COMPLETED).count(); return (double) completed / customerJobs.size() * 100.0; } @@ -289,11 +278,8 @@ public class JobStatisticsService { LocalDateTime yearStart = LocalDateTime.of(year, 1, 1, 0, 0); LocalDateTime yearEnd = LocalDateTime.of(year, 12, 31, 23, 59, 59); - List customerJobs = getJobsByCustomer(customer).stream() - .filter(j -> j.getCreatedAt() != null - && !j.getCreatedAt().isBefore(yearStart) - && !j.getCreatedAt().isAfter(yearEnd)) - .toList(); + List customerJobs = getJobsByCustomer(customer).stream().filter(j -> j.getCreatedAt() != null + && !j.getCreatedAt().isBefore(yearStart) && !j.getCreatedAt().isAfter(yearEnd)).toList(); // Initialize all months with 0 for (Month month : Month.values()) { @@ -317,9 +303,7 @@ public class JobStatisticsService { if (status == null) { return customerJobs; } - return customerJobs.stream() - .filter(j -> j.getStatus() == status) - .toList(); + return customerJobs.stream().filter(j -> j.getStatus() == status).toList(); } /** @@ -351,7 +335,8 @@ public class JobStatisticsService { } } - // Third: extract potential customer name from query and check if it matches a customer + // Third: extract potential customer name from query and check if it matches a + // customer // This handles cases like "cAPPacity GmbH" matching "cAPPacity GmbH & Co. KG" String extractedName = extractCustomerNameFromQuery(query); if (extractedName != null) { @@ -360,13 +345,15 @@ public class JobStatisticsService { String lowerCustomer = customer.toLowerCase(); // Check if customer name starts with the extracted name, or contains it if (lowerCustomer.startsWith(lowerExtracted) || lowerCustomer.contains(lowerExtracted)) { - log.debug("findMatchingCustomer - Extracted name '{}' matches customer: '{}'", extractedName, customer); + log.debug("findMatchingCustomer - Extracted name '{}' matches customer: '{}'", extractedName, + customer); return customer; } } } - // Fourth: word match (any significant word from the query matches customer name) + // Fourth: word match (any significant word from the query matches customer + // name) String[] queryWords = lowerQuery.split("\\s+"); for (String customer : allCustomers) { String lowerCustomer = customer.toLowerCase(); @@ -384,18 +371,15 @@ public class JobStatisticsService { } /** - * Extract potential customer name from a query string. - * Looks for patterns like "firma X", "kunde X", "für X", etc. + * Extract potential customer name from a query string. Looks for patterns like + * "firma X", "kunde X", "für X", etc. */ private String extractCustomerNameFromQuery(String query) { String lowerQuery = query.toLowerCase(); // Patterns that typically precede a customer name - String[] patterns = { - "firma ", "kunde ", "kunden ", "unternehmen ", - "für die firma ", "für den kunden ", "von der firma ", "vom kunden ", - "der firma ", "des kunden ", "bei " - }; + String[] patterns = { "firma ", "kunde ", "kunden ", "unternehmen ", "für die firma ", "für den kunden ", + "von der firma ", "vom kunden ", "der firma ", "des kunden ", "bei " }; for (String pattern : patterns) { int idx = lowerQuery.indexOf(pattern); @@ -421,10 +405,8 @@ public class JobStatisticsService { * Remove common trailing words from extracted customer name. */ private String removeTrailingCommonWords(String text) { - String[] trailingPatterns = { - " an$", " anzeigen$", " zeigen$", " auflisten$", " liste$", - " status$", " mit status$", " die$", " der$", " das$" - }; + String[] trailingPatterns = { " an$", " anzeigen$", " zeigen$", " auflisten$", " liste$", " status$", + " mit status$", " die$", " der$", " das$" }; String result = text; for (String pattern : trailingPatterns) { @@ -435,12 +417,11 @@ public class JobStatisticsService { } /** - * Check if a word is a common German/English word that should be ignored in matching. + * Check if a word is a common German/English word that should be ignored in + * matching. */ private boolean isCommonWord(String word) { - return Set.of( - "zeige", "alle", "jobs", "der", "die", "das", "für", "von", "mit", "und", - "firma", "kunde", "status", "welche", "sind", "gmbh", "show", "all", "the" - ).contains(word.toLowerCase()); + return Set.of("zeige", "alle", "jobs", "der", "die", "das", "für", "von", "mit", "und", "firma", "kunde", + "status", "welche", "sind", "gmbh", "show", "all", "the").contains(word.toLowerCase()); } } diff --git a/src/main/java/de/assecutor/votianlt/service/MessageBadgeUpdateService.java b/src/main/java/de/assecutor/votianlt/service/MessageBadgeUpdateService.java index 8f69442..9953776 100644 --- a/src/main/java/de/assecutor/votianlt/service/MessageBadgeUpdateService.java +++ b/src/main/java/de/assecutor/votianlt/service/MessageBadgeUpdateService.java @@ -12,26 +12,27 @@ import java.util.concurrent.Executor; import java.util.concurrent.Executors; /** - * Service that listens for message-related events and notifies registered UI components - * to update their message badges (e.g., in the sidebar navigation) + * Service that listens for message-related events and notifies registered UI + * components to update their message badges (e.g., in the sidebar navigation) */ @Service @Slf4j public class MessageBadgeUpdateService { - + private final Executor executor = Executors.newSingleThreadExecutor(); private final LinkedHashSet listeners = new LinkedHashSet<>(); - + /** * Register a listener that will be called when message badge should be updated * - * @param listener Runnable that will be called when badge update is needed + * @param listener + * Runnable that will be called when badge update is needed * @return Registration object that can be used to unregister the listener */ public synchronized Registration register(Runnable listener) { listeners.add(listener); log.debug("Registered badge update listener. Total listeners: {}", listeners.size()); - + return () -> { synchronized (MessageBadgeUpdateService.this) { listeners.remove(listener); @@ -39,7 +40,7 @@ public class MessageBadgeUpdateService { } }; } - + /** * Notify all registered listeners that badge should be updated */ @@ -55,7 +56,7 @@ public class MessageBadgeUpdateService { }); } } - + /** * Spring event listener for message read status changes */ @@ -64,7 +65,7 @@ public class MessageBadgeUpdateService { log.debug("MessageBadgeUpdateService received MessageReadStatusChangedEvent"); notifyListeners(); } - + /** * Spring event listener for new messages received */ @@ -74,4 +75,3 @@ public class MessageBadgeUpdateService { notifyListeners(); } } - diff --git a/src/main/java/de/assecutor/votianlt/service/MessageBroadcaster.java b/src/main/java/de/assecutor/votianlt/service/MessageBroadcaster.java index 0d4680d..7dd24d6 100644 --- a/src/main/java/de/assecutor/votianlt/service/MessageBroadcaster.java +++ b/src/main/java/de/assecutor/votianlt/service/MessageBroadcaster.java @@ -13,26 +13,27 @@ import java.util.concurrent.Executors; import java.util.function.Consumer; /** - * Broadcaster service that manages listeners for incoming messages - * and notifies UI components in a thread-safe manner + * Broadcaster service that manages listeners for incoming messages and notifies + * UI components in a thread-safe manner */ @Service @Slf4j public class MessageBroadcaster { - + private final Executor executor = Executors.newSingleThreadExecutor(); private final LinkedHashSet> listeners = new LinkedHashSet<>(); - + /** * Register a listener for incoming messages * - * @param listener Consumer that will be called when a new message arrives + * @param listener + * Consumer that will be called when a new message arrives * @return Registration object that can be used to unregister the listener */ public synchronized Registration register(Consumer listener) { listeners.add(listener); log.debug("Registered message listener. Total listeners: {}", listeners.size()); - + return () -> { synchronized (MessageBroadcaster.this) { listeners.remove(listener); @@ -40,10 +41,10 @@ public class MessageBroadcaster { } }; } - + /** - * Broadcast a message to all registered listeners - * This is called asynchronously to avoid blocking the message reception + * Broadcast a message to all registered listeners This is called asynchronously + * to avoid blocking the message reception */ private synchronized void broadcast(Message message) { log.debug("Broadcasting message to {} listeners", listeners.size()); @@ -57,15 +58,16 @@ public class MessageBroadcaster { }); } } - + /** - * Spring event listener that gets called when a MessageReceivedEvent is published + * Spring event listener that gets called when a MessageReceivedEvent is + * published */ @EventListener public void onMessageReceived(MessageReceivedEvent event) { Message message = event.getMessage(); - log.info("MessageBroadcaster received event for message with origin {} for receiver {}", - message.getOrigin(), message.getReceiver()); + log.info("MessageBroadcaster received event for message with origin {} for receiver {}", message.getOrigin(), + message.getReceiver()); broadcast(message); } } diff --git a/src/main/java/de/assecutor/votianlt/service/MessageService.java b/src/main/java/de/assecutor/votianlt/service/MessageService.java index d649719..9c78fe2 100644 --- a/src/main/java/de/assecutor/votianlt/service/MessageService.java +++ b/src/main/java/de/assecutor/votianlt/service/MessageService.java @@ -28,9 +28,9 @@ public class MessageService { private final JobRepository jobRepository; private final MqttPublisher mqttPublisher; private final ApplicationEventPublisher eventPublisher; - - public MessageService(MessageRepository messageRepository, JobRepository jobRepository, - MqttPublisher mqttPublisher, ApplicationEventPublisher eventPublisher) { + + public MessageService(MessageRepository messageRepository, JobRepository jobRepository, MqttPublisher mqttPublisher, + ApplicationEventPublisher eventPublisher) { this.messageRepository = messageRepository; this.jobRepository = jobRepository; this.mqttPublisher = mqttPublisher; @@ -47,15 +47,17 @@ public class MessageService { /** * Send a general message to a client via MQTT - * @param content Message content - * @param receiver AppUser ID (clientId) + * + * @param content + * Message content + * @param receiver + * AppUser ID (clientId) */ public Message sendGeneralMessageToClient(String content, String receiver) { return sendGeneralMessageToClient(content, receiver, MessageContentType.TEXT); } - public Message sendGeneralMessageToClient(String content, String receiver, - MessageContentType contentType) { + public Message sendGeneralMessageToClient(String content, String receiver, MessageContentType contentType) { Message message = new Message(content, receiver, MessageOrigin.SERVER, contentType); message = saveMessage(message); publishMessageToMqtt(message, receiver); @@ -64,21 +66,25 @@ public class MessageService { /** * Send a job-related message to a client via MQTT - * @param content Message content - * @param receiver AppUser ID (clientId) - * @param jobId Job ObjectId - * @param jobNumber Job number + * + * @param content + * Message content + * @param receiver + * AppUser ID (clientId) + * @param jobId + * Job ObjectId + * @param jobNumber + * Job number */ - public Message sendJobMessageToClient(String content, String receiver, - ObjectId jobId, String jobNumber) { + public Message sendJobMessageToClient(String content, String receiver, ObjectId jobId, String jobNumber) { return sendJobMessageToClient(content, receiver, MessageContentType.TEXT, jobId, jobNumber); } - public Message sendJobMessageToClient(String content, String receiver, - MessageContentType contentType, ObjectId jobId, String jobNumber) { + public Message sendJobMessageToClient(String content, String receiver, MessageContentType contentType, + ObjectId jobId, String jobNumber) { JobContext context = resolveJobContext(jobId, jobNumber); - Message message = new Message(content, receiver, MessageOrigin.SERVER, contentType, - context.jobId(), context.jobNumber()); + Message message = new Message(content, receiver, MessageOrigin.SERVER, contentType, context.jobId(), + context.jobNumber()); message = saveMessage(message); publishMessageToMqtt(message, receiver); return message; @@ -86,7 +92,9 @@ public class MessageService { /** * Handle incoming message from a client - * @param payload Inbound message payload where receiver = AppUser ID (clientId) + * + * @param payload + * Inbound message payload where receiver = AppUser ID (clientId) */ public Message receiveMessageFromClient(ChatMessageInboundPayload payload) { Message message; @@ -94,18 +102,17 @@ public class MessageService { if (payload.hasJobContext()) { JobContext context = resolveJobContext(payload.jobId(), payload.jobNumber()); // receiver = AppUser ID (clientId) - message = new Message(payload.content(), payload.receiver(), - MessageOrigin.CLIENT, contentType, context.jobId(), context.jobNumber()); + message = new Message(payload.content(), payload.receiver(), MessageOrigin.CLIENT, contentType, + context.jobId(), context.jobNumber()); } else { // receiver = AppUser ID (clientId) - message = new Message(payload.content(), payload.receiver(), - MessageOrigin.CLIENT, contentType); + message = new Message(payload.content(), payload.receiver(), MessageOrigin.CLIENT, contentType); } message = saveMessage(message); // Publish event to notify UI components about the new message - log.info("Publishing MessageReceivedEvent for message with origin {} for receiver {}", - message.getOrigin(), message.getReceiver()); + log.info("Publishing MessageReceivedEvent for message with origin {} for receiver {}", message.getOrigin(), + message.getReceiver()); eventPublisher.publishEvent(new MessageReceivedEvent(this, message)); return message; @@ -154,8 +161,11 @@ public class MessageService { } /** - * Get all messages for a specific AppUser (by receiver field), ordered by creation time ascending (oldest first) - * @param appUserId AppUser ID (clientId) + * Get all messages for a specific AppUser (by receiver field), ordered by + * creation time ascending (oldest first) + * + * @param appUserId + * AppUser ID (clientId) */ public List getMessagesForAppUserAscending(String appUserId) { if (appUserId == null || appUserId.isBlank()) { @@ -165,8 +175,11 @@ public class MessageService { } /** - * Get all messages for a specific AppUser (by receiver field), ordered by creation time descending - * @param appUserId AppUser ID (clientId) + * Get all messages for a specific AppUser (by receiver field), ordered by + * creation time descending + * + * @param appUserId + * AppUser ID (clientId) */ public List getMessagesForAppUserDescending(String appUserId) { if (appUserId == null || appUserId.isBlank()) { @@ -225,6 +238,13 @@ public class MessageService { return messageRepository.countByReceiverAndIsReadFalse(receiver); } + /** + * Count all messages in the system + */ + public int countAllMessages() { + return (int) messageRepository.count(); + } + /** * Get a message by ID */ @@ -326,8 +346,7 @@ public class MessageService { } return fuzzyMatches.stream() - .filter(job -> normalizeJobToken(job.getJobNumber()).equalsIgnoreCase(normalizedCandidate)) - .findFirst(); + .filter(job -> normalizeJobToken(job.getJobNumber()).equalsIgnoreCase(normalizedCandidate)).findFirst(); } private String normalizeJobToken(String value) { diff --git a/src/main/java/de/assecutor/votianlt/service/MonthlySchedulerService.java b/src/main/java/de/assecutor/votianlt/service/MonthlySchedulerService.java index 9df13df..56db352 100644 --- a/src/main/java/de/assecutor/votianlt/service/MonthlySchedulerService.java +++ b/src/main/java/de/assecutor/votianlt/service/MonthlySchedulerService.java @@ -9,7 +9,8 @@ import java.time.LocalDate; import java.time.YearMonth; /** - * Service für monatliche Scheduler-Aufgaben, die am letzten Tag des Monats (Ultimo) ausgeführt werden. + * Service für monatliche Scheduler-Aufgaben, die am letzten Tag des Monats + * (Ultimo) ausgeführt werden. */ @Service public class MonthlySchedulerService { @@ -17,8 +18,8 @@ public class MonthlySchedulerService { private static final Logger logger = LoggerFactory.getLogger(MonthlySchedulerService.class); /** - * Scheduler, der täglich um 23:00 Uhr läuft und prüft, ob heute der letzte Tag des Monats ist. - * Wenn ja, wird die monatliche Aufgabe ausgeführt. + * Scheduler, der täglich um 23:00 Uhr läuft und prüft, ob heute der letzte Tag + * des Monats ist. Wenn ja, wird die monatliche Aufgabe ausgeführt. */ @Scheduled(cron = "0 0 23 * * *") // Täglich um 23:00 Uhr public void checkAndRunMonthlyTask() { @@ -29,17 +30,16 @@ public class MonthlySchedulerService { logger.info("Heute ist der letzte Tag des Monats ({}). Führe monatliche Aufgabe aus.", today); runMonthlyUltimoTask(); } else { - logger.debug("Heute ({}) ist nicht der letzte Tag des Monats. Nächster Ultimo: {}", - today, lastDayOfMonth); + logger.debug("Heute ({}) ist nicht der letzte Tag des Monats. Nächster Ultimo: {}", today, lastDayOfMonth); } } /** - * Alternative Implementierung: Direkter Cron-Ausdruck für den letzten Tag des Monats. - * Dieser Scheduler läuft am letzten Tag jedes Monats um 23:00 Uhr. + * Alternative Implementierung: Direkter Cron-Ausdruck für den letzten Tag des + * Monats. Dieser Scheduler läuft am letzten Tag jedes Monats um 23:00 Uhr. * - * Hinweis: Dieser Ansatz ist weniger flexibel, da er nicht alle Monate korrekt abdeckt. - * Der obige Ansatz mit täglicher Prüfung ist robuster. + * Hinweis: Dieser Ansatz ist weniger flexibel, da er nicht alle Monate korrekt + * abdeckt. Der obige Ansatz mit täglicher Prüfung ist robuster. */ // @Scheduled(cron = "0 0 23 L * *") // Am letzten Tag des Monats um 23:00 Uhr public void runMonthlyUltimoTaskDirect() { @@ -48,8 +48,8 @@ public class MonthlySchedulerService { } /** - * Die eigentliche monatliche Aufgabe, die am Ultimo ausgeführt wird. - * Hier können Sie Ihre spezifische Geschäftslogik implementieren. + * Die eigentliche monatliche Aufgabe, die am Ultimo ausgeführt wird. Hier + * können Sie Ihre spezifische Geschäftslogik implementieren. */ private void runMonthlyUltimoTask() { try { diff --git a/src/main/java/de/assecutor/votianlt/service/SystemInvoiceService.java b/src/main/java/de/assecutor/votianlt/service/SystemInvoiceService.java index 9899a60..3e38647 100644 --- a/src/main/java/de/assecutor/votianlt/service/SystemInvoiceService.java +++ b/src/main/java/de/assecutor/votianlt/service/SystemInvoiceService.java @@ -40,7 +40,8 @@ public class SystemInvoiceService { // Set sample data data.setInvoiceNumber("HHA-2021-007"); data.setInvoiceDate("19.07.2021"); - data.setInvoiceText("Gemäß unserem Nutzungsvertrag zu der Bestellnummer 45519389 berechnen wir Ihnen für den Monat Juli 2021 wie folgt:"); + data.setInvoiceText( + "Gemäß unserem Nutzungsvertrag zu der Bestellnummer 45519389 berechnen wir Ihnen für den Monat Juli 2021 wie folgt:"); data.setRecipientName("Hamburger Hochbahn AG"); data.setRecipientDepartment("Kreditorenbuchhaltung"); @@ -71,19 +72,25 @@ public class SystemInvoiceService { // Replace invoice data placeholders filledHtml = filledHtml.replace("HHA-2021-007", data.getInvoiceNumber() != null ? data.getInvoiceNumber() : ""); filledHtml = filledHtml.replace("19.07.2021", data.getInvoiceDate() != null ? data.getInvoiceDate() : ""); - filledHtml = filledHtml.replace("Gemäß unserem Nutzungsvertrag zu der Bestellnummer 45519389 berechnen wir Ihnen für den Monat Juli 2021 wie folgt:", - data.getInvoiceText() != null ? data.getInvoiceText() : ""); + filledHtml = filledHtml.replace( + "Gemäß unserem Nutzungsvertrag zu der Bestellnummer 45519389 berechnen wir Ihnen für den Monat Juli 2021 wie folgt:", + data.getInvoiceText() != null ? data.getInvoiceText() : ""); // Replace recipient address - filledHtml = filledHtml.replace("Hamburger Hochbahn AG", data.getRecipientName() != null ? data.getRecipientName() : ""); - filledHtml = filledHtml.replace("Kreditorenbuchhaltung", data.getRecipientDepartment() != null ? data.getRecipientDepartment() : ""); - filledHtml = filledHtml.replace("Steinstraße 20", data.getRecipientStreet() != null ? data.getRecipientStreet() : ""); - filledHtml = filledHtml.replace("20095 Hamburg", data.getRecipientCity() != null ? data.getRecipientCity() : ""); + filledHtml = filledHtml.replace("Hamburger Hochbahn AG", + data.getRecipientName() != null ? data.getRecipientName() : ""); + filledHtml = filledHtml.replace("Kreditorenbuchhaltung", + data.getRecipientDepartment() != null ? data.getRecipientDepartment() : ""); + filledHtml = filledHtml.replace("Steinstraße 20", + data.getRecipientStreet() != null ? data.getRecipientStreet() : ""); + filledHtml = filledHtml.replace("20095 Hamburg", + data.getRecipientCity() != null ? data.getRecipientCity() : ""); // Replace invoice items if (data.getInvoiceItems() != null && !data.getInvoiceItems().isEmpty()) { SystemInvoiceItem item = data.getInvoiceItems().getFirst(); - filledHtml = filledHtml.replace("Mtl. Lizenzgebühr »ILLT«", item.getDescription() != null ? item.getDescription() : ""); + filledHtml = filledHtml.replace("Mtl. Lizenzgebühr »ILLT«", + item.getDescription() != null ? item.getDescription() : ""); } // Replace amounts diff --git a/src/main/java/de/assecutor/votianlt/util/DateTimeFormatUtil.java b/src/main/java/de/assecutor/votianlt/util/DateTimeFormatUtil.java new file mode 100644 index 0000000..0b42e02 --- /dev/null +++ b/src/main/java/de/assecutor/votianlt/util/DateTimeFormatUtil.java @@ -0,0 +1,80 @@ +package de.assecutor.votianlt.util; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; + +/** + * Utility class for consistent date and time formatting across the application. + * All dates and times are formatted in German format: "TT.MM.JJJJ, HH:MM Uhr" + */ +public class DateTimeFormatUtil { + + private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("dd.MM.yyyy, HH:mm 'Uhr'"); + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("dd.MM.yyyy"); + private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("HH:mm 'Uhr'"); + + private DateTimeFormatUtil() { + // Utility class, no instantiation + } + + /** + * Formats a LocalDateTime to German format: "TT.MM.JJJJ, HH:MM Uhr" + * + * @param dateTime + * the LocalDateTime to format + * @return formatted string or empty string if dateTime is null + */ + public static String formatDateTime(LocalDateTime dateTime) { + if (dateTime == null) { + return ""; + } + return dateTime.format(DATE_TIME_FORMATTER); + } + + /** + * Formats a LocalDate to German format: "TT.MM.JJJJ" + * + * @param date + * the LocalDate to format + * @return formatted string or empty string if date is null + */ + public static String formatDate(LocalDate date) { + if (date == null) { + return ""; + } + return date.format(DATE_FORMATTER); + } + + /** + * Formats only the time part: "HH:MM Uhr" + * + * @param dateTime + * the LocalDateTime to format + * @return formatted time string or empty string if dateTime is null + */ + public static String formatTime(LocalDateTime dateTime) { + if (dateTime == null) { + return ""; + } + return dateTime.format(TIME_FORMATTER); + } + + /** + * Returns the date time formatter for direct use + * + * @return the DateTimeFormatter instance + */ + public static DateTimeFormatter getDateTimeFormatter() { + return DATE_TIME_FORMATTER; + } + + /** + * Returns the date formatter for direct use + * + * @return the DateTimeFormatter instance + */ + public static DateTimeFormatter getDateFormatter() { + return DATE_FORMATTER; + } +}