Erweiterungen

This commit is contained in:
2026-02-04 11:33:43 +01:00
parent 5768a37c5e
commit 6279c972f1
89 changed files with 2371 additions and 1941 deletions

View File

@@ -10,8 +10,8 @@ import java.net.URI;
import java.net.URL; import java.net.URL;
/** /**
* Configuration for LLM integration via LM Studio. * Configuration for LLM integration via LM Studio. LM Studio provides an
* LM Studio provides an OpenAI-compatible API. * OpenAI-compatible API.
*/ */
@Configuration @Configuration
@Slf4j @Slf4j
@@ -37,13 +37,16 @@ public class LlmConfig {
// Test 1: Basic connectivity // Test 1: Basic connectivity
testEndpoint(baseUrl + "/v1/models", "GET", null); testEndpoint(baseUrl + "/v1/models", "GET", null);
// Test 2: Chat completions endpoint WITHOUT streaming (POST with minimal payload) // Test 2: Chat completions endpoint WITHOUT streaming (POST with minimal
String testPayload = "{\"model\":\"" + model + "\",\"messages\":[{\"role\":\"user\",\"content\":\"ping\"}],\"max_tokens\":1,\"stream\":false}"; // payload)
String testPayload = "{\"model\":\"" + model
+ "\",\"messages\":[{\"role\":\"user\",\"content\":\"ping\"}],\"max_tokens\":1,\"stream\":false}";
log.info("Test payload (stream=false): {}", testPayload); log.info("Test payload (stream=false): {}", testPayload);
testEndpoint(baseUrl + "/v1/chat/completions", "POST", testPayload); testEndpoint(baseUrl + "/v1/chat/completions", "POST", testPayload);
// Test 3: Chat completions WITH streaming to compare behavior // 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); log.info("Test payload (stream=true): {}", streamPayload);
testEndpoint(baseUrl + "/v1/chat/completions", "POST", streamPayload); testEndpoint(baseUrl + "/v1/chat/completions", "POST", streamPayload);
} }

View File

@@ -15,8 +15,8 @@ import java.util.List;
import java.util.Map; import java.util.Map;
/** /**
* Service for AI-assisted statistics analysis with chart visualization. * Service for AI-assisted statistics analysis with chart visualization. Uses LM
* Uses LM Studio via direct REST client (like aimailassistant) instead of Spring AI. * Studio via direct REST client (like aimailassistant) instead of Spring AI.
*/ */
@Service @Service
@Slf4j @Slf4j
@@ -36,11 +36,8 @@ public class AiStatisticsService {
/** /**
* Response record containing text and optional chart data. * Response record containing text and optional chart data.
*/ */
public record StatisticsResponse( public record StatisticsResponse(String textResponse, String chartType, String chartData) {
String textResponse, }
String chartType,
String chartData
) {}
/** /**
* Analyze a statistics query and return a response with optional visualization. * Analyze a statistics query and return a response with optional visualization.
@@ -48,11 +45,11 @@ public class AiStatisticsService {
public StatisticsResponse analyzeStatisticsQuery(String userQuery) { public StatisticsResponse analyzeStatisticsQuery(String userQuery) {
log.info("Processing statistics query: {}", 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); QueryAnalysis analysis = analyzeQueryType(userQuery);
log.debug("Query analysis - Type: {}, Chart: {}, Customer: {}, Status: {}", log.debug("Query analysis - Type: {}, Chart: {}, Customer: {}, Status: {}", analysis.queryType,
analysis.queryType, analysis.chartType, analysis.chartType, analysis.customerFilter != null ? analysis.customerFilter : "none",
analysis.customerFilter != null ? analysis.customerFilter : "none",
analysis.statusFilter != null ? analysis.statusFilter : "none"); analysis.statusFilter != null ? analysis.statusFilter : "none");
// Gather context (statistics or job list depending on query type) // Gather context (statistics or job list depending on query type)
@@ -62,8 +59,7 @@ public class AiStatisticsService {
String prompt = buildPrompt(userQuery, statisticsContext, analysis); String prompt = buildPrompt(userQuery, statisticsContext, analysis);
// System prompt - different for list vs statistics queries // System prompt - different for list vs statistics queries
String systemPrompt = analysis.queryType.equals("list") String systemPrompt = analysis.queryType.equals("list") ? buildListSystemPrompt()
? buildListSystemPrompt()
: buildStatisticsSystemPrompt(); : buildStatisticsSystemPrompt();
// Call LLM via direct REST client (like aimailassistant) // Call LLM via direct REST client (like aimailassistant)
@@ -74,21 +70,19 @@ public class AiStatisticsService {
return new StatisticsResponse(llmResponse, analysis.chartType, analysis.chartData); return new StatisticsResponse(llmResponse, analysis.chartType, analysis.chartData);
} else { } else {
log.warn("LLM returned null or blank response, using fallback"); log.warn("LLM returned null or blank response, using fallback");
return new StatisticsResponse( return new StatisticsResponse(buildFallbackResponse(analysis), analysis.chartType, analysis.chartData);
buildFallbackResponse(analysis),
analysis.chartType,
analysis.chartData
);
} }
} }
private record QueryAnalysis( private record QueryAnalysis(String queryType, String chartType, String chartData, String customerFilter, // null =
String queryType, // no
String chartType, // filter,
String chartData, // show
String customerFilter, // null = no filter, show all data // all
JobStatus statusFilter // null = no status filter // data
) {} JobStatus statusFilter // null = no status filter
) {
}
private String buildStatisticsSystemPrompt() { private String buildStatisticsSystemPrompt() {
return """ return """
@@ -134,47 +128,42 @@ public class AiStatisticsService {
} }
/** /**
* Detect user-specified chart type from the query. * Detect user-specified chart type from the query. Returns null if no specific
* Returns null if no specific chart type was requested. * chart type was requested.
*/ */
private String detectUserChartTypePreference(String query) { private String detectUserChartTypePreference(String query) {
String lowerQuery = query.toLowerCase(); String lowerQuery = query.toLowerCase();
// Balkendiagramm / Bar Chart // Balkendiagramm / Bar Chart
if (lowerQuery.contains("balken") || lowerQuery.contains("bar chart") || if (lowerQuery.contains("balken") || lowerQuery.contains("bar chart") || lowerQuery.contains("säulen")
lowerQuery.contains("säulen") || lowerQuery.contains("balkendiagramm")) { || lowerQuery.contains("balkendiagramm")) {
return "bar"; return "bar";
} }
// Tortendiagramm / Pie Chart // Tortendiagramm / Pie Chart
if (lowerQuery.contains("torten") || lowerQuery.contains("pie") || if (lowerQuery.contains("torten") || lowerQuery.contains("pie") || lowerQuery.contains("kreis")
lowerQuery.contains("kreis") || lowerQuery.contains("tortendiagramm") || || lowerQuery.contains("tortendiagramm") || lowerQuery.contains("kreisdiagramm")) {
lowerQuery.contains("kreisdiagramm")) {
return "pie"; return "pie";
} }
// Donut / Ring Chart // Donut / Ring Chart
if (lowerQuery.contains("donut") || lowerQuery.contains("ring") || if (lowerQuery.contains("donut") || lowerQuery.contains("ring") || lowerQuery.contains("doughnut")) {
lowerQuery.contains("doughnut")) {
return "doughnut"; return "doughnut";
} }
// Liniendiagramm / Line Chart // Liniendiagramm / Line Chart
if (lowerQuery.contains("linie") || lowerQuery.contains("line") || if (lowerQuery.contains("linie") || lowerQuery.contains("line") || lowerQuery.contains("liniendiagramm")
lowerQuery.contains("liniendiagramm") || lowerQuery.contains("kurve") || || lowerQuery.contains("kurve") || lowerQuery.contains("graph")) {
lowerQuery.contains("graph")) {
return "line"; return "line";
} }
// Flächendiagramm / Area Chart // Flächendiagramm / Area Chart
if (lowerQuery.contains("fläche") || lowerQuery.contains("area") || if (lowerQuery.contains("fläche") || lowerQuery.contains("area") || lowerQuery.contains("flächendiagramm")) {
lowerQuery.contains("flächendiagramm")) {
return "line"; // Line with fill=true return "line"; // Line with fill=true
} }
// Radar Chart // Radar Chart
if (lowerQuery.contains("radar") || lowerQuery.contains("netz") || if (lowerQuery.contains("radar") || lowerQuery.contains("netz") || lowerQuery.contains("spinne")) {
lowerQuery.contains("spinne")) {
return "radar"; return "radar";
} }
@@ -216,31 +205,29 @@ public class AiStatisticsService {
String chartData; String chartData;
// Status-bezogene Anfragen (Statistik, nicht Liste) // Status-bezogene Anfragen (Statistik, nicht Liste)
if ((lowerQuery.contains("status") && (lowerQuery.contains("statistik") || lowerQuery.contains("verteilung") || if ((lowerQuery.contains("status") && (lowerQuery.contains("statistik") || lowerQuery.contains("verteilung")
lowerQuery.contains("übersicht") || lowerQuery.contains("wie viele"))) || || lowerQuery.contains("übersicht") || lowerQuery.contains("wie viele")))
lowerQuery.contains("zählen") || lowerQuery.contains("anzahl")) { || lowerQuery.contains("zählen") || lowerQuery.contains("anzahl")) {
queryType = "status"; queryType = "status";
defaultChartType = "doughnut"; defaultChartType = "doughnut";
chartData = buildStatusChartData(customerFilter); chartData = buildStatusChartData(customerFilter);
} }
// Umsatz-bezogene Anfragen // Umsatz-bezogene Anfragen
else if (lowerQuery.contains("umsatz") || lowerQuery.contains("revenue") || else if (lowerQuery.contains("umsatz") || lowerQuery.contains("revenue") || lowerQuery.contains("einnahmen")) {
lowerQuery.contains("einnahmen")) {
queryType = "revenue"; queryType = "revenue";
defaultChartType = "bar"; defaultChartType = "bar";
chartData = customerFilter != null ? buildCustomerRevenueChartData(customerFilter) : buildRevenueChartData(); chartData = customerFilter != null ? buildCustomerRevenueChartData(customerFilter)
: buildRevenueChartData();
} }
// Trend-bezogene Anfragen // Trend-bezogene Anfragen
else if (lowerQuery.contains("trend") || lowerQuery.contains("monat") || else if (lowerQuery.contains("trend") || lowerQuery.contains("monat") || lowerQuery.contains("entwicklung")
lowerQuery.contains("entwicklung") || lowerQuery.contains("jahr") || || lowerQuery.contains("jahr") || lowerQuery.contains("verlauf")) {
lowerQuery.contains("verlauf")) {
queryType = "trend"; queryType = "trend";
defaultChartType = "line"; defaultChartType = "line";
chartData = buildTrendChartData(customerFilter); chartData = buildTrendChartData(customerFilter);
} }
// Task-bezogene Anfragen // Task-bezogene Anfragen
else if (lowerQuery.contains("task") || lowerQuery.contains("aufgabe") || else if (lowerQuery.contains("task") || lowerQuery.contains("aufgabe") || lowerQuery.contains("erledigt")) {
lowerQuery.contains("erledigt")) {
queryType = "tasks"; queryType = "tasks";
defaultChartType = "doughnut"; defaultChartType = "doughnut";
chartData = buildTaskChartData(); chartData = buildTaskChartData();
@@ -274,7 +261,8 @@ public class AiStatisticsService {
if (lowerQuery.contains("abgeholt") || lowerQuery.contains("picked up")) { if (lowerQuery.contains("abgeholt") || lowerQuery.contains("picked up")) {
return JobStatus.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; return JobStatus.IN_TRANSIT;
} }
if (lowerQuery.contains("zugestellt") || lowerQuery.contains("delivered") || lowerQuery.contains("geliefert")) { 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")) { if (lowerQuery.contains("abgeschlossen") || lowerQuery.contains("completed") || lowerQuery.contains("fertig")) {
return JobStatus.COMPLETED; 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 JobStatus.CANCELLED;
} }
return null; return null;
@@ -294,45 +283,31 @@ public class AiStatisticsService {
*/ */
private boolean isListQuery(String lowerQuery) { private boolean isListQuery(String lowerQuery) {
// Keywords that indicate a list/detail query (not statistics) // Keywords that indicate a list/detail query (not statistics)
boolean hasListKeywords = lowerQuery.contains("zeige alle") || boolean hasListKeywords = lowerQuery.contains("zeige alle") || lowerQuery.contains("liste")
lowerQuery.contains("liste") || || lowerQuery.contains("welche jobs") || lowerQuery.contains("alle jobs")
lowerQuery.contains("welche jobs") || || lowerQuery.contains("alle aufträge") || lowerQuery.contains("zeige die jobs")
lowerQuery.contains("alle jobs") || || lowerQuery.contains("zeige die aufträge") || lowerQuery.contains("zeig mir die")
lowerQuery.contains("alle aufträge") || || lowerQuery.contains("gib mir die");
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) // Keywords that indicate statistics (override list detection)
boolean hasStatsKeywords = lowerQuery.contains("statistik") || boolean hasStatsKeywords = lowerQuery.contains("statistik") || lowerQuery.contains("diagramm")
lowerQuery.contains("diagramm") || || lowerQuery.contains("chart") || lowerQuery.contains("verteilung") || lowerQuery.contains("wie viele")
lowerQuery.contains("chart") || || lowerQuery.contains("anzahl") || lowerQuery.contains("zähle") || lowerQuery.contains("umsatz")
lowerQuery.contains("verteilung") || || lowerQuery.contains("trend") || lowerQuery.contains("torte") || lowerQuery.contains("balken");
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; return hasListKeywords && !hasStatsKeywords;
} }
/** /**
* Detect customer filter from the query. * Detect customer filter from the query. Returns the matching customer name or
* Returns the matching customer name or null if no filter detected. * null if no filter detected.
*/ */
private String detectCustomerFilter(String query) { private String detectCustomerFilter(String query) {
String lowerQuery = query.toLowerCase(); String lowerQuery = query.toLowerCase();
// Keywords that indicate a customer filter // Keywords that indicate a customer filter
String[] filterIndicators = { String[] filterIndicators = { "für ", "von ", "bei ", "kunde ", "firma ", "unternehmen ", "für die firma ",
"für ", "von ", "bei ", "kunde ", "firma ", "unternehmen ", "für den kunden ", "von der firma ", "vom kunden ", "nur ", "ausschließlich ", "speziell " };
"für die firma ", "für den kunden ", "von der firma ", "vom kunden ",
"nur ", "ausschließlich ", "speziell "
};
// Check if any indicator is present // Check if any indicator is present
boolean hasIndicator = false; boolean hasIndicator = false;
@@ -367,15 +342,14 @@ public class AiStatisticsService {
List<String> labels = new ArrayList<>(); List<String> labels = new ArrayList<>();
List<Long> data = new ArrayList<>(); List<Long> data = new ArrayList<>();
// Moderne Farbpalette mit satteren Farben // Moderne Farbpalette mit satteren Farben
List<String> colors = List.of( List<String> colors = List.of("#06b6d4", // CREATED - cyan
"#06b6d4", // CREATED - cyan "#f59e0b", // IN_PROGRESS - amber
"#f59e0b", // IN_PROGRESS - amber "#3b82f6", // PICKUP_SCHEDULED - blau
"#3b82f6", // PICKUP_SCHEDULED - blau "#8b5cf6", // PICKED_UP - violett
"#8b5cf6", // PICKED_UP - violett "#f97316", // IN_TRANSIT - orange
"#f97316", // IN_TRANSIT - orange "#22c55e", // DELIVERED - grün
"#22c55e", // DELIVERED - grün "#6366f1", // COMPLETED - indigo
"#6366f1", // COMPLETED - indigo "#ef4444" // CANCELLED - rot
"#ef4444" // CANCELLED - rot
); );
for (JobStatus status : JobStatus.values()) { for (JobStatus status : JobStatus.values()) {
@@ -402,12 +376,10 @@ public class AiStatisticsService {
} }
// Gradient-ähnliche Farbpalette für Balken // Gradient-ähnliche Farbpalette für Balken
List<String> colors = List.of( List<String> colors = List.of("#6366f1", "#8b5cf6", "#a855f7", "#c084fc", "#d8b4fe", "#e9d5ff", "#f3e8ff",
"#6366f1", "#8b5cf6", "#a855f7", "#c084fc", "#faf5ff", "#ede9fe", "#ddd6fe");
"#d8b4fe", "#e9d5ff", "#f3e8ff", "#faf5ff", return buildChartJsonDouble(labels, data, colors.subList(0, Math.min(labels.size(), colors.size())),
"#ede9fe", "#ddd6fe" "Umsatz (EUR)");
);
return buildChartJsonDouble(labels, data, colors.subList(0, Math.min(labels.size(), colors.size())), "Umsatz (EUR)");
} }
private String buildCustomerRevenueChartData(String customer) { private String buildCustomerRevenueChartData(String customer) {
@@ -418,11 +390,8 @@ public class AiStatisticsService {
long inProgress = statusCounts.getOrDefault(JobStatus.IN_PROGRESS, 0L); long inProgress = statusCounts.getOrDefault(JobStatus.IN_PROGRESS, 0L);
List<String> labels = List.of("Aufträge gesamt", "Abgeschlossen", "In Bearbeitung", "Umsatz (€/100)"); List<String> labels = List.of("Aufträge gesamt", "Abgeschlossen", "In Bearbeitung", "Umsatz (€/100)");
List<Double> data = List.of( List<Double> data = List.of((double) totalJobs, (double) completed, (double) inProgress,
(double) totalJobs, totalRevenue.doubleValue() / 100 // Scale down for better visualization
(double) completed,
(double) inProgress,
totalRevenue.doubleValue() / 100 // Scale down for better visualization
); );
List<String> colors = List.of("#3b82f6", "#22c55e", "#f59e0b", "#6366f1"); List<String> colors = List.of("#3b82f6", "#22c55e", "#f59e0b", "#6366f1");
@@ -435,16 +404,15 @@ public class AiStatisticsService {
? statisticsService.getMonthlyJobCountsForCustomer(currentYear, customerFilter) ? statisticsService.getMonthlyJobCountsForCustomer(currentYear, customerFilter)
: statisticsService.getMonthlyJobCounts(currentYear); : statisticsService.getMonthlyJobCounts(currentYear);
List<String> labels = List.of("Jan", "Feb", "Mär", "Apr", "Mai", "Jun", List<String> labels = List.of("Jan", "Feb", "Mär", "Apr", "Mai", "Jun", "Jul", "Aug", "Sep", "Okt", "Nov",
"Jul", "Aug", "Sep", "Okt", "Nov", "Dez"); "Dez");
List<Long> data = new ArrayList<>(); List<Long> data = new ArrayList<>();
for (Month month : Month.values()) { for (Month month : Month.values()) {
data.add(monthlyData.getOrDefault(month, 0L)); data.add(monthlyData.getOrDefault(month, 0L));
} }
String datasetLabel = customerFilter != null String datasetLabel = customerFilter != null ? String.format("%s - %d", customerFilter, currentYear)
? String.format("%s - %d", customerFilter, currentYear)
: String.format("Aufträge %d", currentYear); : String.format("Aufträge %d", currentYear);
return String.format(""" return String.format("""
@@ -471,10 +439,7 @@ public class AiStatisticsService {
Map<String, Long> taskStats = statisticsService.getTaskCompletionStats(); Map<String, Long> taskStats = statisticsService.getTaskCompletionStats();
List<String> labels = List.of("Erledigt", "Ausstehend"); List<String> labels = List.of("Erledigt", "Ausstehend");
List<Long> data = List.of( List<Long> data = List.of(taskStats.getOrDefault("completed", 0L), taskStats.getOrDefault("pending", 0L));
taskStats.getOrDefault("completed", 0L),
taskStats.getOrDefault("pending", 0L)
);
List<String> colors = List.of("#22c55e", "#f59e0b"); List<String> colors = List.of("#22c55e", "#f59e0b");
return buildChartJson(labels, data, colors, "Aufgaben"); return buildChartJson(labels, data, colors, "Aufgaben");
@@ -485,8 +450,7 @@ public class AiStatisticsService {
? statisticsService.getJobCountsByStatusForCustomer(customerFilter) ? statisticsService.getJobCountsByStatusForCustomer(customerFilter)
: statisticsService.getJobCountsByStatus(); : statisticsService.getJobCountsByStatus();
long total = customerFilter != null long total = customerFilter != null ? statisticsService.getTotalJobCountForCustomer(customerFilter)
? statisticsService.getTotalJobCountForCustomer(customerFilter)
: statisticsService.getTotalJobCount(); : statisticsService.getTotalJobCount();
long completed = statusCounts.getOrDefault(JobStatus.COMPLETED, 0L); long completed = statusCounts.getOrDefault(JobStatus.COMPLETED, 0L);
long inProgress = statusCounts.getOrDefault(JobStatus.IN_PROGRESS, 0L); long inProgress = statusCounts.getOrDefault(JobStatus.IN_PROGRESS, 0L);
@@ -570,8 +534,8 @@ public class AiStatisticsService {
// General statistics (all data) // General statistics (all data)
var statusCounts = statisticsService.getJobCountsByStatus(); var statusCounts = statisticsService.getJobCountsByStatus();
context.append("**Aktuelle Auftragsstatistiken:**\n"); context.append("**Aktuelle Auftragsstatistiken:**\n");
statusCounts.forEach((status, count) -> statusCounts.forEach((status, count) -> context
context.append(String.format("- %s: %d Aufträge\n", status.getDisplayName(), count))); .append(String.format("- %s: %d Aufträge\n", status.getDisplayName(), count)));
context.append(String.format("\n**Gesamtübersicht:**\n")); context.append(String.format("\n**Gesamtübersicht:**\n"));
context.append(String.format("- Gesamtanzahl Aufträge: %d\n", statisticsService.getTotalJobCount())); 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"); context.append("\n**Top 5 Kunden nach Umsatz:**\n");
for (var entry : topCustomers) { for (var entry : topCustomers) {
context.append(String.format("- %s: %.2f EUR\n", context.append(String.format("- %s: %.2f EUR\n",
entry.getKey() != null ? entry.getKey() : "Unbekannt", entry.getKey() != null ? entry.getKey() : "Unbekannt", entry.getValue()));
entry.getValue()));
} }
} }
} }
@@ -606,8 +569,8 @@ public class AiStatisticsService {
List<Job> jobs; List<Job> jobs;
if (customerFilter != null && statusFilter != null) { if (customerFilter != null && statusFilter != null) {
jobs = statisticsService.getJobsByCustomerAndStatus(customerFilter, statusFilter); jobs = statisticsService.getJobsByCustomerAndStatus(customerFilter, statusFilter);
context.append(String.format("**Jobs für %s mit Status %s:**\n\n", context.append(
customerFilter, statusFilter.getDisplayName())); String.format("**Jobs für %s mit Status %s:**\n\n", customerFilter, statusFilter.getDisplayName()));
} else if (customerFilter != null) { } else if (customerFilter != null) {
jobs = statisticsService.getJobsByCustomer(customerFilter); jobs = statisticsService.getJobsByCustomer(customerFilter);
context.append(String.format("**Jobs für %s:**\n\n", 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)); context.append(String.format("... und %d weitere Jobs\n", jobs.size() - 10));
break; break;
} }
context.append(String.format("- %s: %s (%s)\n", context.append(
job.getJobNumber() != null ? job.getJobNumber() : "Ohne Nr.", String.format("- %s: %s (%s)\n", job.getJobNumber() != null ? job.getJobNumber() : "Ohne Nr.",
job.getCustomerSelection() != null ? job.getCustomerSelection() : "Unbekannt", job.getCustomerSelection() != null ? job.getCustomerSelection() : "Unbekannt",
job.getStatus().getDisplayName())); job.getStatus().getDisplayName()));
shown++; shown++;
} }
@@ -639,7 +602,8 @@ public class AiStatisticsService {
} }
private String buildPrompt(String userQuery, String statisticsContext, QueryAnalysis analysis) { 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(""" return String.format("""
%s %s
@@ -652,107 +616,93 @@ public class AiStatisticsService {
JobStatus statusFilter = analysis.statusFilter; JobStatus statusFilter = analysis.statusFilter;
return switch (analysis.queryType) { return switch (analysis.queryType) {
case "list" -> { case "list" -> {
List<Job> jobs; List<Job> jobs;
if (customer != null && statusFilter != null) { if (customer != null && statusFilter != null) {
jobs = statisticsService.getJobsByCustomerAndStatus(customer, statusFilter); jobs = statisticsService.getJobsByCustomerAndStatus(customer, statusFilter);
yield String.format("Es wurden %d Jobs für %s mit Status \"%s\" gefunden.", yield String.format("Es wurden %d Jobs für %s mit Status \"%s\" gefunden.", jobs.size(), customer,
jobs.size(), customer, statusFilter.getDisplayName()); statusFilter.getDisplayName());
} else if (customer != null) { } else if (customer != null) {
jobs = statisticsService.getJobsByCustomer(customer); jobs = statisticsService.getJobsByCustomer(customer);
yield String.format("Es wurden %d Jobs für %s gefunden.", jobs.size(), customer); yield String.format("Es wurden %d Jobs für %s gefunden.", jobs.size(), customer);
} else if (statusFilter != null) { } else if (statusFilter != null) {
jobs = statisticsService.getJobsByStatus(statusFilter); jobs = statisticsService.getJobsByStatus(statusFilter);
yield String.format("Es wurden %d Jobs mit Status \"%s\" gefunden.", yield String.format("Es wurden %d Jobs mit Status \"%s\" gefunden.", jobs.size(),
jobs.size(), statusFilter.getDisplayName()); statusFilter.getDisplayName());
} else { } else {
yield "Hier sind die aktuellen Jobs."; yield "Hier sind die aktuellen Jobs.";
}
} }
case "status" -> { }
var counts = customer != null case "status" -> {
? statisticsService.getJobCountsByStatusForCustomer(customer) var counts = customer != null ? statisticsService.getJobCountsByStatusForCustomer(customer)
: statisticsService.getJobCountsByStatus(); : statisticsService.getJobCountsByStatus();
String title = customer != null String title = customer != null ? String.format("**Auftragsübersicht für %s:**\n\n", customer)
? String.format("**Auftragsübersicht für %s:**\n\n", customer) : "**Auftragsübersicht nach Status:**\n\n";
: "**Auftragsübersicht nach Status:**\n\n"; StringBuilder sb = new StringBuilder(title);
StringBuilder sb = new StringBuilder(title); counts.forEach((status, count) -> {
counts.forEach((status, count) -> { if (count > 0) {
if (count > 0) { sb.append(String.format("- **%s:** %d Aufträge\n", status.getDisplayName(), count));
sb.append(String.format("- **%s:** %d Aufträge\n", status.getDisplayName(), count)); }
} });
}); long total = customer != null ? statisticsService.getTotalJobCountForCustomer(customer)
long total = customer != null : statisticsService.getTotalJobCount();
? statisticsService.getTotalJobCountForCustomer(customer) sb.append(String.format("\n**Gesamt:** %d Aufträge", total));
: statisticsService.getTotalJobCount(); yield sb.toString();
sb.append(String.format("\n**Gesamt:** %d Aufträge", total)); }
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(); yield sb.toString();
} }
case "revenue" -> { }
if (customer != null) { case "trend" -> {
var revenue = statisticsService.getTotalRevenueForCustomer(customer); int year = Year.now().getValue();
var jobCount = statisticsService.getTotalJobCountForCustomer(customer); var monthly = customer != null ? statisticsService.getMonthlyJobCountsForCustomer(year, customer)
yield String.format("**Umsatz für %s:**\n\n" + : statisticsService.getMonthlyJobCounts(year);
"- **Gesamtumsatz:** %.2f EUR\n" + long total = monthly.values().stream().mapToLong(Long::longValue).sum();
"- **Aufträge:** %d", String title = customer != null ? String.format("**Monatstrend %d für %s:**", year, customer)
customer, revenue, jobCount); : String.format("**Monatstrend %d:**", year);
} else { yield String.format(
var topCustomers = statisticsService.getTopCustomersByRevenue(5); "%s\n\nInsgesamt wurden %d Aufträge erstellt. " + "Die Verteilung ist im Diagramm ersichtlich.",
StringBuilder sb = new StringBuilder("**Top Kunden nach Umsatz:**\n\n"); title, total);
int rank = 1; }
for (var entry : topCustomers) { case "tasks" -> {
sb.append(String.format("%d. **%s:** %.2f EUR\n", var taskStats = statisticsService.getTaskCompletionStats();
rank++, long total = taskStats.getOrDefault("total", 0L);
entry.getKey() != null ? entry.getKey() : "Unbekannt", long completed = taskStats.getOrDefault("completed", 0L);
entry.getValue())); double rate = total > 0 ? (double) completed / total * 100 : 0;
} yield String.format("**Aufgabenstatistik:**\n\n" + "- **Gesamt:** %d Aufgaben\n"
sb.append(String.format("\n**Gesamtumsatz:** %.2f EUR", statisticsService.getTotalRevenue())); + "- **Erledigt:** %d (%.1f%%)\n" + "- **Ausstehend:** %d", total, completed, rate,
yield sb.toString(); taskStats.getOrDefault("pending", 0L));
} }
} default -> {
case "trend" -> { if (customer != null) {
int year = Year.now().getValue(); yield String.format(
var monthly = customer != null "**Übersicht für %s:**\n\n" + "- **Aufträge gesamt:** %d\n" + "- **Abschlussrate:** %.1f%%\n"
? statisticsService.getMonthlyJobCountsForCustomer(year, customer) + "- **Umsatz:** %.2f EUR",
: statisticsService.getMonthlyJobCounts(year); customer, statisticsService.getTotalJobCountForCustomer(customer),
long total = monthly.values().stream().mapToLong(Long::longValue).sum(); statisticsService.getCompletionRateForCustomer(customer),
String title = customer != null statisticsService.getTotalRevenueForCustomer(customer));
? String.format("**Monatstrend %d für %s:**", year, customer) } else {
: String.format("**Monatstrend %d:**", year); yield String.format(
yield String.format("%s\n\nInsgesamt wurden %d Aufträge erstellt. " + "**Übersicht:**\n\n" + "- **Aufträge gesamt:** %d\n" + "- **Abschlussrate:** %.1f%%\n"
"Die Verteilung ist im Diagramm ersichtlich.", title, total); + "- **Gesamtumsatz:** %.2f EUR",
} statisticsService.getTotalJobCount(), statisticsService.getCompletionRate(),
case "tasks" -> { statisticsService.getTotalRevenue());
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());
}
} }
}
}; };
} }
} }

View File

@@ -13,8 +13,8 @@ import java.util.List;
import java.util.Map; import java.util.Map;
/** /**
* Direct REST client for LM Studio API. * Direct REST client for LM Studio API. Uses Spring WebClient like
* Uses Spring WebClient like aimailassistant - bypasses Spring AI. * aimailassistant - bypasses Spring AI.
*/ */
@Component @Component
@Slf4j @Slf4j
@@ -24,14 +24,10 @@ public class LlmRestClient {
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final String model; private final String model;
public LlmRestClient( public LlmRestClient(@Value("${spring.ai.openai.base-url:http://192.168.180.10:1234}") String baseUrl,
@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) {
@Value("${spring.ai.openai.chat.options.model:local-model}") String model,
ObjectMapper objectMapper) {
this.webClient = WebClient.builder() this.webClient = WebClient.builder().baseUrl(baseUrl + "/v1/chat/completions").build();
.baseUrl(baseUrl + "/v1/chat/completions")
.build();
this.model = model; this.model = model;
this.objectMapper = objectMapper; this.objectMapper = objectMapper;
@@ -41,8 +37,10 @@ public class LlmRestClient {
/** /**
* Send a chat completion request to LM Studio. * Send a chat completion request to LM Studio.
* *
* @param systemPrompt System prompt for context * @param systemPrompt
* @param userMessage User message/question * System prompt for context
* @param userMessage
* User message/question
* @return LLM response text, or null on error * @return LLM response text, or null on error
*/ */
public String chat(String systemPrompt, String userMessage) { 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. * Send a chat completion request to LM Studio with custom parameters.
* *
* @param systemPrompt System prompt for context * @param systemPrompt
* @param userMessage User message/question * System prompt for context
* @param temperature Temperature for response randomness (0.0-1.0) * @param userMessage
* @param maxTokens Maximum tokens in response * 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 * @return LLM response text, or null on error
*/ */
public String chat(String systemPrompt, String userMessage, double temperature, int maxTokens) { public String chat(String systemPrompt, String userMessage, double temperature, int maxTokens) {
try { try {
Map<String, Object> request = Map.of( Map<String, Object> request = Map.of("model", model, "messages",
"model", model, List.of(Map.of("role", "system", "content", systemPrompt != null ? systemPrompt : ""),
"messages", List.of( Map.of("role", "user", "content", userMessage)),
Map.of("role", "system", "content", systemPrompt != null ? systemPrompt : ""), "temperature", temperature, "max_tokens", maxTokens, "stream", false // WICHTIG: Kein Streaming!
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)...", log.info("Sending request to LLM (model: {}, prompt length: {} chars)...", model, userMessage.length());
model, userMessage.length());
long startTime = System.currentTimeMillis(); long startTime = System.currentTimeMillis();
String response = webClient.post() String response = webClient.post().contentType(MediaType.APPLICATION_JSON).bodyValue(request).retrieve()
.contentType(MediaType.APPLICATION_JSON) .bodyToMono(String.class).timeout(Duration.ofSeconds(120)).block();
.bodyValue(request)
.retrieve()
.bodyToMono(String.class)
.timeout(Duration.ofSeconds(120))
.block();
long duration = System.currentTimeMillis() - startTime; long duration = System.currentTimeMillis() - startTime;
log.info("LLM response received in {}ms", duration); log.info("LLM response received in {}ms", duration);

View File

@@ -8,15 +8,15 @@ import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary; import org.springframework.context.annotation.Primary;
/** /**
* Jackson configuration for consistent JSON serialization across the application. * Jackson configuration for consistent JSON serialization across the
* Ensures all date/time fields are serialized as ISO 8601 strings. * application. Ensures all date/time fields are serialized as ISO 8601 strings.
*/ */
@Configuration @Configuration
public class JacksonConfig { public class JacksonConfig {
/** /**
* Creates a configured ObjectMapper bean that serializes dates as ISO 8601 strings. * Creates a configured ObjectMapper bean that serializes dates as ISO 8601
* This bean is used throughout the application for JSON serialization. * strings. This bean is used throughout the application for JSON serialization.
*/ */
@Bean @Bean
@Primary @Primary
@@ -28,4 +28,3 @@ public class JacksonConfig {
return objectMapper; return objectMapper;
} }
} }

View File

@@ -124,6 +124,9 @@ public class MongoConfig {
if (source.containsKey("task_order")) { if (source.containsKey("task_order")) {
task.setTaskOrder(source.getInteger("task_order", 0)); task.setTaskOrder(source.getInteger("task_order", 0));
} }
if (source.containsKey("description")) {
task.setDescription(source.getString("description"));
}
if (source.containsKey("completed")) { if (source.containsKey("completed")) {
task.setCompleted(source.getBoolean("completed", false)); task.setCompleted(source.getBoolean("completed", false));
} }

View File

@@ -17,4 +17,3 @@ public class PasswordEncoderConfig {
return new BCryptPasswordEncoder(); return new BCryptPasswordEncoder();
} }
} }

View File

@@ -14,8 +14,8 @@ import java.util.List;
import java.util.Map; import java.util.Map;
/** /**
* REST API controller for message operations. * REST API controller for message operations. Provides endpoints for sending
* Provides endpoints for sending messages, retrieving messages, and marking messages as read. * messages, retrieving messages, and marking messages as read.
*/ */
@RestController @RestController
@RequestMapping("/api/messages") @RequestMapping("/api/messages")
@@ -29,9 +29,8 @@ public class MessageApiController {
} }
/** /**
* Send a general message to a client * Send a general message to a client POST /api/messages/send Body: { "content":
* POST /api/messages/send * "message text", "receiver": "appUserId", "contentType": "TEXT|IMAGE" }
* Body: { "content": "message text", "receiver": "appUserId", "contentType": "TEXT|IMAGE" }
*/ */
@PostMapping("/send") @PostMapping("/send")
public ResponseEntity<Message> sendGeneralMessage(@RequestBody Map<String, String> request) { public ResponseEntity<Message> sendGeneralMessage(@RequestBody Map<String, String> request) {
@@ -40,8 +39,7 @@ public class MessageApiController {
String receiver = request.get("receiver"); String receiver = request.get("receiver");
MessageContentType contentType = resolveContentType(request.get("contentType")); MessageContentType contentType = resolveContentType(request.get("contentType"));
if (content == null || content.isBlank() || if (content == null || content.isBlank() || receiver == null || receiver.isBlank()) {
receiver == null || receiver.isBlank()) {
log.warn("Invalid message request: missing required fields"); log.warn("Invalid message request: missing required fields");
return ResponseEntity.badRequest().build(); return ResponseEntity.badRequest().build();
} }
@@ -60,10 +58,9 @@ public class MessageApiController {
} }
/** /**
* Send a job-related message to a client * Send a job-related message to a client POST /api/messages/send-job-message
* POST /api/messages/send-job-message * Body: { "content": "message text", "receiver": "appUserId", "jobId": "job
* Body: { "content": "message text", "receiver": "appUserId", * id", "jobNumber": "job number", "contentType": "TEXT|IMAGE" }
* "jobId": "job id", "jobNumber": "job number", "contentType": "TEXT|IMAGE" }
*/ */
@PostMapping("/send-job-message") @PostMapping("/send-job-message")
public ResponseEntity<Message> sendJobMessage(@RequestBody Map<String, String> request) { public ResponseEntity<Message> sendJobMessage(@RequestBody Map<String, String> request) {
@@ -74,9 +71,8 @@ public class MessageApiController {
String jobNumber = request.get("jobNumber"); String jobNumber = request.get("jobNumber");
MessageContentType contentType = resolveContentType(request.get("contentType")); MessageContentType contentType = resolveContentType(request.get("contentType"));
if (content == null || content.isBlank() || if (content == null || content.isBlank() || receiver == null || receiver.isBlank() || jobIdStr == null
receiver == null || receiver.isBlank() || || jobIdStr.isBlank()) {
jobIdStr == null || jobIdStr.isBlank()) {
log.warn("Invalid job message request: missing required fields"); log.warn("Invalid job message request: missing required fields");
return ResponseEntity.badRequest().build(); return ResponseEntity.badRequest().build();
} }
@@ -96,8 +92,8 @@ public class MessageApiController {
} }
/** /**
* Get all messages for a specific receiver * Get all messages for a specific receiver GET
* GET /api/messages/receiver/{username} * /api/messages/receiver/{username}
*/ */
@GetMapping("/receiver/{username}") @GetMapping("/receiver/{username}")
public ResponseEntity<List<Message>> getMessagesForReceiver(@PathVariable String username) { public ResponseEntity<List<Message>> getMessagesForReceiver(@PathVariable String username) {
@@ -111,8 +107,8 @@ public class MessageApiController {
} }
/** /**
* Get all unread messages for a specific receiver * Get all unread messages for a specific receiver GET
* GET /api/messages/receiver/{username}/unread * /api/messages/receiver/{username}/unread
*/ */
@GetMapping("/receiver/{username}/unread") @GetMapping("/receiver/{username}/unread")
public ResponseEntity<List<Message>> getUnreadMessagesForReceiver(@PathVariable String username) { public ResponseEntity<List<Message>> getUnreadMessagesForReceiver(@PathVariable String username) {
@@ -126,8 +122,8 @@ public class MessageApiController {
} }
/** /**
* Get unread message count for a specific receiver * Get unread message count for a specific receiver GET
* GET /api/messages/receiver/{username}/unread-count * /api/messages/receiver/{username}/unread-count
*/ */
@GetMapping("/receiver/{username}/unread-count") @GetMapping("/receiver/{username}/unread-count")
public ResponseEntity<Map<String, Long>> getUnreadMessageCount(@PathVariable String username) { public ResponseEntity<Map<String, Long>> getUnreadMessageCount(@PathVariable String username) {
@@ -141,8 +137,7 @@ public class MessageApiController {
} }
/** /**
* Get all messages related to a specific job * Get all messages related to a specific job GET /api/messages/job/{jobId}
* GET /api/messages/job/{jobId}
*/ */
@GetMapping("/job/{jobId}") @GetMapping("/job/{jobId}")
public ResponseEntity<List<Message>> getMessagesForJob(@PathVariable String jobId) { public ResponseEntity<List<Message>> getMessagesForJob(@PathVariable String jobId) {
@@ -160,8 +155,7 @@ public class MessageApiController {
} }
/** /**
* Get all messages (for admin/overview) * Get all messages (for admin/overview) GET /api/messages/all
* GET /api/messages/all
*/ */
@GetMapping("/all") @GetMapping("/all")
public ResponseEntity<List<Message>> getAllMessages() { public ResponseEntity<List<Message>> getAllMessages() {
@@ -175,8 +169,8 @@ public class MessageApiController {
} }
/** /**
* Get messages by origin (incoming/outgoing/server) * Get messages by origin (incoming/outgoing/server) GET
* GET /api/messages/origin/{origin} * /api/messages/origin/{origin}
*/ */
@GetMapping("/origin/{origin}") @GetMapping("/origin/{origin}")
public ResponseEntity<List<Message>> getMessagesByOrigin(@PathVariable String origin) { public ResponseEntity<List<Message>> getMessagesByOrigin(@PathVariable String origin) {
@@ -194,8 +188,7 @@ public class MessageApiController {
} }
/** /**
* Mark a message as read * Mark a message as read PUT /api/messages/{messageId}/mark-read
* PUT /api/messages/{messageId}/mark-read
*/ */
@PutMapping("/{messageId}/mark-read") @PutMapping("/{messageId}/mark-read")
public ResponseEntity<Void> markMessageAsRead(@PathVariable String messageId) { public ResponseEntity<Void> markMessageAsRead(@PathVariable String messageId) {
@@ -213,8 +206,7 @@ public class MessageApiController {
} }
/** /**
* Delete a message * Delete a message DELETE /api/messages/{messageId}
* DELETE /api/messages/{messageId}
*/ */
@DeleteMapping("/{messageId}") @DeleteMapping("/{messageId}")
public ResponseEntity<Void> deleteMessage(@PathVariable String messageId) { public ResponseEntity<Void> deleteMessage(@PathVariable String messageId) {

View File

@@ -76,9 +76,9 @@ public class MessageController {
public MessageController(MqttPublisher mqttPublisher, AppUserRepository appUserRepository, public MessageController(MqttPublisher mqttPublisher, AppUserRepository appUserRepository,
AppUserService appUserService, JobRepository jobRepository, CargoItemRepository cargoItemRepository, AppUserService appUserService, JobRepository jobRepository, CargoItemRepository cargoItemRepository,
TaskRepository taskRepository, PhotoRepository photoRepository, BarcodeRepository barcodeRepository, TaskRepository taskRepository, PhotoRepository photoRepository, BarcodeRepository barcodeRepository,
SignatureRepository signatureRepository, CommentRepository commentRepository, JobHistoryService jobHistoryService, SignatureRepository signatureRepository, CommentRepository commentRepository,
EmailService emailService, MessageService messageService, ObjectMapper objectMapper, JobHistoryService jobHistoryService, EmailService emailService, MessageService messageService,
ClientConnectionService clientConnectionService) { ObjectMapper objectMapper, ClientConnectionService clientConnectionService) {
this.mqttPublisher = mqttPublisher; this.mqttPublisher = mqttPublisher;
this.appUserRepository = appUserRepository; this.appUserRepository = appUserRepository;
this.appUserService = appUserService; this.appUserService = appUserService;
@@ -181,8 +181,8 @@ public class MessageController {
List<Job> allJobs = jobRepository.findAll(); List<Job> allJobs = jobRepository.findAll();
log.info("DEBUG: Total jobs in database: {}", allJobs.size()); log.info("DEBUG: Total jobs in database: {}", allJobs.size());
for (Job job : allJobs) { for (Job job : allJobs) {
log.info("DEBUG: Job {} (number: {}) has app_user='{}', digitalProcessing={}", log.info("DEBUG: Job {} (number: {}) has app_user='{}', digitalProcessing={}", job.getIdAsString(),
job.getIdAsString(), job.getJobNumber(), job.getAppUser(), job.isDigitalProcessing()); job.getJobNumber(), job.getAppUser(), job.isDigitalProcessing());
} }
// For each job, fetch related cargo items and tasks (ordered by task order) // 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) { if (extra instanceof Map<?, ?> extraData) {
Object barcodesObj = extraData.get("barcodes"); Object barcodesObj = extraData.get("barcodes");
if (barcodesObj instanceof List<?> barcodesList) { 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") @SuppressWarnings("unchecked")
List<String> barcodes = (List<String>) barcodesList; List<String> barcodes = (List<String>) barcodesList;
@@ -404,15 +405,15 @@ public class MessageController {
if (extra instanceof Map<?, ?> extraData) { if (extra instanceof Map<?, ?> extraData) {
Object photosObj = extraData.get("photos"); Object photosObj = extraData.get("photos");
if (photosObj instanceof List<?> photosList) { 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") @SuppressWarnings("unchecked")
List<String> photos = (List<String>) photosList; List<String> photos = (List<String>) photosList;
if (!photos.isEmpty()) { if (!photos.isEmpty()) {
for (String photoString : photos) { for (String photoString : photos) {
String completedBy = task.getCompletedBy() != null ? task.getCompletedBy() : "Unknown"; String completedBy = task.getCompletedBy() != null ? task.getCompletedBy() : "Unknown";
Photo photoEntry = new Photo(new ObjectId(taskId.toString()), photoString, Photo photoEntry = new Photo(new ObjectId(taskId.toString()), photoString, completedBy);
completedBy);
photoRepository.save(photoEntry); photoRepository.save(photoEntry);
} }
@@ -609,9 +610,8 @@ public class MessageController {
} }
/** /**
* Handle pong response from a client. * Handle pong response from a client. Client sends to /server/{clientId}/pong
* Client sends to /server/{clientId}/pong with payload { timestamp }. * with payload { timestamp }. Used for connection monitoring.
* Used for connection monitoring.
*/ */
public void handlePong(Map<String, Object> payload) { public void handlePong(Map<String, Object> payload) {
String clientId = payload.get("clientId") != null ? payload.get("clientId").toString() : null; 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. * Handle incoming message from a client via MQTT. Client sends to
* Client sends to /server/{clientId}/message with payload: * /server/{clientId}/message with payload: { "content": "message payload",
* { * "contentType": "TEXT|IMAGE", "jobId": "optional job id", "jobNumber":
* "content": "message payload", * "optional job number" }
* "contentType": "TEXT|IMAGE",
* "jobId": "optional job id",
* "jobNumber": "optional job number"
* }
* *
* The clientId is extracted from the MQTT topic and represents the AppUser ID. * The clientId is extracted from the MQTT topic and represents the AppUser ID.
* This clientId is stored as the receiver field in the message. * This clientId is stored as the receiver field in the message.

View File

@@ -8,8 +8,8 @@ import org.bson.types.ObjectId;
* Normalized payload for chat messages sent by mobile clients via MQTT. * Normalized payload for chat messages sent by mobile clients via MQTT.
* receiver = AppUser ID (clientId) extracted from MQTT topic * receiver = AppUser ID (clientId) extracted from MQTT topic
*/ */
public record ChatMessageInboundPayload(String receiver, String content, public record ChatMessageInboundPayload(String receiver, String content, MessageContentType contentType, ObjectId jobId,
MessageContentType contentType, ObjectId jobId, String jobNumber) { String jobNumber) {
public ChatMessageInboundPayload { public ChatMessageInboundPayload {
contentType = contentType != null ? contentType : MessageContentType.TEXT; contentType = contentType != null ? contentType : MessageContentType.TEXT;
@@ -58,8 +58,7 @@ public record ChatMessageInboundPayload(String receiver, String content,
try { try {
return new ObjectId(candidate); return new ObjectId(candidate);
} catch (IllegalArgumentException ex) { } catch (IllegalArgumentException ex) {
throw new IllegalArgumentException( throw new IllegalArgumentException("Field '%s' must be a valid MongoDB ObjectId".formatted(fieldName), ex);
"Field '%s' must be a valid MongoDB ObjectId".formatted(fieldName), ex);
} }
} }

View File

@@ -7,32 +7,16 @@ import de.assecutor.votianlt.model.MessageType;
import java.time.LocalDateTime; import java.time.LocalDateTime;
/** /**
* Outbound chat message payload published to MQTT subscribers. * Outbound chat message payload published to MQTT subscribers. The receiver is
* The receiver is implicit from the MQTT topic (/client/{appUserId}/message) * implicit from the MQTT topic (/client/{appUserId}/message)
*/ */
public record ChatMessageOutboundPayload( public record ChatMessageOutboundPayload(String messageId, String content, MessageContentType contentType,
String messageId, MessageOrigin origin, MessageType messageType, LocalDateTime createdAt, String jobId, String jobNumber,
String content, boolean read) {
MessageContentType contentType,
MessageOrigin origin,
MessageType messageType,
LocalDateTime createdAt,
String jobId,
String jobNumber,
boolean read
) {
public static ChatMessageOutboundPayload fromMessage(Message message) { public static ChatMessageOutboundPayload fromMessage(Message message) {
return new ChatMessageOutboundPayload( return new ChatMessageOutboundPayload(message.getIdAsString(), message.getContent(), message.getContentType(),
message.getIdAsString(), message.getOrigin(), message.getMessageType(), message.getCreatedAt(), message.getJobIdAsString(),
message.getContent(), message.getJobNumber(), message.isRead());
message.getContentType(),
message.getOrigin(),
message.getMessageType(),
message.getCreatedAt(),
message.getJobIdAsString(),
message.getJobNumber(),
message.isRead()
);
} }
} }

View File

@@ -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;
}
}

View File

@@ -3,8 +3,8 @@ package de.assecutor.votianlt.event;
import org.springframework.context.ApplicationEvent; import org.springframework.context.ApplicationEvent;
/** /**
* Event published when message read status changes (e.g., messages marked as read) * Event published when message read status changes (e.g., messages marked as
* This allows UI components like the sidebar badge to update accordingly * read) This allows UI components like the sidebar badge to update accordingly
*/ */
public class MessageReadStatusChangedEvent extends ApplicationEvent { public class MessageReadStatusChangedEvent extends ApplicationEvent {
@@ -12,4 +12,3 @@ public class MessageReadStatusChangedEvent extends ApplicationEvent {
super(source); super(source);
} }
} }

View File

@@ -10,8 +10,8 @@ import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
/** /**
* Configuration for the MCP (Model Context Protocol) server. * Configuration for the MCP (Model Context Protocol) server. Registers all MCP
* Registers all MCP tools for job statistics and queries. * tools for job statistics and queries.
*/ */
@Configuration @Configuration
@Slf4j @Slf4j

View File

@@ -27,8 +27,7 @@ public class JobQueryTool {
@Tool(description = "Query jobs with optional filters. Returns a list of jobs matching the criteria.") @Tool(description = "Query jobs with optional filters. Returns a list of jobs matching the criteria.")
public List<JobQueryResult> queryJobs( public List<JobQueryResult> queryJobs(
@ToolParam(description = "Optional: Job status filter (CREATED, IN_PROGRESS, PICKUP_SCHEDULED, PICKED_UP, IN_TRANSIT, DELIVERED, COMPLETED, CANCELLED)") @ToolParam(description = "Optional: Job status filter (CREATED, IN_PROGRESS, PICKUP_SCHEDULED, PICKED_UP, IN_TRANSIT, DELIVERED, COMPLETED, CANCELLED)") String status,
String status,
@ToolParam(description = "Optional: Customer name filter") String customer, @ToolParam(description = "Optional: Customer name filter") String customer,
@ToolParam(description = "Optional: Pickup city filter") String pickupCity, @ToolParam(description = "Optional: Pickup city filter") String pickupCity,
@ToolParam(description = "Optional: Delivery city filter") String deliveryCity, @ToolParam(description = "Optional: Delivery city filter") String deliveryCity,
@@ -52,10 +51,7 @@ public class JobQueryTool {
jobs = statisticsService.getLatestJobs(actualLimit); jobs = statisticsService.getLatestJobs(actualLimit);
} }
return jobs.stream() return jobs.stream().limit(actualLimit).map(this::toQueryResult).toList();
.limit(actualLimit)
.map(this::toQueryResult)
.toList();
} }
@Tool(description = "Get detailed information about a specific job by its job number") @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") @Tool(description = "Get jobs assigned to a specific mobile app user")
public List<JobQueryResult> getJobsByAppUser( public List<JobQueryResult> getJobsByAppUser(@ToolParam(description = "App user identifier") String appUser) {
@ToolParam(description = "App user identifier") String appUser) {
log.info("MCP Tool: Getting jobs for app user: {}", appUser); log.info("MCP Tool: Getting jobs for app user: {}", appUser);
return statisticsService.getJobsByAppUser(appUser).stream() return statisticsService.getJobsByAppUser(appUser).stream().map(this::toQueryResult).toList();
.map(this::toQueryResult)
.toList();
} }
@Tool(description = "Get the most recent jobs, sorted by creation date descending") @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); log.info("MCP Tool: Getting latest jobs, limit: {}", limit);
int actualLimit = limit != null ? limit : 10; int actualLimit = limit != null ? limit : 10;
return statisticsService.getLatestJobs(actualLimit).stream() return statisticsService.getLatestJobs(actualLimit).stream().map(this::toQueryResult).toList();
.map(this::toQueryResult)
.toList();
} }
@Tool(description = "Get jobs created within a specific date range") @Tool(description = "Get jobs created within a specific date range")
@@ -102,27 +93,17 @@ public class JobQueryTool {
LocalDateTime end = LocalDateTime.parse(endDate); LocalDateTime end = LocalDateTime.parse(endDate);
int actualLimit = limit != null ? limit : 100; int actualLimit = limit != null ? limit : 100;
return statisticsService.getJobsByDateRange(start, end).stream() return statisticsService.getJobsByDateRange(start, end).stream().limit(actualLimit).map(this::toQueryResult)
.limit(actualLimit)
.map(this::toQueryResult)
.toList(); .toList();
} }
private JobQueryResult toQueryResult(Job job) { private JobQueryResult toQueryResult(Job job) {
return JobQueryResult.builder() return JobQueryResult.builder().jobId(job.getIdAsString()).jobNumber(job.getJobNumber())
.jobId(job.getIdAsString())
.jobNumber(job.getJobNumber())
.status(job.getStatus() != null ? job.getStatus().name() : null) .status(job.getStatus() != null ? job.getStatus().name() : null)
.statusDisplayName(job.getStatus() != null ? job.getStatus().getDisplayName() : null) .statusDisplayName(job.getStatus() != null ? job.getStatus().getDisplayName() : null)
.customer(job.getCustomerSelection()) .customer(job.getCustomerSelection()).pickupCity(job.getPickupCity())
.pickupCity(job.getPickupCity()) .deliveryCity(job.getDeliveryCity()).pickupDate(job.getPickupDate()).deliveryDate(job.getDeliveryDate())
.deliveryCity(job.getDeliveryCity()) .price(job.getPrice()).createdAt(job.getCreatedAt()).assignedAppUser(job.getAppUser())
.pickupDate(job.getPickupDate()) .digitalProcessing(job.isDigitalProcessing()).build();
.deliveryDate(job.getDeliveryDate())
.price(job.getPrice())
.createdAt(job.getCreatedAt())
.assignedAppUser(job.getAppUser())
.digitalProcessing(job.isDigitalProcessing())
.build();
} }
} }

View File

@@ -17,8 +17,8 @@ import java.util.Map;
import java.util.stream.Collectors; import java.util.stream.Collectors;
/** /**
* MCP Tool for job statistics queries. * MCP Tool for job statistics queries. Provides various statistics and
* Provides various statistics and aggregations about jobs. * aggregations about jobs.
*/ */
@Component @Component
@Slf4j @Slf4j
@@ -42,16 +42,10 @@ public class JobStatisticsTool {
long cancelled = countsByStatus.getOrDefault(JobStatus.CANCELLED, 0L); long cancelled = countsByStatus.getOrDefault(JobStatus.CANCELLED, 0L);
long inProgress = countsByStatus.getOrDefault(JobStatus.IN_PROGRESS, 0L); long inProgress = countsByStatus.getOrDefault(JobStatus.IN_PROGRESS, 0L);
return JobStatisticsResult.builder() return JobStatisticsResult.builder().countsByStatus(statusCounts)
.countsByStatus(statusCounts) .totalJobs(statisticsService.getTotalJobCount()).completedJobs(completed).cancelledJobs(cancelled)
.totalJobs(statisticsService.getTotalJobCount()) .inProgressJobs(inProgress).completionRate(statisticsService.getCompletionRate())
.completedJobs(completed) .totalRevenue(statisticsService.getTotalRevenue()).queryTimestamp(LocalDateTime.now()).build();
.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)") @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"); log.info("MCP Tool: Getting job counts by status");
Map<JobStatus, Long> counts = statisticsService.getJobCountsByStatus(); Map<JobStatus, Long> counts = statisticsService.getJobCountsByStatus();
return counts.entrySet().stream() return counts.entrySet().stream().collect(Collectors
.collect(Collectors.toMap( .toMap(e -> e.getKey().name() + " (" + e.getKey().getDisplayName() + ")", Map.Entry::getValue));
e -> e.getKey().name() + " (" + e.getKey().getDisplayName() + ")",
Map.Entry::getValue));
} }
@Tool(description = "Get the completion rate as a percentage (completed jobs / total jobs * 100)") @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); log.info("MCP Tool: Getting revenue by customer, limit: {}", limit);
int actualLimit = limit != null ? limit : 10; int actualLimit = limit != null ? limit : 10;
return statisticsService.getTopCustomersByRevenue(actualLimit).stream() return statisticsService.getTopCustomersByRevenue(actualLimit).stream().map(entry -> {
.map(entry -> { String customer = entry.getKey();
String customer = entry.getKey(); long jobCount = statisticsService.getJobsByCustomer(customer).size();
long jobCount = statisticsService.getJobsByCustomer(customer).size(); return CustomerRevenueResult.builder().customer(customer).revenue(entry.getValue()).jobCount(jobCount)
return CustomerRevenueResult.builder() .build();
.customer(customer) }).toList();
.revenue(entry.getValue())
.jobCount(jobCount)
.build();
})
.toList();
} }
@Tool(description = "Get monthly job trend data for a specific year showing job counts per month") @Tool(description = "Get monthly job trend data for a specific year showing job counts per month")
@@ -99,9 +86,7 @@ public class JobStatisticsTool {
Map<Month, Long> monthlyData = statisticsService.getMonthlyJobCounts(year); Map<Month, Long> monthlyData = statisticsService.getMonthlyJobCounts(year);
return monthlyData.entrySet().stream() return monthlyData.entrySet().stream()
.collect(Collectors.toMap( .collect(Collectors.toMap(e -> e.getKey().toString(), Map.Entry::getValue));
e -> e.getKey().toString(),
Map.Entry::getValue));
} }
@Tool(description = "Get total revenue from all jobs") @Tool(description = "Get total revenue from all jobs")

View File

@@ -32,12 +32,8 @@ public class TaskCompletionTool {
double completionRate = total > 0 ? (double) completed / total * 100.0 : 0.0; double completionRate = total > 0 ? (double) completed / total * 100.0 : 0.0;
return TaskCompletionResult.builder() return TaskCompletionResult.builder().totalTasks(total).completedTasks(completed).pendingTasks(pending)
.totalTasks(total) .completionRate(completionRate).build();
.completedTasks(completed)
.pendingTasks(pending)
.completionRate(completionRate)
.build();
} }
@Tool(description = "Get a summary of task completion as a formatted string") @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; double completionRate = total > 0 ? (double) completed / total * 100.0 : 0.0;
return String.format( return String.format("Task Statistics: %d total tasks, %d completed (%.1f%%), %d pending", total, completed,
"Task Statistics: %d total tasks, %d completed (%.1f%%), %d pending", completionRate, pending);
total, completed, completionRate, pending);
} }
} }

View File

@@ -20,8 +20,8 @@ import java.nio.charset.StandardCharsets;
import java.util.Map; import java.util.Map;
/** /**
* Configuration for the plugin-based messaging system. * Configuration for the plugin-based messaging system. Initializes the selected
* Initializes the selected plugin and sets up message routing. * plugin and sets up message routing.
*/ */
@Configuration @Configuration
@Slf4j @Slf4j
@@ -54,8 +54,9 @@ public class PluginMessagingConfig {
} }
/** /**
* Initialize the messaging plugin after application startup. * Initialize the messaging plugin after application startup. This method is
* This method is called after all beans are created, so we can safely access MessageDeliveryService. * called after all beans are created, so we can safely access
* MessageDeliveryService.
*/ */
@EventListener(ApplicationReadyEvent.class) @EventListener(ApplicationReadyEvent.class)
public void initializePlugin(ApplicationReadyEvent event) { public void initializePlugin(ApplicationReadyEvent event) {
@@ -66,9 +67,11 @@ public class PluginMessagingConfig {
PluginConfig config = createPluginConfig(pluginType); PluginConfig config = createPluginConfig(pluginType);
// Get beans from context (after all beans are created) // 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); 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 // Set up a listener to subscribe when connected
log.info("[PluginMessagingConfig] Adding state listener"); log.info("[PluginMessagingConfig] Adding state listener");
@@ -89,10 +92,12 @@ public class PluginMessagingConfig {
}); });
log.info("[PluginMessagingConfig] State listener added"); 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); 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) { } catch (Exception e) {
log.error("[PluginMessagingConfig] Failed to initialize plugin: {}", e.getMessage(), e); log.error("[PluginMessagingConfig] Failed to initialize plugin: {}", e.getMessage(), e);
@@ -105,11 +110,11 @@ public class PluginMessagingConfig {
*/ */
private MessagingPlugin createPlugin(String type) { private MessagingPlugin createPlugin(String type) {
return switch (type.toLowerCase()) { return switch (type.toLowerCase()) {
case "mqtt" -> new MqttMessagingPlugin(); case "mqtt" -> new MqttMessagingPlugin();
// Add more plugin types here in the future // Add more plugin types here in the future
// case "websocket" -> new WebSocketMessagingPlugin(); // case "websocket" -> new WebSocketMessagingPlugin();
// case "grpc" -> new GrpcMessagingPlugin(); // case "grpc" -> new GrpcMessagingPlugin();
default -> throw new IllegalArgumentException("Unknown plugin type: " + type); default -> throw new IllegalArgumentException("Unknown plugin type: " + type);
}; };
} }
@@ -120,17 +125,17 @@ public class PluginMessagingConfig {
PluginConfig config = new PluginConfig(); PluginConfig config = new PluginConfig();
switch (type.toLowerCase()) { switch (type.toLowerCase()) {
case "mqtt" -> { case "mqtt" -> {
config.setProperty("broker.host", mqttBrokerHost); config.setProperty("broker.host", mqttBrokerHost);
config.setProperty("broker.port", mqttBrokerPort); config.setProperty("broker.port", mqttBrokerPort);
config.setProperty("username", mqttUsername); config.setProperty("username", mqttUsername);
config.setProperty("password", mqttPassword); config.setProperty("password", mqttPassword);
config.setProperty("client.id", mqttClientId); config.setProperty("client.id", mqttClientId);
config.setProperty("auto.reconnect", true); config.setProperty("auto.reconnect", true);
config.setProperty("clean.start", true); config.setProperty("clean.start", true);
} }
// Add more plugin configurations here // Add more plugin configurations here
default -> throw new IllegalArgumentException("Unknown plugin type: " + type); default -> throw new IllegalArgumentException("Unknown plugin type: " + type);
} }
return config; return config;
@@ -139,9 +144,8 @@ public class PluginMessagingConfig {
/** /**
* Setup message subscriptions using the new plugin API. * Setup message subscriptions using the new plugin API.
*/ */
private void setupSubscriptions(MessageDeliveryService deliveryService, private void setupSubscriptions(MessageDeliveryService deliveryService, MessageController messageController,
MessageController messageController, ClientConnectionService clientConnectionService) {
ClientConnectionService clientConnectionService) {
log.info("[PluginMessagingConfig] Setting up message subscriptions"); log.info("[PluginMessagingConfig] Setting up message subscriptions");
try { try {
@@ -153,7 +157,8 @@ public class PluginMessagingConfig {
// ACK messages are wrapped in MessageEnvelope // ACK messages are wrapped in MessageEnvelope
MessageEnvelope envelope = objectMapper.readValue(json, MessageEnvelope.class); 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); deliveryService.handleAcknowledgment(ack);
} catch (Exception e) { } catch (Exception e) {
log.error("[PluginMessagingConfig] Error handling ACK message: {}", e.getMessage(), 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 // Register message handlers for different message types
String[] messageTypes = { String[] messageTypes = { "task_completed", "jobs/assigned", "message", "login", "pong" };
"task_completed",
"jobs/assigned",
"message",
"login",
"pong"
};
for (String messageType : messageTypes) { for (String messageType : messageTypes) {
pluginManager.registerMessageHandler(messageType, (clientId, payload) -> pluginManager.registerMessageHandler(messageType,
handleEnvelopedMessage(clientId, payload, deliveryService, messageController, clientConnectionService)); (clientId, payload) -> handleEnvelopedMessage(clientId, payload, deliveryService,
messageController, clientConnectionService));
} }
log.info("[PluginMessagingConfig] Message subscriptions initialized"); log.info("[PluginMessagingConfig] Message subscriptions initialized");
@@ -183,11 +183,11 @@ public class PluginMessagingConfig {
} }
/** /**
* Handle incoming enveloped message. * Handle incoming enveloped message. Supports both new envelope format and
* Supports both new envelope format and legacy format for backwards compatibility. * legacy format for backwards compatibility.
*/ */
private void handleEnvelopedMessage(String clientId, byte[] payload, MessageDeliveryService deliveryService, private void handleEnvelopedMessage(String clientId, byte[] payload, MessageDeliveryService deliveryService,
MessageController messageController, ClientConnectionService clientConnectionService) { MessageController messageController, ClientConnectionService clientConnectionService) {
try { try {
String json = new String(payload, StandardCharsets.UTF_8); String json = new String(payload, StandardCharsets.UTF_8);
log.info("[PluginMessagingConfig] Received JSON from client {}: {}", clientId, json); log.info("[PluginMessagingConfig] Received JSON from client {}: {}", clientId, json);
@@ -214,12 +214,12 @@ public class PluginMessagingConfig {
} }
/** /**
* Handle legacy message format (without envelope wrapper). * Handle legacy message format (without envelope wrapper). This supports older
* This supports older clients that don't use the envelope format. * clients that don't use the envelope format.
*/ */
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
private void handleLegacyMessage(String clientId, String json, private void handleLegacyMessage(String clientId, String json, MessageController messageController,
MessageController messageController, ClientConnectionService clientConnectionService) { ClientConnectionService clientConnectionService) {
try { try {
Map<String, Object> payload = objectMapper.readValue(json, Map.class); Map<String, Object> payload = objectMapper.readValue(json, Map.class);
log.info("[PluginMessagingConfig] Processing legacy message from client {}: {}", clientId, payload); 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); log.warn("[PluginMessagingConfig] Unknown legacy message format from client {}: {}", clientId, json);
} catch (Exception e) { } 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);
} }
} }
} }

View File

@@ -28,8 +28,8 @@ public class AcknowledgmentHandler {
} }
/** /**
* Route incoming message envelope to appropriate application handler. * Route incoming message envelope to appropriate application handler. Unwraps
* Unwraps the envelope and delegates to MessageController. * the envelope and delegates to MessageController.
*/ */
public void routeIncomingMessage(MessageEnvelope envelope) { public void routeIncomingMessage(MessageEnvelope envelope) {
try { try {
@@ -40,7 +40,8 @@ public class AcknowledgmentHandler {
// Convert payload to Map for routing // Convert payload to Map for routing
Map<String, Object> payloadMap = objectMapper.convertValue(payload, Map<String, Object> payloadMap = objectMapper.convertValue(payload,
new TypeReference<Map<String, Object>>() {}); new TypeReference<Map<String, Object>>() {
});
// Route based on topic pattern // Route based on topic pattern
if (topic.matches("/server/.+/task_completed")) { if (topic.matches("/server/.+/task_completed")) {
@@ -58,8 +59,7 @@ public class AcknowledgmentHandler {
} }
} catch (Exception e) { } catch (Exception e) {
log.error("[AckHandler] Error routing message {}: {}", log.error("[AckHandler] Error routing message {}: {}", envelope.getMessageId(), e.getMessage(), e);
envelope.getMessageId(), e.getMessage(), e);
} }
} }
@@ -96,8 +96,7 @@ public class AcknowledgmentHandler {
} }
/** /**
* Handle login request * Handle login request Topic: /server/login
* Topic: /server/login
*/ */
private void handleLogin(Map<String, Object> payload) { private void handleLogin(Map<String, Object> payload) {
try { try {
@@ -146,4 +145,3 @@ public class AcknowledgmentHandler {
} }
} }
} }

View File

@@ -59,4 +59,3 @@ public class DeliveryConfig {
*/ */
private int acknowledgedRetentionDays = 7; private int acknowledgedRetentionDays = 7;
} }

View File

@@ -6,28 +6,37 @@ import java.util.Optional;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
/** /**
* Service for reliable message delivery with acknowledgment tracking. * Service for reliable message delivery with acknowledgment tracking. Provides
* Provides guaranteed delivery with retry mechanism and acknowledgment handling. * guaranteed delivery with retry mechanism and acknowledgment handling.
*/ */
public interface MessageDeliveryService { 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 clientId
* @param messageType The type of message (e.g., "jobs", "message", "auth", "task") * The target client identifier
* @param payload The message payload (will be serialized to JSON) * @param messageType
* @param options Delivery options (retries, timeout, etc.) * 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 * @return CompletableFuture with delivery receipt
*/ */
CompletableFuture<DeliveryReceipt> sendToClient(String clientId, String messageType, Object payload, DeliveryOptions options); CompletableFuture<DeliveryReceipt> sendToClient(String clientId, String messageType, Object payload,
DeliveryOptions options);
/** /**
* Send a message to a specific client with default delivery options. * Send a message to a specific client with default delivery options.
* *
* @param clientId The target client identifier * @param clientId
* @param messageType The type of message * The target client identifier
* @param payload The message payload * @param messageType
* The type of message
* @param payload
* The message payload
* @return CompletableFuture with delivery receipt * @return CompletableFuture with delivery receipt
*/ */
default CompletableFuture<DeliveryReceipt> sendToClient(String clientId, String messageType, Object payload) { default CompletableFuture<DeliveryReceipt> sendToClient(String clientId, String messageType, Object payload) {
@@ -36,11 +45,17 @@ public interface MessageDeliveryService {
/** /**
* Send a message with delivery tracking and acknowledgment. * Send a message with delivery tracking and acknowledgment.
* @deprecated Use {@link #sendToClient(String, String, Object, DeliveryOptions)} instead
* *
* @param topic The destination topic * @deprecated Use
* @param payload The message payload (will be serialized to JSON) * {@link #sendToClient(String, String, Object, DeliveryOptions)}
* @param options Delivery options (retries, timeout, etc.) * instead
*
* @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 * @return CompletableFuture with delivery receipt
*/ */
@Deprecated @Deprecated
@@ -48,10 +63,13 @@ public interface MessageDeliveryService {
/** /**
* Send a message with default delivery options. * Send a message with default delivery options.
*
* @deprecated Use {@link #sendToClient(String, String, Object)} instead * @deprecated Use {@link #sendToClient(String, String, Object)} instead
* *
* @param topic The destination topic * @param topic
* @param payload The message payload * The destination topic
* @param payload
* The message payload
* @return CompletableFuture with delivery receipt * @return CompletableFuture with delivery receipt
*/ */
@Deprecated @Deprecated
@@ -60,25 +78,28 @@ public interface MessageDeliveryService {
} }
/** /**
* Handle incoming message envelope from transport layer. * Handle incoming message envelope from transport layer. Extracts payload and
* Extracts payload and routes to application layer. * routes to application layer.
* *
* @param envelope The received message envelope * @param envelope
* The received message envelope
*/ */
void handleIncomingMessage(MessageEnvelope envelope); void handleIncomingMessage(MessageEnvelope envelope);
/** /**
* Handle acknowledgment from client. * Handle acknowledgment from client. Updates delivery status and removes from
* Updates delivery status and removes from pending queue. * pending queue.
* *
* @param ack The acknowledgment message * @param ack
* The acknowledgment message
*/ */
void handleAcknowledgment(AcknowledgmentMessage ack); void handleAcknowledgment(AcknowledgmentMessage ack);
/** /**
* Get the current delivery status for a message. * 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 * @return Optional containing the delivery status, or empty if not found
*/ */
Optional<DeliveryStatus> getDeliveryStatus(String messageId); Optional<DeliveryStatus> getDeliveryStatus(String messageId);
@@ -86,29 +107,29 @@ public interface MessageDeliveryService {
/** /**
* Get detailed pending delivery information. * 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 * @return Optional containing the pending delivery, or empty if not found
*/ */
Optional<PendingDelivery> getPendingDelivery(String messageId); Optional<PendingDelivery> getPendingDelivery(String messageId);
/** /**
* Retry all pending deliveries that are ready for retry. * Retry all pending deliveries that are ready for retry. Called by scheduled
* Called by scheduled task. * task.
*/ */
void retryPendingDeliveries(); void retryPendingDeliveries();
/** /**
* Retry pending deliveries for a specific client. * Retry pending deliveries for a specific client. Called when a client
* Called when a client reconnects. * reconnects.
* *
* @param clientId The client identifier * @param clientId
* The client identifier
*/ */
void retryPendingDeliveriesForClient(String clientId); void retryPendingDeliveriesForClient(String clientId);
/** /**
* Clean up expired and completed deliveries. * Clean up expired and completed deliveries. Called by scheduled task.
* Called by scheduled task.
*/ */
void cleanupOldDeliveries(); void cleanupOldDeliveries();
} }

View File

@@ -45,12 +45,8 @@ public class MessageDeliveryServiceImpl implements MessageDeliveryService {
private ScheduledExecutorService retryScheduler; private ScheduledExecutorService retryScheduler;
public MessageDeliveryServiceImpl( public MessageDeliveryServiceImpl(PluginManager pluginManager, PendingDeliveryRepository pendingDeliveryRepository,
PluginManager pluginManager, AcknowledgmentHandler acknowledgmentHandler, DeliveryConfig config, ObjectMapper objectMapper,
PendingDeliveryRepository pendingDeliveryRepository,
AcknowledgmentHandler acknowledgmentHandler,
DeliveryConfig config,
ObjectMapper objectMapper,
ClientConnectionService clientConnectionService) { ClientConnectionService clientConnectionService) {
this.pluginManager = pluginManager; this.pluginManager = pluginManager;
this.pendingDeliveryRepository = pendingDeliveryRepository; this.pendingDeliveryRepository = pendingDeliveryRepository;
@@ -67,14 +63,10 @@ public class MessageDeliveryServiceImpl implements MessageDeliveryService {
t.setDaemon(true); t.setDaemon(true);
return t; return t;
}); });
retryScheduler.scheduleAtFixedRate( retryScheduler.scheduleAtFixedRate(this::retryPendingDeliveries, ackRetryIntervalSeconds,
this::retryPendingDeliveries, ackRetryIntervalSeconds, TimeUnit.SECONDS);
ackRetryIntervalSeconds, log.info("[MessageDelivery] Started retry scheduler (interval: {}s, max retries: {})", ackRetryIntervalSeconds,
ackRetryIntervalSeconds, ackMaxRetries);
TimeUnit.SECONDS
);
log.info("[MessageDelivery] Started retry scheduler (interval: {}s, max retries: {})",
ackRetryIntervalSeconds, ackMaxRetries);
} }
@PreDestroy @PreDestroy
@@ -94,7 +86,8 @@ public class MessageDeliveryServiceImpl implements MessageDeliveryService {
} }
@Override @Override
public CompletableFuture<DeliveryReceipt> sendToClient(String clientId, String messageType, Object payload, DeliveryOptions options) { public CompletableFuture<DeliveryReceipt> sendToClient(String clientId, String messageType, Object payload,
DeliveryOptions options) {
try { try {
String destination = clientId + "/" + messageType; String destination = clientId + "/" + messageType;
final LocalDateTime expiresAt = options.calculateExpiryTime(); final LocalDateTime expiresAt = options.calculateExpiryTime();
@@ -105,19 +98,12 @@ public class MessageDeliveryServiceImpl implements MessageDeliveryService {
byte[] envelopeData = json.getBytes(StandardCharsets.UTF_8); byte[] envelopeData = json.getBytes(StandardCharsets.UTF_8);
if (options.isRequiresAck()) { if (options.isRequiresAck()) {
PendingDelivery pending = new PendingDelivery( PendingDelivery pending = new PendingDelivery(messageId, destination, envelopeData,
messageId, options.getMaxRetries(), expiresAt);
destination,
envelopeData,
options.getMaxRetries(),
expiresAt
);
pendingDeliveryRepository.save(pending); pendingDeliveryRepository.save(pending);
} }
SendOptions sendOptions = SendOptions.builder() SendOptions sendOptions = SendOptions.builder().qos(options.getQos()).retained(options.isRetained())
.qos(options.getQos())
.retained(options.isRetained())
.build(); .build();
final boolean requiresAck = options.isRequiresAck(); 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); log.info("[MessageDelivery] Sending message {} to client {} (type: {})", messageId, clientId, messageType);
return pluginManager.sendToClient(clientId, messageType, envelopeData, sendOptions) return pluginManager.sendToClient(clientId, messageType, envelopeData, sendOptions).thenApply(v -> {
.thenApply(v -> { if (requiresAck) {
if (requiresAck) { updatePendingDeliveryAfterSend(messageId, ackTimeout);
updatePendingDeliveryAfterSend(messageId, ackTimeout); }
} return DeliveryReceipt.submitted(messageId, destination, expiresAt);
return DeliveryReceipt.submitted(messageId, destination, expiresAt); }).exceptionally(ex -> {
}) log.error("[MessageDelivery] Failed to send message {}: {}", messageId, ex.getMessage());
.exceptionally(ex -> { if (requiresAck) {
log.error("[MessageDelivery] Failed to send message {}: {}", messageId, ex.getMessage()); markPendingDeliveryFailed(messageId, ex.getMessage());
if (requiresAck) { }
markPendingDeliveryFailed(messageId, ex.getMessage()); return DeliveryReceipt.failed(messageId, destination);
} });
return DeliveryReceipt.failed(messageId, destination);
});
} catch (Exception e) { } catch (Exception e) {
log.error("[MessageDelivery] Error creating message for client {}: {}", clientId, e.getMessage()); log.error("[MessageDelivery] Error creating message for client {}: {}", clientId, e.getMessage());
@@ -162,8 +146,7 @@ public class MessageDeliveryServiceImpl implements MessageDeliveryService {
@Override @Override
public void handleIncomingMessage(MessageEnvelope envelope) { public void handleIncomingMessage(MessageEnvelope envelope) {
try { try {
log.info("[MessageDelivery] Received message {} on topic {}", log.info("[MessageDelivery] Received message {} on topic {}", envelope.getMessageId(), envelope.getTopic());
envelope.getMessageId(), envelope.getTopic());
if (envelope.isRequiresAck()) { if (envelope.isRequiresAck()) {
sendAcknowledgment(envelope); sendAcknowledgment(envelope);
@@ -172,16 +155,15 @@ public class MessageDeliveryServiceImpl implements MessageDeliveryService {
acknowledgmentHandler.routeIncomingMessage(envelope); acknowledgmentHandler.routeIncomingMessage(envelope);
} catch (Exception e) { } catch (Exception e) {
log.error("[MessageDelivery] Error handling incoming message {}: {}", log.error("[MessageDelivery] Error handling incoming message {}: {}", envelope.getMessageId(),
envelope.getMessageId(), e.getMessage()); e.getMessage());
} }
} }
@Override @Override
public void handleAcknowledgment(AcknowledgmentMessage ack) { public void handleAcknowledgment(AcknowledgmentMessage ack) {
try { try {
log.info("[MessageDelivery] Received ACK for message {} (status: {})", log.info("[MessageDelivery] Received ACK for message {} (status: {})", ack.getMessageId(), ack.getStatus());
ack.getMessageId(), ack.getStatus());
Optional<PendingDelivery> pendingOpt = pendingDeliveryRepository.findByMessageId(ack.getMessageId()); Optional<PendingDelivery> pendingOpt = pendingDeliveryRepository.findByMessageId(ack.getMessageId());
@@ -192,27 +174,25 @@ public class MessageDeliveryServiceImpl implements MessageDeliveryService {
PendingDelivery pending = pendingOpt.get(); PendingDelivery pending = pendingOpt.get();
switch (ack.getStatus()) { switch (ack.getStatus()) {
case RECEIVED, PROCESSED -> { case RECEIVED, PROCESSED -> {
pendingDeliveryRepository.delete(pending); pendingDeliveryRepository.delete(pending);
} }
case FAILED -> { case FAILED -> {
pending.markAsFailed(ack.getErrorMessage()); pending.markAsFailed(ack.getErrorMessage());
pendingDeliveryRepository.save(pending); pendingDeliveryRepository.save(pending);
log.warn("[MessageDelivery] Message {} failed on client: {}", log.warn("[MessageDelivery] Message {} failed on client: {}", ack.getMessageId(),
ack.getMessageId(), ack.getErrorMessage()); ack.getErrorMessage());
} }
} }
} catch (Exception e) { } catch (Exception e) {
log.error("[MessageDelivery] Error handling ACK for message {}: {}", log.error("[MessageDelivery] Error handling ACK for message {}: {}", ack.getMessageId(), e.getMessage());
ack.getMessageId(), e.getMessage());
} }
} }
@Override @Override
public Optional<DeliveryStatus> getDeliveryStatus(String messageId) { public Optional<DeliveryStatus> getDeliveryStatus(String messageId) {
return pendingDeliveryRepository.findByMessageId(messageId) return pendingDeliveryRepository.findByMessageId(messageId).map(PendingDelivery::getStatus);
.map(PendingDelivery::getStatus);
} }
@Override @Override
@@ -272,11 +252,8 @@ public class MessageDeliveryServiceImpl implements MessageDeliveryService {
pendingDeliveryRepository.deleteAll(oldAcknowledged); pendingDeliveryRepository.deleteAll(oldAcknowledged);
} }
List<PendingDelivery> expired = pendingDeliveryRepository List<PendingDelivery> expired = pendingDeliveryRepository.findByStatusInAndExpiresAtBefore(
.findByStatusInAndExpiresAtBefore( List.of(DeliveryStatus.PENDING, DeliveryStatus.SENT), LocalDateTime.now());
List.of(DeliveryStatus.PENDING, DeliveryStatus.SENT),
LocalDateTime.now()
);
for (PendingDelivery pending : expired) { for (PendingDelivery pending : expired) {
pending.markAsExpired(); pending.markAsExpired();
@@ -352,17 +329,15 @@ public class MessageDeliveryServiceImpl implements MessageDeliveryService {
pending.incrementRetryCount(); pending.incrementRetryCount();
SendOptions options = SendOptions.reliable(); SendOptions options = SendOptions.reliable();
pluginManager.sendToClient(clientId, messageType, pending.getEnvelopeData(), options) pluginManager.sendToClient(clientId, messageType, pending.getEnvelopeData(), options).thenAccept(v -> {
.thenAccept(v -> { pending.markAsSent(nextRetry);
pending.markAsSent(nextRetry); pendingDeliveryRepository.save(pending);
pendingDeliveryRepository.save(pending); }).exceptionally(ex -> {
}) log.error("[MessageDelivery] Retry failed for message {}: {}", pending.getMessageId(), ex.getMessage());
.exceptionally(ex -> { pending.markAsFailed(ex.getMessage());
log.error("[MessageDelivery] Retry failed for message {}: {}", pending.getMessageId(), ex.getMessage()); pendingDeliveryRepository.save(pending);
pending.markAsFailed(ex.getMessage()); return null;
pendingDeliveryRepository.save(pending); });
return null;
});
} catch (Exception e) { } catch (Exception e) {
log.error("[MessageDelivery] Error retrying delivery {}: {}", pending.getMessageId(), e.getMessage()); log.error("[MessageDelivery] Error retrying delivery {}: {}", pending.getMessageId(), e.getMessage());
@@ -376,11 +351,8 @@ public class MessageDeliveryServiceImpl implements MessageDeliveryService {
return; return;
} }
AcknowledgmentMessage ack = new AcknowledgmentMessage( AcknowledgmentMessage ack = new AcknowledgmentMessage(envelope.getMessageId(), AckStatus.RECEIVED,
envelope.getMessageId(), "server");
AckStatus.RECEIVED,
"server"
);
String ackJson = objectMapper.writeValueAsString(ack); String ackJson = objectMapper.writeValueAsString(ack);
byte[] ackData = ackJson.getBytes(StandardCharsets.UTF_8); byte[] ackData = ackJson.getBytes(StandardCharsets.UTF_8);
@@ -389,12 +361,14 @@ public class MessageDeliveryServiceImpl implements MessageDeliveryService {
pluginManager.sendAckToClient(clientId, envelope.getMessageId(), ackData, SendOptions.fireAndForget()) pluginManager.sendAckToClient(clientId, envelope.getMessageId(), ackData, SendOptions.fireAndForget())
.exceptionally(ex -> { .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; return null;
}); });
} catch (Exception e) { } 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());
} }
} }

View File

@@ -43,4 +43,3 @@ public class RetryScheduler {
} }
} }
} }

View File

@@ -19,4 +19,3 @@ public enum AckStatus {
*/ */
FAILED FAILED
} }

View File

@@ -60,4 +60,3 @@ public class AcknowledgmentMessage {
this.errorMessage = errorMessage; this.errorMessage = errorMessage;
} }
} }

View File

@@ -71,22 +71,14 @@ public class DeliveryOptions {
* Options for fire-and-forget messages (no acknowledgment required) * Options for fire-and-forget messages (no acknowledgment required)
*/ */
public static DeliveryOptions fireAndForget() { public static DeliveryOptions fireAndForget() {
return DeliveryOptions.builder() return DeliveryOptions.builder().requiresAck(false).maxRetries(0).build();
.requiresAck(false)
.maxRetries(0)
.build();
} }
/** /**
* Options for critical messages with extended retry * Options for critical messages with extended retry
*/ */
public static DeliveryOptions critical() { public static DeliveryOptions critical() {
return DeliveryOptions.builder() return DeliveryOptions.builder().requiresAck(true).maxRetries(5).ackTimeout(Duration.ofMinutes(2))
.requiresAck(true) .expiryDuration(Duration.ofDays(7)).build();
.maxRetries(5)
.ackTimeout(Duration.ofMinutes(2))
.expiryDuration(Duration.ofDays(7))
.build();
} }
} }

View File

@@ -43,26 +43,13 @@ public class DeliveryReceipt {
* Create a receipt for a successfully submitted message * Create a receipt for a successfully submitted message
*/ */
public static DeliveryReceipt submitted(String messageId, String topic, LocalDateTime expiresAt) { public static DeliveryReceipt submitted(String messageId, String topic, LocalDateTime expiresAt) {
return new DeliveryReceipt( return new DeliveryReceipt(messageId, topic, LocalDateTime.now(), DeliveryStatus.PENDING, expiresAt);
messageId,
topic,
LocalDateTime.now(),
DeliveryStatus.PENDING,
expiresAt
);
} }
/** /**
* Create a receipt for a failed submission * Create a receipt for a failed submission
*/ */
public static DeliveryReceipt failed(String messageId, String topic) { public static DeliveryReceipt failed(String messageId, String topic) {
return new DeliveryReceipt( return new DeliveryReceipt(messageId, topic, LocalDateTime.now(), DeliveryStatus.FAILED, null);
messageId,
topic,
LocalDateTime.now(),
DeliveryStatus.FAILED,
null
);
} }
} }

View File

@@ -29,4 +29,3 @@ public enum DeliveryStatus {
*/ */
EXPIRED EXPIRED
} }

View File

@@ -11,9 +11,9 @@ import java.util.Map;
import java.util.UUID; import java.util.UUID;
/** /**
* Envelope that wraps all messages sent through the messaging system. * Envelope that wraps all messages sent through the messaging system. Contains
* Contains metadata for delivery tracking and acknowledgment. * metadata for delivery tracking and acknowledgment. This is a DTO class - not
* This is a DTO class - not persisted to MongoDB. * persisted to MongoDB.
*/ */
@Data @Data
@NoArgsConstructor @NoArgsConstructor

View File

@@ -14,8 +14,8 @@ import org.springframework.data.mongodb.core.mapping.Field;
import java.time.LocalDateTime; import java.time.LocalDateTime;
/** /**
* Represents a message delivery that is pending acknowledgment. * Represents a message delivery that is pending acknowledgment. Stored in
* Stored in MongoDB for retry and tracking purposes. * MongoDB for retry and tracking purposes.
*/ */
@Data @Data
@NoArgsConstructor @NoArgsConstructor
@@ -112,8 +112,8 @@ public class PendingDelivery {
/** /**
* Constructor for new pending delivery * Constructor for new pending delivery
*/ */
public PendingDelivery(String messageId, String topic, byte[] envelopeData, public PendingDelivery(String messageId, String topic, byte[] envelopeData, int maxRetries,
int maxRetries, LocalDateTime expiresAt) { LocalDateTime expiresAt) {
this.messageId = messageId; this.messageId = messageId;
this.topic = topic; this.topic = topic;
this.envelopeData = envelopeData; this.envelopeData = envelopeData;
@@ -183,11 +183,8 @@ public class PendingDelivery {
* Check if ready for retry * Check if ready for retry
*/ */
public boolean isReadyForRetry() { public boolean isReadyForRetry() {
return status == DeliveryStatus.SENT return status == DeliveryStatus.SENT && nextRetryAt != null && LocalDateTime.now().isAfter(nextRetryAt)
&& nextRetryAt != null && !hasReachedMaxRetries() && !isExpired();
&& LocalDateTime.now().isAfter(nextRetryAt)
&& !hasReachedMaxRetries()
&& !isExpired();
} }
/** /**
@@ -211,4 +208,3 @@ public class PendingDelivery {
return id != null ? id.toString() : null; return id != null ? id.toString() : null;
} }
} }

View File

@@ -105,4 +105,3 @@ public class ConnectionStateEvent {
return state == ConnectionState.ERROR; return state == ConnectionState.ERROR;
} }
} }

View File

@@ -3,9 +3,9 @@ package de.assecutor.votianlt.messaging.plugin;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
/** /**
* Interface for messaging transport plugins. * Interface for messaging transport plugins. Plugins implement specific
* Plugins implement specific transport protocols (MQTT, WebSocket, gRPC, etc.) * transport protocols (MQTT, WebSocket, gRPC, etc.) and provide a unified
* and provide a unified interface for the messaging layer. * interface for the messaging layer.
* *
* The plugin is responsible for managing the internal topic/channel structure. * The plugin is responsible for managing the internal topic/channel structure.
* The messaging layer only uses clientId and messageType as identifiers. * The messaging layer only uses clientId and messageType as identifiers.
@@ -13,73 +13,95 @@ import java.util.concurrent.CompletableFuture;
public interface MessagingPlugin { public interface MessagingPlugin {
/** /**
* Initialize the plugin with configuration. * Initialize the plugin with configuration. Called once during application
* Called once during application startup. * startup.
* *
* @param config Plugin-specific configuration * @param config
* @throws PluginException if initialization fails * Plugin-specific configuration
* @throws PluginException
* if initialization fails
*/ */
void init(PluginConfig config) throws PluginException; void init(PluginConfig config) throws PluginException;
/** /**
* Shutdown the plugin and release resources. * Shutdown the plugin and release resources. Called during application
* Called during application shutdown. * shutdown.
* *
* @throws PluginException if shutdown fails * @throws PluginException
* if shutdown fails
*/ */
void exit() throws PluginException; void exit() throws PluginException;
/** /**
* Callback when connection state changes. * Callback when connection state changes. The plugin should call this method
* The plugin should call this method when the underlying transport * when the underlying transport connection state changes (connected,
* connection state changes (connected, disconnected, error). * disconnected, error).
* *
* @param listener Connection state listener * @param listener
* Connection state listener
*/ */
void setConnectionListener(ConnectionStateListener listener); void setConnectionListener(ConnectionStateListener listener);
/** /**
* Send a message to a specific client. * Send a message to a specific client. The plugin is responsible for
* The plugin is responsible for determining the correct topic/channel based on the messageType. * determining the correct topic/channel based on the messageType.
* *
* @param clientId Target client identifier * @param clientId
* @param messageType Type of message (e.g., "jobs", "message", "auth", "task") * Target client identifier
* @param payload Message payload as byte array * @param messageType
* @param options Transport-specific options * 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 * @return CompletableFuture that completes when message is sent
* @throws PluginException if sending fails * @throws PluginException
* if sending fails
*/ */
CompletableFuture<Void> sendToClient(String clientId, String messageType, byte[] payload, SendOptions options) throws PluginException; CompletableFuture<Void> sendToClient(String clientId, String messageType, byte[] payload, SendOptions options)
throws PluginException;
/** /**
* Send an acknowledgment to a specific client. * Send an acknowledgment to a specific client. The plugin is responsible for
* The plugin is responsible for determining the correct ACK topic/channel. * determining the correct ACK topic/channel.
* *
* @param clientId Target client identifier * @param clientId
* @param messageId Message ID being acknowledged * Target client identifier
* @param payload ACK payload as byte array * @param messageId
* @param options Transport-specific options * Message ID being acknowledged
* @param payload
* ACK payload as byte array
* @param options
* Transport-specific options
* @return CompletableFuture that completes when ACK is sent * @return CompletableFuture that completes when ACK is sent
* @throws PluginException if sending fails * @throws PluginException
* if sending fails
*/ */
CompletableFuture<Void> sendAckToClient(String clientId, String messageId, byte[] payload, SendOptions options) throws PluginException; CompletableFuture<Void> sendAckToClient(String clientId, String messageId, byte[] payload, SendOptions options)
throws PluginException;
/** /**
* Register a handler for incoming messages of a specific type from clients. * Register a handler for incoming messages of a specific type from clients. The
* The plugin is responsible for subscribing to the appropriate topics/channels. * 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 messageType
* @param handler Message handler to be called when a message is received * Type of message to handle (e.g., "task_completed", "message",
* @throws PluginException if registration fails * "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; void registerMessageHandler(String messageType, ClientMessageHandler handler) throws PluginException;
/** /**
* Register a handler for incoming acknowledgments from clients. * Register a handler for incoming acknowledgments from clients. The plugin is
* The plugin is responsible for subscribing to the appropriate ACK topics/channels. * responsible for subscribing to the appropriate ACK topics/channels.
* *
* @param handler ACK handler to be called when an ACK is received * @param handler
* @throws PluginException if registration fails * ACK handler to be called when an ACK is received
* @throws PluginException
* if registration fails
*/ */
void registerAckHandler(AckHandler handler) throws PluginException; void registerAckHandler(AckHandler handler) throws PluginException;
@@ -119,22 +141,25 @@ public interface MessagingPlugin {
/** /**
* Called when connection state changes. * Called when connection state changes.
* *
* @param event Connection state event * @param event
* Connection state event
*/ */
void onConnectionStateChanged(ConnectionStateEvent event); void onConnectionStateChanged(ConnectionStateEvent event);
} }
/** /**
* Handler for received messages from clients. * Handler for received messages from clients. Includes the clientId extracted
* Includes the clientId extracted from the topic/channel. * from the topic/channel.
*/ */
@FunctionalInterface @FunctionalInterface
interface ClientMessageHandler { interface ClientMessageHandler {
/** /**
* Called when a message is received from a client. * Called when a message is received from a client.
* *
* @param clientId Client identifier extracted from the topic/channel * @param clientId
* @param payload Message payload as byte array * Client identifier extracted from the topic/channel
* @param payload
* Message payload as byte array
*/ */
void onMessageReceived(String clientId, byte[] payload); void onMessageReceived(String clientId, byte[] payload);
} }
@@ -147,10 +172,11 @@ public interface MessagingPlugin {
/** /**
* Called when an ACK is received from a client. * Called when an ACK is received from a client.
* *
* @param messageId Message ID being acknowledged * @param messageId
* @param payload ACK payload as byte array * Message ID being acknowledged
* @param payload
* ACK payload as byte array
*/ */
void onAckReceived(String messageId, byte[] payload); void onAckReceived(String messageId, byte[] payload);
} }
} }

View File

@@ -9,8 +9,8 @@ import java.util.HashMap;
import java.util.Map; import java.util.Map;
/** /**
* Configuration for messaging plugins. * Configuration for messaging plugins. Provides a flexible key-value store for
* Provides a flexible key-value store for plugin-specific settings. * plugin-specific settings.
*/ */
@Data @Data
@Builder @Builder
@@ -27,7 +27,8 @@ public class PluginConfig {
/** /**
* Get a string property. * Get a string property.
* *
* @param key Property key * @param key
* Property key
* @return Property value or null if not found * @return Property value or null if not found
*/ */
public String getString(String key) { public String getString(String key) {
@@ -38,8 +39,10 @@ public class PluginConfig {
/** /**
* Get a string property with default value. * Get a string property with default value.
* *
* @param key Property key * @param key
* @param defaultValue Default value if property not found * Property key
* @param defaultValue
* Default value if property not found
* @return Property value or default * @return Property value or default
*/ */
public String getString(String key, String defaultValue) { public String getString(String key, String defaultValue) {
@@ -50,7 +53,8 @@ public class PluginConfig {
/** /**
* Get an integer property. * Get an integer property.
* *
* @param key Property key * @param key
* Property key
* @return Property value or null if not found * @return Property value or null if not found
*/ */
public Integer getInt(String key) { public Integer getInt(String key) {
@@ -70,8 +74,10 @@ public class PluginConfig {
/** /**
* Get an integer property with default value. * Get an integer property with default value.
* *
* @param key Property key * @param key
* @param defaultValue Default value if property not found * Property key
* @param defaultValue
* Default value if property not found
* @return Property value or default * @return Property value or default
*/ */
public int getInt(String key, int defaultValue) { public int getInt(String key, int defaultValue) {
@@ -82,7 +88,8 @@ public class PluginConfig {
/** /**
* Get a boolean property. * Get a boolean property.
* *
* @param key Property key * @param key
* Property key
* @return Property value or null if not found * @return Property value or null if not found
*/ */
public Boolean getBoolean(String key) { public Boolean getBoolean(String key) {
@@ -98,8 +105,10 @@ public class PluginConfig {
/** /**
* Get a boolean property with default value. * Get a boolean property with default value.
* *
* @param key Property key * @param key
* @param defaultValue Default value if property not found * Property key
* @param defaultValue
* Default value if property not found
* @return Property value or default * @return Property value or default
*/ */
public boolean getBoolean(String key, boolean defaultValue) { public boolean getBoolean(String key, boolean defaultValue) {
@@ -110,8 +119,10 @@ public class PluginConfig {
/** /**
* Set a property. * Set a property.
* *
* @param key Property key * @param key
* @param value Property value * Property key
* @param value
* Property value
*/ */
public void setProperty(String key, Object value) { public void setProperty(String key, Object value) {
properties.put(key, value); properties.put(key, value);
@@ -120,11 +131,11 @@ public class PluginConfig {
/** /**
* Check if a property exists. * Check if a property exists.
* *
* @param key Property key * @param key
* Property key
* @return true if property exists * @return true if property exists
*/ */
public boolean hasProperty(String key) { public boolean hasProperty(String key) {
return properties.containsKey(key); return properties.containsKey(key);
} }
} }

View File

@@ -17,4 +17,3 @@ public class PluginException extends Exception {
super(cause); super(cause);
} }
} }

View File

@@ -10,8 +10,8 @@ import java.util.Optional;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
/** /**
* Manager for messaging plugins. * Manager for messaging plugins. Handles plugin lifecycle, registration, and
* Handles plugin lifecycle, registration, and delegation. * delegation.
*/ */
@Component @Component
@Slf4j @Slf4j
@@ -24,9 +24,12 @@ public class PluginManager {
/** /**
* Initialize and activate a plugin. * Initialize and activate a plugin.
* *
* @param plugin Plugin to activate * @param plugin
* @param config Plugin configuration * Plugin to activate
* @throws PluginException if initialization fails * @param config
* Plugin configuration
* @throws PluginException
* if initialization fails
*/ */
public void activatePlugin(MessagingPlugin plugin, PluginConfig config) throws PluginException { public void activatePlugin(MessagingPlugin plugin, PluginConfig config) throws PluginException {
log.info("[PluginManager] Activating plugin: {}", plugin.getPluginName()); log.info("[PluginManager] Activating plugin: {}", plugin.getPluginName());
@@ -43,11 +46,8 @@ public class PluginManager {
// Set connection listener // Set connection listener
plugin.setConnectionListener(event -> { plugin.setConnectionListener(event -> {
String previousState = event.getPreviousState() != null String previousState = event.getPreviousState() != null ? event.getPreviousState().toString() : "NONE";
? event.getPreviousState().toString() log.info("[PluginManager] Connection state changed: {} -> {}", previousState, event.getState());
: "NONE";
log.info("[PluginManager] Connection state changed: {} -> {}",
previousState, event.getState());
connectionHistory.add(event); connectionHistory.add(event);
notifyStateListeners(event); notifyStateListeners(event);
}); });
@@ -56,8 +56,7 @@ public class PluginManager {
plugin.init(config); plugin.init(config);
activePlugin = plugin; activePlugin = plugin;
log.info("[PluginManager] Plugin activated: {} v{}", log.info("[PluginManager] Plugin activated: {} v{}", plugin.getPluginName(), plugin.getPluginVersion());
plugin.getPluginName(), plugin.getPluginVersion());
} }
/** /**
@@ -72,14 +71,20 @@ public class PluginManager {
/** /**
* Send a message to a specific client via the active plugin. * Send a message to a specific client via the active plugin.
* *
* @param clientId Target client identifier * @param clientId
* @param messageType Type of message (e.g., "jobs", "message", "auth", "task") * Target client identifier
* @param payload Message payload * @param messageType
* @param options Send options * 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 * @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<Void> sendToClient(String clientId, String messageType, byte[] payload, SendOptions options) throws PluginException { public CompletableFuture<Void> sendToClient(String clientId, String messageType, byte[] payload,
SendOptions options) throws PluginException {
if (activePlugin == null) { if (activePlugin == null) {
return CompletableFuture.failedFuture(new PluginException("No active plugin")); 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. * Send an acknowledgment to a specific client via the active plugin.
* *
* @param clientId Target client identifier * @param clientId
* @param messageId Message ID being acknowledged * Target client identifier
* @param payload ACK payload * @param messageId
* @param options Send options * Message ID being acknowledged
* @param payload
* ACK payload
* @param options
* Send options
* @return CompletableFuture that completes when ACK is sent * @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<Void> sendAckToClient(String clientId, String messageId, byte[] payload, SendOptions options) throws PluginException { public CompletableFuture<Void> sendAckToClient(String clientId, String messageId, byte[] payload,
SendOptions options) throws PluginException {
if (activePlugin == null) { if (activePlugin == null) {
return CompletableFuture.failedFuture(new PluginException("No active plugin")); 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. * Register a handler for incoming messages of a specific type from clients.
* *
* @param messageType Type of message to handle * @param messageType
* @param handler Message handler * Type of message to handle
* @throws PluginException if no plugin is active or registration fails * @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) { if (activePlugin == null) {
throw new PluginException("No active plugin"); throw new PluginException("No active plugin");
} }
@@ -131,8 +146,10 @@ public class PluginManager {
/** /**
* Register a handler for incoming acknowledgments from clients. * Register a handler for incoming acknowledgments from clients.
* *
* @param handler ACK handler * @param handler
* @throws PluginException if no plugin is active or registration fails * ACK handler
* @throws PluginException
* if no plugin is active or registration fails
*/ */
public void registerAckHandler(MessagingPlugin.AckHandler handler) throws PluginException { public void registerAckHandler(MessagingPlugin.AckHandler handler) throws PluginException {
if (activePlugin == null) { if (activePlugin == null) {
@@ -184,7 +201,8 @@ public class PluginManager {
/** /**
* Add a plugin state listener. * Add a plugin state listener.
* *
* @param listener State listener * @param listener
* State listener
*/ */
public void addStateListener(PluginStateListener listener) { public void addStateListener(PluginStateListener listener) {
stateListeners.add(listener); stateListeners.add(listener);
@@ -193,7 +211,8 @@ public class PluginManager {
/** /**
* Remove a plugin state listener. * Remove a plugin state listener.
* *
* @param listener State listener * @param listener
* State listener
*/ */
public void removeStateListener(PluginStateListener listener) { public void removeStateListener(PluginStateListener listener) {
stateListeners.remove(listener); stateListeners.remove(listener);
@@ -202,7 +221,8 @@ public class PluginManager {
/** /**
* Notify all state listeners of a connection state change. * Notify all state listeners of a connection state change.
* *
* @param event Connection state event * @param event
* Connection state event
*/ */
private void notifyStateListeners(ConnectionStateEvent event) { private void notifyStateListeners(ConnectionStateEvent event) {
for (PluginStateListener listener : stateListeners) { for (PluginStateListener listener : stateListeners) {
@@ -243,9 +263,9 @@ public class PluginManager {
/** /**
* Called when plugin connection state changes. * Called when plugin connection state changes.
* *
* @param event Connection state event * @param event
* Connection state event
*/ */
void onConnectionStateChanged(ConnectionStateEvent event); void onConnectionStateChanged(ConnectionStateEvent event);
} }
} }

View File

@@ -70,7 +70,8 @@ public class PluginMetadata {
/** /**
* Check if a feature is supported. * Check if a feature is supported.
* *
* @param feature Feature name * @param feature
* Feature name
* @return true if supported * @return true if supported
*/ */
public boolean supportsFeature(String feature) { public boolean supportsFeature(String feature) {
@@ -80,7 +81,8 @@ public class PluginMetadata {
/** /**
* Add a supported feature. * Add a supported feature.
* *
* @param feature Feature name * @param feature
* Feature name
*/ */
public void addSupportedFeature(String feature) { public void addSupportedFeature(String feature) {
if (!supportedFeatures.contains(feature)) { if (!supportedFeatures.contains(feature)) {
@@ -88,4 +90,3 @@ public class PluginMetadata {
} }
} }
} }

View File

@@ -53,7 +53,8 @@ public class ReceivedMessage {
/** /**
* Get metadata value. * Get metadata value.
* *
* @param key Metadata key * @param key
* Metadata key
* @return Metadata value or null * @return Metadata value or null
*/ */
public Object getMetadata(String key) { public Object getMetadata(String key) {
@@ -63,8 +64,10 @@ public class ReceivedMessage {
/** /**
* Set metadata value. * Set metadata value.
* *
* @param key Metadata key * @param key
* @param value Metadata value * Metadata key
* @param value
* Metadata value
*/ */
public void setMetadata(String key, Object value) { public void setMetadata(String key, Object value) {
metadata.put(key, 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; return payload != null ? new String(payload, java.nio.charset.StandardCharsets.UTF_8) : null;
} }
} }

View File

@@ -9,8 +9,8 @@ import java.util.HashMap;
import java.util.Map; import java.util.Map;
/** /**
* Options for sending messages via plugins. * Options for sending messages via plugins. Provides transport-agnostic options
* Provides transport-agnostic options with extensibility for plugin-specific settings. * with extensibility for plugin-specific settings.
*/ */
@Data @Data
@Builder @Builder
@@ -50,7 +50,8 @@ public class SendOptions {
/** /**
* Get an additional option. * Get an additional option.
* *
* @param key Option key * @param key
* Option key
* @return Option value or null * @return Option value or null
*/ */
public Object getAdditionalOption(String key) { public Object getAdditionalOption(String key) {
@@ -60,8 +61,10 @@ public class SendOptions {
/** /**
* Set an additional option. * Set an additional option.
* *
* @param key Option key * @param key
* @param value Option value * Option key
* @param value
* Option value
*/ */
public void setAdditionalOption(String key, Object value) { public void setAdditionalOption(String key, Object value) {
additionalOptions.put(key, value); additionalOptions.put(key, value);
@@ -82,10 +85,7 @@ public class SendOptions {
* @return Fire-and-forget options * @return Fire-and-forget options
*/ */
public static SendOptions fireAndForget() { public static SendOptions fireAndForget() {
return SendOptions.builder() return SendOptions.builder().qos(0).retained(false).build();
.qos(0)
.retained(false)
.build();
} }
/** /**
@@ -94,10 +94,6 @@ public class SendOptions {
* @return Reliable delivery options * @return Reliable delivery options
*/ */
public static SendOptions reliable() { public static SendOptions reliable() {
return SendOptions.builder() return SendOptions.builder().qos(2).retained(false).build();
.qos(2)
.retained(false)
.build();
} }
} }

View File

@@ -15,14 +15,14 @@ import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
/** /**
* MQTT implementation of the MessagingPlugin interface. * MQTT implementation of the MessagingPlugin interface. Uses HiveMQ MQTT 5
* Uses HiveMQ MQTT 5 client for communication. * client for communication.
* *
* Topic Structure (managed internally): * Topic Structure (managed internally): - Server -> Client:
* - Server -> Client: /client/{clientId}/{messageType} * /client/{clientId}/{messageType} - Client -> Server:
* - Client -> Server: /server/{clientId}/{messageType} * /server/{clientId}/{messageType} - ACK Server -> Client:
* - ACK Server -> Client: /client/{clientId}/ack (messageId in payload) * /client/{clientId}/ack (messageId in payload) - ACK Client -> Server:
* - ACK Client -> Server: /server/{clientId}/ack (messageId in payload) * /server/{clientId}/ack (messageId in payload)
*/ */
@Slf4j @Slf4j
public class MqttMessagingPlugin implements MessagingPlugin { public class MqttMessagingPlugin implements MessagingPlugin {
@@ -31,12 +31,12 @@ public class MqttMessagingPlugin implements MessagingPlugin {
private static final String PLUGIN_VERSION = "2.0.0"; private static final String PLUGIN_VERSION = "2.0.0";
// Topic templates // Topic templates
private static final String TOPIC_TO_CLIENT = "/client/%s/%s"; // /client/{clientId}/{messageType} 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_ACK_TO_CLIENT = "/client/%s/ack"; // /client/{clientId}/ack (messageId in payload)
// Subscription patterns // Subscription patterns
private static final String PATTERN_FROM_CLIENT = "/server/+/%s"; // /server/+/{messageType} 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_ACK_FROM_CLIENT = "/server/+/ack"; // /server/+/ack
private Mqtt5AsyncClient mqttClient; private Mqtt5AsyncClient mqttClient;
private ConnectionStateListener connectionListener; private ConnectionStateListener connectionListener;
@@ -71,31 +71,22 @@ public class MqttMessagingPlugin implements MessagingPlugin {
int connectionTimeout = config.getInt(CONFIG_CONNECTION_TIMEOUT, 60); int connectionTimeout = config.getInt(CONFIG_CONNECTION_TIMEOUT, 60);
int keepAlive = config.getInt(CONFIG_KEEP_ALIVE, 60); int keepAlive = config.getInt(CONFIG_KEEP_ALIVE, 60);
log.info("[MqttPlugin] Connecting to {}:{} with clientId: {} (timeout: {}s, keepAlive: {}s)", log.info("[MqttPlugin] Connecting to {}:{} with clientId: {} (timeout: {}s, keepAlive: {}s)", brokerHost,
brokerHost, brokerPort, clientId, connectionTimeout, keepAlive); brokerPort, clientId, connectionTimeout, keepAlive);
// Build MQTT client // Build MQTT client
var clientBuilder = MqttClient.builder() var clientBuilder = MqttClient.builder().useMqttVersion5().identifier(clientId).serverHost(brokerHost)
.useMqttVersion5() .serverPort(brokerPort).automaticReconnect().initialDelay(1, java.util.concurrent.TimeUnit.SECONDS)
.identifier(clientId) .maxDelay(30, java.util.concurrent.TimeUnit.SECONDS).applyAutomaticReconnect();
.serverHost(brokerHost)
.serverPort(brokerPort)
.automaticReconnect()
.initialDelay(1, java.util.concurrent.TimeUnit.SECONDS)
.maxDelay(30, java.util.concurrent.TimeUnit.SECONDS)
.applyAutomaticReconnect();
mqttClient = clientBuilder.buildAsync(); mqttClient = clientBuilder.buildAsync();
// Build connect options // Build connect options
var connectBuilder = com.hivemq.client.mqtt.mqtt5.message.connect.Mqtt5Connect.builder() var connectBuilder = com.hivemq.client.mqtt.mqtt5.message.connect.Mqtt5Connect.builder()
.cleanStart(cleanStart) .cleanStart(cleanStart).keepAlive(keepAlive);
.keepAlive(keepAlive);
if (username != null && password != null) { if (username != null && password != null) {
connectBuilder.simpleAuth() connectBuilder.simpleAuth().username(username).password(password.getBytes(StandardCharsets.UTF_8))
.username(username)
.password(password.getBytes(StandardCharsets.UTF_8))
.applySimpleAuth(); .applySimpleAuth();
} }
@@ -108,17 +99,19 @@ public class MqttMessagingPlugin implements MessagingPlugin {
.orTimeout(connectionTimeout, java.util.concurrent.TimeUnit.SECONDS) .orTimeout(connectionTimeout, java.util.concurrent.TimeUnit.SECONDS)
.whenComplete((connAck, throwable) -> { .whenComplete((connAck, throwable) -> {
if (throwable != null) { if (throwable != null) {
String errorMsg = String.format("Connection to %s:%d failed: %s", String errorMsg = String.format("Connection to %s:%d failed: %s", brokerHost, brokerPort,
brokerHost, brokerPort, throwable.getMessage()); throwable.getMessage());
log.error("[MqttPlugin] {}", errorMsg, throwable); log.error("[MqttPlugin] {}", errorMsg, throwable);
// Check for specific error types // Check for specific error types
if (throwable instanceof java.util.concurrent.TimeoutException) { 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) { } else if (throwable.getCause() instanceof java.net.UnknownHostException) {
log.error("[MqttPlugin] Unknown host - DNS resolution failed for {}", brokerHost); log.error("[MqttPlugin] Unknown host - DNS resolution failed for {}", brokerHost);
} else if (throwable.getCause() instanceof java.net.ConnectException) { } 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; connected = false;
@@ -185,7 +178,8 @@ public class MqttMessagingPlugin implements MessagingPlugin {
} }
@Override @Override
public CompletableFuture<Void> sendToClient(String clientId, String messageType, byte[] payload, SendOptions options) throws PluginException { public CompletableFuture<Void> sendToClient(String clientId, String messageType, byte[] payload,
SendOptions options) throws PluginException {
if (!connected) { if (!connected) {
return CompletableFuture.failedFuture(new PluginException("MQTT client is not connected")); return CompletableFuture.failedFuture(new PluginException("MQTT client is not connected"));
} }
@@ -197,7 +191,8 @@ public class MqttMessagingPlugin implements MessagingPlugin {
} }
@Override @Override
public CompletableFuture<Void> sendAckToClient(String clientId, String messageId, byte[] payload, SendOptions options) throws PluginException { public CompletableFuture<Void> sendAckToClient(String clientId, String messageId, byte[] payload,
SendOptions options) throws PluginException {
if (!connected) { if (!connected) {
return CompletableFuture.failedFuture(new PluginException("MQTT client is not connected")); return CompletableFuture.failedFuture(new PluginException("MQTT client is not connected"));
} }
@@ -221,10 +216,7 @@ public class MqttMessagingPlugin implements MessagingPlugin {
String loginTopic = "/server/login"; String loginTopic = "/server/login";
log.info("[MqttPlugin] Registering handler for message type '{}' with topic: {}", messageType, loginTopic); log.info("[MqttPlugin] Registering handler for message type '{}' with topic: {}", messageType, loginTopic);
mqttClient.subscribeWith() mqttClient.subscribeWith().topicFilter(loginTopic).qos(MqttQos.EXACTLY_ONCE).send()
.topicFilter(loginTopic)
.qos(MqttQos.EXACTLY_ONCE)
.send()
.whenComplete((subAck, throwable) -> { .whenComplete((subAck, throwable) -> {
if (throwable != null) { if (throwable != null) {
log.error("[MqttPlugin] Subscription to {} failed: {}", loginTopic, throwable.getMessage()); log.error("[MqttPlugin] Subscription to {} failed: {}", loginTopic, throwable.getMessage());
@@ -236,15 +228,14 @@ public class MqttMessagingPlugin implements MessagingPlugin {
} else { } else {
// Standard pattern: /server/+/{messageType} // Standard pattern: /server/+/{messageType}
String topicPattern = String.format(PATTERN_FROM_CLIENT, 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() mqttClient.subscribeWith().topicFilter(topicPattern).qos(MqttQos.EXACTLY_ONCE).send()
.topicFilter(topicPattern)
.qos(MqttQos.EXACTLY_ONCE)
.send()
.whenComplete((subAck, throwable) -> { .whenComplete((subAck, throwable) -> {
if (throwable != null) { if (throwable != null) {
log.error("[MqttPlugin] Subscription to {} failed: {}", topicPattern, throwable.getMessage()); log.error("[MqttPlugin] Subscription to {} failed: {}", topicPattern,
throwable.getMessage());
messageHandlers.remove(messageType); messageHandlers.remove(messageType);
} else { } else {
log.info("[MqttPlugin] Successfully subscribed to: {}", topicPattern); log.info("[MqttPlugin] Successfully subscribed to: {}", topicPattern);
@@ -264,13 +255,11 @@ public class MqttMessagingPlugin implements MessagingPlugin {
this.ackHandler = handler; this.ackHandler = handler;
// Subscribe to ACK topic pattern // Subscribe to ACK topic pattern
mqttClient.subscribeWith() mqttClient.subscribeWith().topicFilter(PATTERN_ACK_FROM_CLIENT).qos(MqttQos.EXACTLY_ONCE).send()
.topicFilter(PATTERN_ACK_FROM_CLIENT)
.qos(MqttQos.EXACTLY_ONCE)
.send()
.whenComplete((subAck, throwable) -> { .whenComplete((subAck, throwable) -> {
if (throwable != null) { 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; this.ackHandler = null;
} else { } else {
log.info("[MqttPlugin] Successfully subscribed to: {}", PATTERN_ACK_FROM_CLIENT); log.info("[MqttPlugin] Successfully subscribed to: {}", PATTERN_ACK_FROM_CLIENT);
@@ -295,19 +284,14 @@ public class MqttMessagingPlugin implements MessagingPlugin {
@Override @Override
public PluginMetadata getMetadata() { public PluginMetadata getMetadata() {
return PluginMetadata.builder() return PluginMetadata.builder().name(PLUGIN_NAME).version(PLUGIN_VERSION)
.name(PLUGIN_NAME) .description("MQTT v5 messaging plugin using HiveMQ client").supportsWildcards(true)
.version(PLUGIN_VERSION) .supportsRetainedMessages(true).supportsQos(true).maxQosLevel(2).build();
.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() { private void setupGlobalMessageHandler() {
mqttClient.publishes(com.hivemq.client.mqtt.MqttGlobalPublishFilter.ALL, publish -> { 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 // Check if it's a client message
else if (topic.startsWith("/server/")) { else if (topic.startsWith("/server/")) {
handleClientMessage(topic, payload); handleClientMessage(topic, payload);
} } else {
else {
log.warn("[MqttPlugin] Received message on unexpected topic: {}", topic); log.warn("[MqttPlugin] Received message on unexpected topic: {}", topic);
} }
} catch (Exception e) { } catch (Exception e) {
@@ -343,12 +326,9 @@ public class MqttMessagingPlugin implements MessagingPlugin {
} }
} }
/** /**
* Handle ACK message from client. * Handle ACK message from client. Topic format: /server/{clientId}/ack
* Topic format: /server/{clientId}/ack (messageId in payload) * (messageId in payload)
*/ */
private void handleAckMessage(String topic, byte[] payload) { private void handleAckMessage(String topic, byte[] payload) {
if (ackHandler == null) { if (ackHandler == null) {
@@ -359,7 +339,7 @@ public class MqttMessagingPlugin implements MessagingPlugin {
// Extract clientId from topic: /server/{clientId}/ack // Extract clientId from topic: /server/{clientId}/ack
String[] parts = topic.split("/"); String[] parts = topic.split("/");
if (parts.length >= 4) { 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 // Extract messageId from payload
String payloadStr = new String(payload, StandardCharsets.UTF_8); String payloadStr = new String(payload, StandardCharsets.UTF_8);
@@ -377,9 +357,8 @@ public class MqttMessagingPlugin implements MessagingPlugin {
} }
/** /**
* Extract messageId from ACK payload. * Extract messageId from ACK payload. Expected payload format: JSON with
* Expected payload format: JSON with "messageId" field, e.g., {"messageId": "abc-123"} * "messageId" field, e.g., {"messageId": "abc-123"} or plain messageId string.
* or plain messageId string.
*/ */
private String extractMessageIdFromPayload(String payload) { private String extractMessageIdFromPayload(String payload) {
if (payload == null || payload.isBlank()) { if (payload == null || payload.isBlank()) {
@@ -418,9 +397,9 @@ public class MqttMessagingPlugin implements MessagingPlugin {
} }
/** /**
* Handle client message. * Handle client message. Topic format: /server/{clientId}/{messageType} or
* Topic format: /server/{clientId}/{messageType} or /server/{messageType} (for login) * /server/{messageType} (for login) messageType can contain slashes, e.g.,
* messageType can contain slashes, e.g., "jobs/assigned" * "jobs/assigned"
*/ */
private void handleClientMessage(String topic, byte[] payload) { private void handleClientMessage(String topic, byte[] payload) {
// Extract clientId and messageType from topic // Extract clientId and messageType from topic
@@ -463,17 +442,13 @@ public class MqttMessagingPlugin implements MessagingPlugin {
*/ */
private CompletableFuture<Void> sendToTopic(String topic, byte[] payload, SendOptions options) { private CompletableFuture<Void> sendToTopic(String topic, byte[] payload, SendOptions options) {
try { try {
var publishBuilder = Mqtt5Publish.builder() var publishBuilder = Mqtt5Publish.builder().topic(topic).payload(payload).qos(mapQos(options.getQos()))
.topic(topic)
.payload(payload)
.qos(mapQos(options.getQos()))
.retain(options.isRetained()); .retain(options.isRetained());
return mqttClient.publish(publishBuilder.build()) return mqttClient.publish(publishBuilder.build()).thenApply(publishResult -> {
.thenApply(publishResult -> { log.debug("[MqttPlugin] Message published to topic: {}", topic);
log.debug("[MqttPlugin] Message published to topic: {}", topic); return null;
return null; });
});
} catch (Exception e) { } catch (Exception e) {
log.error("[MqttPlugin] Failed to publish to topic {}: {}", topic, e.getMessage(), e); log.error("[MqttPlugin] Failed to publish to topic {}: {}", topic, e.getMessage(), e);
return CompletableFuture.failedFuture(new PluginException("Failed to publish message", 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) { private MqttQos mapQos(int qos) {
return switch (qos) { return switch (qos) {
case 0 -> MqttQos.AT_MOST_ONCE; case 0 -> MqttQos.AT_MOST_ONCE;
case 1 -> MqttQos.AT_LEAST_ONCE; case 1 -> MqttQos.AT_LEAST_ONCE;
case 2 -> MqttQos.EXACTLY_ONCE; case 2 -> MqttQos.EXACTLY_ONCE;
default -> MqttQos.AT_LEAST_ONCE; default -> MqttQos.AT_LEAST_ONCE;
}; };
} }
@@ -496,14 +471,11 @@ public class MqttMessagingPlugin implements MessagingPlugin {
* Notify connection state listener. * Notify connection state listener.
*/ */
private void notifyConnectionState(ConnectionState state, String message) { 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) { if (connectionListener != null) {
ConnectionStateEvent event = ConnectionStateEvent.builder() ConnectionStateEvent event = ConnectionStateEvent.builder().state(state).previousState(null)
.state(state) .errorMessage(message).pluginName(PLUGIN_NAME).build();
.previousState(null)
.errorMessage(message)
.pluginName(PLUGIN_NAME)
.build();
try { try {
log.debug("[MqttPlugin] Calling connectionListener.onConnectionStateChanged"); log.debug("[MqttPlugin] Calling connectionListener.onConnectionStateChanged");
connectionListener.onConnectionStateChanged(event); connectionListener.onConnectionStateChanged(event);

View File

@@ -50,7 +50,6 @@ public class AppUser {
@Field("geraet") @Field("geraet")
private String geraet; private String geraet;
@Field("owner") @Field("owner")
private ObjectId owner; private ObjectId owner;

View File

@@ -49,7 +49,8 @@ public class Message {
private LocalDateTime createdAt; 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") @Field("origin")
private MessageOrigin origin; private MessageOrigin origin;
@@ -94,8 +95,7 @@ public class Message {
/** /**
* Constructor for general messages with explicit content type * Constructor for general messages with explicit content type
*/ */
public Message(String content, String receiver, MessageOrigin origin, public Message(String content, String receiver, MessageOrigin origin, MessageContentType contentType) {
MessageContentType contentType) {
initializeBaseFields(content, receiver, origin, contentType); initializeBaseFields(content, receiver, origin, contentType);
this.messageType = MessageType.GENERAL; this.messageType = MessageType.GENERAL;
} }
@@ -103,16 +103,15 @@ public class Message {
/** /**
* Constructor for job-related messages * Constructor for job-related messages
*/ */
public Message(String content, String receiver, MessageOrigin origin, public Message(String content, String receiver, MessageOrigin origin, ObjectId jobId, String jobNumber) {
ObjectId jobId, String jobNumber) {
this(content, receiver, origin, MessageContentType.TEXT, jobId, jobNumber); this(content, receiver, origin, MessageContentType.TEXT, jobId, jobNumber);
} }
/** /**
* Constructor for job-related messages with explicit content type * Constructor for job-related messages with explicit content type
*/ */
public Message(String content, String receiver, MessageOrigin origin, public Message(String content, String receiver, MessageOrigin origin, MessageContentType contentType,
MessageContentType contentType, ObjectId jobId, String jobNumber) { ObjectId jobId, String jobNumber) {
initializeBaseFields(content, receiver, origin, contentType); initializeBaseFields(content, receiver, origin, contentType);
this.messageType = MessageType.JOB_RELATED; this.messageType = MessageType.JOB_RELATED;
this.jobId = jobId; this.jobId = jobId;

View File

@@ -4,6 +4,5 @@ package de.assecutor.votianlt.model;
* Supported content variants for chat messages. * Supported content variants for chat messages.
*/ */
public enum MessageContentType { public enum MessageContentType {
TEXT, TEXT, IMAGE
IMAGE
} }

View File

@@ -17,7 +17,8 @@ public class PriceTable {
public 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.monthlyBasePackage = monthlyBasePackage;
this.appUsageLicense = appUsageLicense; this.appUsageLicense = appUsageLicense;
this.revenueParticipation = revenueParticipation; this.revenueParticipation = revenueParticipation;

View File

@@ -13,7 +13,7 @@ public class CustomerInvoice {
private String id; private String id;
// Pflichtangaben nach §14 UStG (German VAT law) // Pflichtangaben nach §14 UStG (German VAT law)
private String invoiceNumber; // Fortlaufende Rechnungsnummer private String invoiceNumber; // Fortlaufende Rechnungsnummer
private LocalDate invoiceDate; // Rechnungsdatum private LocalDate invoiceDate; // Rechnungsdatum
private LocalDate deliveryDate; // Leistungsdatum private LocalDate deliveryDate; // Leistungsdatum
@@ -24,7 +24,7 @@ public class CustomerInvoice {
private String senderCity; private String senderCity;
private String senderCountry; private String senderCountry;
private String senderTaxNumber; // Steuernummer private String senderTaxNumber; // Steuernummer
private String senderVatId; // USt-IdNr. private String senderVatId; // USt-IdNr.
private String senderPhone; private String senderPhone;
private String senderEmail; private String senderEmail;
private String senderWebsite; private String senderWebsite;
@@ -43,20 +43,20 @@ public class CustomerInvoice {
private List<CustomerInvoiceItem> items; private List<CustomerInvoiceItem> items;
// Beträge // Beträge
private BigDecimal netAmount; // Nettobetrag private BigDecimal netAmount; // Nettobetrag
private BigDecimal vatRate; // Steuersatz (z.B. 19% = 0.19) private BigDecimal vatRate; // Steuersatz (z.B. 19% = 0.19)
private BigDecimal vatAmount; // Steuerbetrag private BigDecimal vatAmount; // Steuerbetrag
private BigDecimal totalAmount; // Bruttobetrag private BigDecimal totalAmount; // Bruttobetrag
// Zahlungsdetails // Zahlungsdetails
private String paymentTerms; // Zahlungsbedingungen private String paymentTerms; // Zahlungsbedingungen
private LocalDate paymentDueDate; // Fälligkeitsdatum private LocalDate paymentDueDate; // Fälligkeitsdatum
private String bankAccount; // Bankverbindung private String bankAccount; // Bankverbindung
private String iban; private String iban;
private String bic; private String bic;
// Zusätzliche rechtliche Angaben // Zusätzliche rechtliche Angaben
private String legalNotes; // Rechtliche Hinweise private String legalNotes; // Rechtliche Hinweise
private String reverseChargeNote; // Hinweis auf Reverse Charge (falls zutreffend) private String reverseChargeNote; // Hinweis auf Reverse Charge (falls zutreffend)
// Constructors // Constructors

View File

@@ -5,19 +5,20 @@ import java.math.BigDecimal;
public class CustomerInvoiceItem { public class CustomerInvoiceItem {
private BigDecimal quantity; private BigDecimal quantity;
private String unit; // Einheit (Stk., h, kg, etc.) private String unit; // Einheit (Stk., h, kg, etc.)
private String description; private String description;
private BigDecimal unitPrice; // Einzelpreis netto private BigDecimal unitPrice; // Einzelpreis netto
private BigDecimal netTotal; // Gesamtpreis netto private BigDecimal netTotal; // Gesamtpreis netto
private BigDecimal vatRate; // Steuersatz private BigDecimal vatRate; // Steuersatz
private BigDecimal vatAmount; // Steuerbetrag private BigDecimal vatAmount; // Steuerbetrag
private BigDecimal grossTotal; // Gesamtpreis brutto private BigDecimal grossTotal; // Gesamtpreis brutto
// Constructors // Constructors
public CustomerInvoiceItem() { 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.quantity = quantity;
this.unit = unit; this.unit = unit;
this.description = description; this.description = description;

View File

@@ -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 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<br>" + private String footerText = "Geschäftsführer: Carsten Annacker, Halstenbek · Gunnar Timm, Geesthacht<br>"
"Steuernummer: 22 294 53099 · USt-IdNr.: DE261094748 · Sitz: Geesthacht · Handelsregister: Lübeck HRB 8595<br>" + + "Steuernummer: 22 294 53099 · USt-IdNr.: DE261094748 · Sitz: Geesthacht · Handelsregister: Lübeck HRB 8595<br>"
"Bankverbindung: Hamburger Sparkasse · IBAN DE67200505501217139888 · BIC HASPDEHHXXX"; + "Bankverbindung: Hamburger Sparkasse · IBAN DE67200505501217139888 · BIC HASPDEHHXXX";
public SystemInvoiceData() { public SystemInvoiceData() {
} }
public String getCompanyName() { return companyName; } public String getCompanyName() {
public void setCompanyName(String companyName) { this.companyName = companyName; } return companyName;
}
public String getCompanySubtitle() { return companySubtitle; } public void setCompanyName(String companyName) {
public void setCompanySubtitle(String companySubtitle) { this.companySubtitle = companySubtitle; } this.companyName = companyName;
}
public String getCompanyStreet() { return companyStreet; } public String getCompanySubtitle() {
public void setCompanyStreet(String companyStreet) { this.companyStreet = companyStreet; } return companySubtitle;
}
public String getCompanyCity() { return companyCity; } public void setCompanySubtitle(String companySubtitle) {
public void setCompanyCity(String companyCity) { this.companyCity = companyCity; } this.companySubtitle = companySubtitle;
}
public String getCompanyPhone() { return companyPhone; } public String getCompanyStreet() {
public void setCompanyPhone(String companyPhone) { this.companyPhone = companyPhone; } return companyStreet;
}
public String getCompanyFax() { return companyFax; } public void setCompanyStreet(String companyStreet) {
public void setCompanyFax(String companyFax) { this.companyFax = companyFax; } this.companyStreet = companyStreet;
}
public String getCompanyEmail() { return companyEmail; } public String getCompanyCity() {
public void setCompanyEmail(String companyEmail) { this.companyEmail = companyEmail; } return companyCity;
}
public String getCompanyWebsite() { return companyWebsite; } public void setCompanyCity(String companyCity) {
public void setCompanyWebsite(String companyWebsite) { this.companyWebsite = companyWebsite; } this.companyCity = companyCity;
}
public String getInvoiceNumber() { return invoiceNumber; } public String getCompanyPhone() {
public void setInvoiceNumber(String invoiceNumber) { this.invoiceNumber = invoiceNumber; } return companyPhone;
}
public String getInvoiceDate() { return invoiceDate; } public void setCompanyPhone(String companyPhone) {
public void setInvoiceDate(String invoiceDate) { this.invoiceDate = invoiceDate; } this.companyPhone = companyPhone;
}
public String getInvoiceText() { return invoiceText; } public String getCompanyFax() {
public void setInvoiceText(String invoiceText) { this.invoiceText = invoiceText; } return companyFax;
}
public String getSenderLine() { return senderLine; } public void setCompanyFax(String companyFax) {
public void setSenderLine(String senderLine) { this.senderLine = senderLine; } this.companyFax = companyFax;
}
public String getRecipientName() { return recipientName; } public String getCompanyEmail() {
public void setRecipientName(String recipientName) { this.recipientName = recipientName; } return companyEmail;
}
public String getRecipientDepartment() { return recipientDepartment; } public void setCompanyEmail(String companyEmail) {
public void setRecipientDepartment(String recipientDepartment) { this.recipientDepartment = recipientDepartment; } this.companyEmail = companyEmail;
}
public String getRecipientStreet() { return recipientStreet; } public String getCompanyWebsite() {
public void setRecipientStreet(String recipientStreet) { this.recipientStreet = recipientStreet; } return companyWebsite;
}
public String getRecipientCity() { return recipientCity; } public void setCompanyWebsite(String companyWebsite) {
public void setRecipientCity(String recipientCity) { this.recipientCity = recipientCity; } this.companyWebsite = companyWebsite;
}
public List<SystemInvoiceItem> getInvoiceItems() { return systemInvoiceItems; } public String getInvoiceNumber() {
public void setInvoiceItems(List<SystemInvoiceItem> systemInvoiceItems) { this.systemInvoiceItems = systemInvoiceItems; } return invoiceNumber;
}
public String getNetAmount() { return netAmount; } public void setInvoiceNumber(String invoiceNumber) {
public void setNetAmount(String netAmount) { this.netAmount = netAmount; } this.invoiceNumber = invoiceNumber;
}
public String getVatRate() { return vatRate; } public String getInvoiceDate() {
public void setVatRate(String vatRate) { this.vatRate = vatRate; } return invoiceDate;
}
public String getVatAmount() { return vatAmount; } public void setInvoiceDate(String invoiceDate) {
public void setVatAmount(String vatAmount) { this.vatAmount = vatAmount; } this.invoiceDate = invoiceDate;
}
public String getTotalAmount() { return totalAmount; } public String getInvoiceText() {
public void setTotalAmount(String totalAmount) { this.totalAmount = totalAmount; } return invoiceText;
}
public String getPaymentTerms() { return paymentTerms; } public void setInvoiceText(String invoiceText) {
public void setPaymentTerms(String paymentTerms) { this.paymentTerms = paymentTerms; } this.invoiceText = invoiceText;
}
public String getFooterText() { return footerText; } public String getSenderLine() {
public void setFooterText(String footerText) { this.footerText = footerText; } 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<SystemInvoiceItem> getInvoiceItems() {
return systemInvoiceItems;
}
public void setInvoiceItems(List<SystemInvoiceItem> 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;
}
} }

View File

@@ -1,7 +1,12 @@
package de.assecutor.votianlt.model.task; package de.assecutor.votianlt.model.task;
public enum TaskType { 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; private final String displayName;

View File

@@ -54,33 +54,28 @@ class MqttPublisherImpl implements MqttPublisher {
String messageType = parts[3]; String messageType = parts[3];
// Use MessageDeliveryService for reliable delivery // Use MessageDeliveryService for reliable delivery
DeliveryOptions options = DeliveryOptions.builder() DeliveryOptions options = DeliveryOptions.builder().requiresAck(true).retained(retained).build();
.requiresAck(true)
.retained(retained)
.build();
deliveryService.sendToClient(clientId, messageType, payload, options) deliveryService.sendToClient(clientId, messageType, payload, options).thenAccept(receipt -> {
.thenAccept(receipt -> { log.info("=== MESSAGE DELIVERY SUBMITTED ===");
log.info("=== MESSAGE DELIVERY SUBMITTED ==="); log.info("Topic: {}", topic);
log.info("Topic: {}", topic); log.info("Message ID: {}", receipt.getMessageId());
log.info("Message ID: {}", receipt.getMessageId()); log.info("Status: {}", receipt.getStatus());
log.info("Status: {}", receipt.getStatus()); log.info("Retained: {}", retained);
log.info("Retained: {}", retained);
// Log payload for debugging // Log payload for debugging
try { try {
String json = (payload instanceof String s) ? s : objectMapper.writeValueAsString(payload); String json = (payload instanceof String s) ? s : objectMapper.writeValueAsString(payload);
log.info("Payload: {}", json); log.info("Payload: {}", json);
} catch (Exception e) { } catch (Exception e) {
log.debug("Could not serialize payload for logging: {}", e.getMessage()); log.debug("Could not serialize payload for logging: {}", e.getMessage());
} }
log.info("=== END MESSAGE DELIVERY ==="); log.info("=== END MESSAGE DELIVERY ===");
}) }).exceptionally(ex -> {
.exceptionally(ex -> { log.error("Failed to submit message for delivery to topic {}: {}", topic, ex.getMessage(), ex);
log.error("Failed to submit message for delivery to topic {}: {}", topic, ex.getMessage(), ex); return null;
return null; });
});
} catch (Exception e) { } catch (Exception e) {
log.error("Failed to publish message for topic {}: {}", topic, e.getMessage(), e); log.error("Failed to publish message for topic {}: {}", topic, e.getMessage(), e);

View File

@@ -79,16 +79,19 @@ public final class AdminLayout extends AppLayout {
SideNavItem dashboard = new SideNavItem("Dashboard", "admin-dashboard", new Icon(VaadinIcon.DASHBOARD)); 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 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 priceTable = new SideNavItem("Preis-Tabelle", "admin-price-table", new Icon(VaadinIcon.COG));
//SideNavItem systemSettings = new SideNavItem("Systemeinstellungen", "admin-settings", new Icon(VaadinIcon.COG)); // SideNavItem systemSettings = new SideNavItem("Systemeinstellungen",
//SideNavItem userManagement = new SideNavItem("Benutzerverwaltung", "admin-users", new Icon(VaadinIcon.USERS)); // "admin-settings", new Icon(VaadinIcon.COG));
//SideNavItem systemLogs = new SideNavItem("System-Logs", "admin-logs", new Icon(VaadinIcon.FILE_TEXT)); // 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(dashboard);
nav.addItem(pdfTest); nav.addItem(pdfTest);
nav.addItem(priceTable); nav.addItem(priceTable);
//nav.addItem(systemSettings); // nav.addItem(systemSettings);
//nav.addItem(userManagement); // nav.addItem(userManagement);
//nav.addItem(systemLogs); // nav.addItem(systemLogs);
// Create a vertical layout to hold menu items // Create a vertical layout to hold menu items
VerticalLayout navContainer = new VerticalLayout(); VerticalLayout navContainer = new VerticalLayout();

View File

@@ -10,10 +10,12 @@ import de.assecutor.votianlt.security.SecurityService;
import de.assecutor.votianlt.repository.CargoItemRepository; import de.assecutor.votianlt.repository.CargoItemRepository;
import de.assecutor.votianlt.service.JobHistoryService; import de.assecutor.votianlt.service.JobHistoryService;
import de.assecutor.votianlt.service.EmailService; import de.assecutor.votianlt.service.EmailService;
import de.assecutor.votianlt.event.JobCreatedEvent;
import java.util.Objects; import java.util.Objects;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.bson.types.ObjectId; import org.bson.types.ObjectId;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.time.LocalDateTime; import java.time.LocalDateTime;
@@ -32,6 +34,7 @@ public class AddJobService {
private final SecurityService securityService; private final SecurityService securityService;
private final JobHistoryService jobHistoryService; private final JobHistoryService jobHistoryService;
private final EmailService emailService; private final EmailService emailService;
private final ApplicationEventPublisher eventPublisher;
/** /**
* Speichert einen neuen Auftrag samt CargoItems und Tasks * Speichert einen neuen Auftrag samt CargoItems und Tasks
@@ -118,6 +121,14 @@ public class AddJobService {
e.getMessage()); 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()); log.info("Auftrag erfolgreich gespeichert: {}", savedJob.getJobNumber());
return savedJob; return savedJob;

View File

@@ -26,8 +26,7 @@ public class UserInvoiceDataService {
} }
public UserInvoiceData createOrUpdate(ObjectId userId, boolean billingEnabled, String prefix, String ustId, public UserInvoiceData createOrUpdate(ObjectId userId, boolean billingEnabled, String prefix, String ustId,
String taxNumber, String bankName, String iban, String taxRate, String taxNumber, String bankName, String iban, String taxRate, String introText, String paymentTerms) {
String introText, String paymentTerms) {
// If billing is disabled, delete any existing record and return null // If billing is disabled, delete any existing record and return null
if (!billingEnabled) { if (!billingEnabled) {
deleteByUserId(userId); deleteByUserId(userId);

View File

@@ -102,7 +102,6 @@ public class AddAppUserView extends VerticalLayout {
phoneField.setRequiredIndicatorVisible(true); phoneField.setRequiredIndicatorVisible(true);
phoneField.addBlurListener(e -> validateField(phoneField, "Telefonnummer ist ein Pflichtfeld")); phoneField.addBlurListener(e -> validateField(phoneField, "Telefonnummer ist ein Pflichtfeld"));
emailField.setWidthFull(); emailField.setWidthFull();
emailField.setRequiredIndicatorVisible(true); emailField.setRequiredIndicatorVisible(true);
emailField.addBlurListener(e -> validateEmailField()); emailField.addBlurListener(e -> validateEmailField());
@@ -122,7 +121,6 @@ public class AddAppUserView extends VerticalLayout {
confirmPasswordField.setRequiredIndicatorVisible(true); confirmPasswordField.setRequiredIndicatorVisible(true);
confirmPasswordField.addBlurListener(e -> validateConfirmPasswordField()); confirmPasswordField.addBlurListener(e -> validateConfirmPasswordField());
// Add fields to form // Add fields to form
formLayout.add(designationField); formLayout.add(designationField);
formLayout.add(nameLayout); formLayout.add(nameLayout);
@@ -200,10 +198,12 @@ public class AddAppUserView extends VerticalLayout {
emailField.setInvalid(true); emailField.setInvalid(true);
emailField.setErrorMessage("E-Mail-Adresse bereits vorhanden"); emailField.setErrorMessage("E-Mail-Adresse bereits vorhanden");
} else { } 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) { } 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() { private boolean validateAllFields() {
validateField(designationField, "Kennung ist ein Pflichtfeld"); validateField(designationField, "Kennung ist ein Pflichtfeld");
validateField(firstnameField, "Vorname ist ein Pflichtfeld"); validateField(firstnameField, "Vorname ist ein Pflichtfeld");
@@ -280,7 +279,7 @@ public class AddAppUserView extends VerticalLayout {
validateConfirmPasswordField(); validateConfirmPasswordField();
return !designationField.isInvalid() && !firstnameField.isInvalid() && !lastnameField.isInvalid() return !designationField.isInvalid() && !firstnameField.isInvalid() && !lastnameField.isInvalid()
&& !phoneField.isInvalid() && !emailField.isInvalid() && !phoneField.isInvalid() && !emailField.isInvalid() && !passwordField.isInvalid()
&& !passwordField.isInvalid() && !confirmPasswordField.isInvalid(); && !confirmPasswordField.isInvalid();
} }
} }

View File

@@ -264,8 +264,8 @@ public class AddCustomerView extends Main {
validateField(city); validateField(city);
validateEmail(); validateEmail();
return !companyName.isInvalid() && !firstName.isInvalid() && !lastName.isInvalid() return !companyName.isInvalid() && !firstName.isInvalid() && !lastName.isInvalid() && !telephone.isInvalid()
&& !telephone.isInvalid() && !mail.isInvalid() && !street.isInvalid() && !mail.isInvalid() && !street.isInvalid() && !houseNumber.isInvalid() && !zip.isInvalid()
&& !houseNumber.isInvalid() && !zip.isInvalid() && !city.isInvalid(); && !city.isInvalid();
} }
} }

View File

@@ -132,6 +132,7 @@ public class AddJobView extends Main {
private Span cargoError; private Span cargoError;
private VerticalLayout cargoList; private VerticalLayout cargoList;
private VerticalLayout tasksList; private VerticalLayout tasksList;
private ComboBox<TaskTemplate> templateComboBox;
private TextArea remarkArea; private TextArea remarkArea;
private VerticalLayout pickupSection; private VerticalLayout pickupSection;
private VerticalLayout deliverySection; private VerticalLayout deliverySection;
@@ -145,8 +146,8 @@ public class AddJobView extends Main {
private List<AppUser> availableAppUsers; private List<AppUser> availableAppUsers;
public AddJobView(AddJobService addJobService, AddCustomerService addCustomerService, public AddJobView(AddJobService addJobService, AddCustomerService addCustomerService,
CustomerService customerService, AppUserService appUserService, CustomerService customerService, AppUserService appUserService, TaskTemplateService taskTemplateService,
TaskTemplateService taskTemplateService, SecurityService securityService) { SecurityService securityService) {
this.addJobService = addJobService; this.addJobService = addJobService;
this.addCustomerService = addCustomerService; this.addCustomerService = addCustomerService;
this.customerService = customerService; this.customerService = customerService;
@@ -367,28 +368,22 @@ public class AddJobView extends Main {
pickupDate = new DatePicker("Datum"); pickupDate = new DatePicker("Datum");
pickupDate.setRequiredIndicatorVisible(true); pickupDate.setRequiredIndicatorVisible(true);
pickupDate.setLocale(java.util.Locale.GERMANY); // Monday as first day of week pickupDate.setLocale(java.util.Locale.GERMANY); // Monday as first day of week
pickupDate.setI18n(new DatePicker.DatePickerI18n() pickupDate.setI18n(new DatePicker.DatePickerI18n().setFirstDayOfWeek(1) // 1 = Monday
.setFirstDayOfWeek(1) // 1 = Monday .setMonthNames(java.util.Arrays.asList("Januar", "Februar", "März", "April", "Mai", "Juni", "Juli",
.setMonthNames(java.util.Arrays.asList( "August", "September", "Oktober", "November", "Dezember"))
"Januar", "Februar", "März", "April", "Mai", "Juni", .setWeekdays(java.util.Arrays.asList("Sonntag", "Montag", "Dienstag", "Mittwoch", "Donnerstag",
"Juli", "August", "September", "Oktober", "November", "Dezember")) "Freitag", "Samstag"))
.setWeekdays(java.util.Arrays.asList( .setWeekdaysShort(java.util.Arrays.asList("So", "Mo", "Di", "Mi", "Do", "Fr", "Sa")));
"Sonntag", "Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag"))
.setWeekdaysShort(java.util.Arrays.asList(
"So", "Mo", "Di", "Mi", "Do", "Fr", "Sa")));
deliveryDate = new DatePicker("Datum"); deliveryDate = new DatePicker("Datum");
deliveryDate.setRequiredIndicatorVisible(true); deliveryDate.setRequiredIndicatorVisible(true);
deliveryDate.setLocale(java.util.Locale.GERMANY); // Monday as first day of week deliveryDate.setLocale(java.util.Locale.GERMANY); // Monday as first day of week
deliveryDate.setI18n(new DatePicker.DatePickerI18n() deliveryDate.setI18n(new DatePicker.DatePickerI18n().setFirstDayOfWeek(1) // 1 = Monday
.setFirstDayOfWeek(1) // 1 = Monday .setMonthNames(java.util.Arrays.asList("Januar", "Februar", "März", "April", "Mai", "Juni", "Juli",
.setMonthNames(java.util.Arrays.asList( "August", "September", "Oktober", "November", "Dezember"))
"Januar", "Februar", "März", "April", "Mai", "Juni", .setWeekdays(java.util.Arrays.asList("Sonntag", "Montag", "Dienstag", "Mittwoch", "Donnerstag",
"Juli", "August", "September", "Oktober", "November", "Dezember")) "Freitag", "Samstag"))
.setWeekdays(java.util.Arrays.asList( .setWeekdaysShort(java.util.Arrays.asList("So", "Mo", "Di", "Mi", "Do", "Fr", "Sa")));
"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 // Submit button - initially disabled until all required fields are valid
submitButton = new Button("Auftrag anlegen", event -> submit()); submitButton = new Button("Auftrag anlegen", event -> submit());
@@ -423,6 +418,15 @@ public class AddJobView extends Main {
// Tab 4: Tasks // Tab 4: Tasks
tasksTab = tabSheet.add("Aufgaben", createTasksTab()); 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 // Tab 5: Price & Submit
priceTab = tabSheet.add("Preis & Abschluss", createPriceAndSubmitTab()); priceTab = tabSheet.add("Preis & Abschluss", createPriceAndSubmitTab());
@@ -853,8 +857,7 @@ public class AddJobView extends Main {
binder.bind(deliveryPhone, Job::getDeliveryPhone, Job::setDeliveryPhone); binder.bind(deliveryPhone, Job::getDeliveryPhone, Job::setDeliveryPhone);
binder.bind(deliveryAddressAddition, Job::getDeliveryAddressAddition, Job::setDeliveryAddressAddition); binder.bind(deliveryAddressAddition, Job::getDeliveryAddressAddition, Job::setDeliveryAddressAddition);
binder.forField(digitalProcessing).bind( binder.forField(digitalProcessing).bind(Job::isDigitalProcessing,
Job::isDigitalProcessing,
(job, value) -> job.setDigitalProcessing(Boolean.TRUE.equals(value))); (job, value) -> job.setDigitalProcessing(Boolean.TRUE.equals(value)));
// Bind appUser with converter: AppUser object <-> String ID // 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") }, "Bitte App-Nutzer auswählen, wenn Digitale Abwicklung aktiv ist")
.bind(Job::getAppUser, Job::setAppUser); .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 -> { digitalProcessing.addValueChangeListener(e -> {
boolean required = Boolean.TRUE.equals(e.getValue()); boolean required = Boolean.TRUE.equals(e.getValue());
appUser.setRequiredIndicatorVisible(required); 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(); triggerValidation();
updateTabLabels(); updateTabLabels();
}); });
@@ -888,8 +912,10 @@ public class AddJobView extends Main {
triggerValidation(); triggerValidation();
updateTabLabels(); updateTabLabels();
}); });
// Initialize required indicator state // Initialize required indicator and visibility state
appUser.setRequiredIndicatorVisible(Boolean.TRUE.equals(digitalProcessing.getValue())); boolean digitalInitial = Boolean.TRUE.equals(digitalProcessing.getValue());
appUser.setRequiredIndicatorVisible(digitalInitial);
appUser.setVisible(digitalInitial);
// Set up validation triggers and visual styling // Set up validation triggers and visual styling
setupValidationTriggers(); setupValidationTriggers();
@@ -956,11 +982,8 @@ public class AddJobView extends Main {
// Update submit button state based on all validation checks // Update submit button state based on all validation checks
if (submitButton != null) { if (submitButton != null) {
boolean hasErrors = hasAddressValidationErrors() boolean hasErrors = hasAddressValidationErrors() || hasAppointmentValidationErrors()
|| hasAppointmentValidationErrors() || hasCargoValidationErrors() || hasPriceValidationErrors() || hasTasksValidationErrors();
|| hasCargoValidationErrors()
|| hasPriceValidationErrors()
|| hasTasksValidationErrors();
submitButton.setEnabled(!hasErrors); submitButton.setEnabled(!hasErrors);
} }
} }
@@ -1073,8 +1096,7 @@ public class AddJobView extends Main {
return true; return true;
} }
// Check if at least one todo item is non-empty // Check if at least one todo item is non-empty
boolean hasValidTodoItem = todoItems.stream() boolean hasValidTodoItem = todoItems.stream().anyMatch(item -> item != null && !item.trim().isEmpty());
.anyMatch(item -> item != null && !item.trim().isEmpty());
if (!hasValidTodoItem) { if (!hasValidTodoItem) {
return true; return true;
} }
@@ -1164,9 +1186,10 @@ public class AddJobView extends Main {
addCustomerService.addCustomer(deliveryCustomer); addCustomerService.addCustomer(deliveryCustomer);
} }
// All validations passed, save the job with cargo items and tasks (tasks may be // All validations passed, save the job with cargo items and tasks
// empty) // If digital processing is disabled, don't save any tasks
Job savedJob = addJobService.addJobWithCargo(job, cargoFilled, tasksState); List<BaseTask> tasksToSave = job.isDigitalProcessing() ? tasksState : List.of();
Job savedJob = addJobService.addJobWithCargo(job, cargoFilled, tasksToSave);
// Erfolgsmeldung und Navigation zur Zusammenfassung // Erfolgsmeldung und Navigation zur Zusammenfassung
Notification successNotification = Notification Notification successNotification = Notification
@@ -1426,7 +1449,7 @@ public class AddJobView extends Main {
tasksTitle.getStyle().set("margin", "0"); tasksTitle.getStyle().set("margin", "0");
tasksTitle.getStyle().set("white-space", "nowrap"); tasksTitle.getStyle().set("white-space", "nowrap");
ComboBox<TaskTemplate> templateComboBox = new ComboBox<>(); templateComboBox = new ComboBox<>();
templateComboBox.setPlaceholder("Template auswählen..."); templateComboBox.setPlaceholder("Template auswählen...");
templateComboBox.setItemLabelGenerator(TaskTemplate::getTemplateName); templateComboBox.setItemLabelGenerator(TaskTemplate::getTemplateName);
templateComboBox.setClearButtonVisible(true); templateComboBox.setClearButtonVisible(true);
@@ -1688,24 +1711,31 @@ public class AddJobView extends Main {
BaseTask newTask = createTaskByType(selectedType); BaseTask newTask = createTaskByType(selectedType);
BaseTask oldTask = currentTask[0]; BaseTask oldTask = currentTask[0];
newTask.setDescription(oldTask.getDescription());
newTask.setCompleted(oldTask.isCompleted()); newTask.setCompleted(oldTask.isCompleted());
newTask.setCompletedAt(oldTask.getCompletedAt()); newTask.setCompletedAt(oldTask.getCompletedAt());
newTask.setCompletedBy(oldTask.getCompletedBy()); newTask.setCompletedBy(oldTask.getCompletedBy());
// Preserve task-specific properties // Preserve task-specific properties
switch (oldTask) { switch (oldTask) {
case ConfirmationTask oldConfirmationTask when newTask instanceof ConfirmationTask newConfirmationTask -> newConfirmationTask.setButtonText(oldConfirmationTask.getButtonText()); case ConfirmationTask oldConfirmationTask when newTask instanceof ConfirmationTask newConfirmationTask ->
case TodoListTask oldTodoTask when newTask instanceof TodoListTask newTodoTask -> newTodoTask.setTodoItems(oldTodoTask.getTodoItems()); newConfirmationTask.setButtonText(oldConfirmationTask.getButtonText());
case PhotoTask oldPhotoTask when newTask instanceof PhotoTask newPhotoTask -> { case TodoListTask oldTodoTask when newTask instanceof TodoListTask newTodoTask ->
newPhotoTask.setMinPhotoCount(oldPhotoTask.getMinPhotoCount()); newTodoTask.setTodoItems(oldTodoTask.getTodoItems());
newPhotoTask.setMaxPhotoCount(oldPhotoTask.getMaxPhotoCount()); case PhotoTask oldPhotoTask when newTask instanceof PhotoTask newPhotoTask -> {
} newPhotoTask.setMinPhotoCount(oldPhotoTask.getMinPhotoCount());
case BarcodeTask oldBarcodeTask when newTask instanceof BarcodeTask newBarcodeTask -> { newPhotoTask.setMaxPhotoCount(oldPhotoTask.getMaxPhotoCount());
newBarcodeTask.setMinBarcodeCount(oldBarcodeTask.getMinBarcodeCount()); }
newBarcodeTask.setMaxBarcodeCount(oldBarcodeTask.getMaxBarcodeCount()); case BarcodeTask oldBarcodeTask when newTask instanceof BarcodeTask newBarcodeTask -> {
} newBarcodeTask.setMinBarcodeCount(oldBarcodeTask.getMinBarcodeCount());
default -> { 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 // Replace in state and preserve order
@@ -1974,6 +2004,27 @@ public class AddJobView extends Main {
configContainer.add(barcodeLayout); configContainer.add(barcodeLayout);
break; 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: default:
throw new IllegalArgumentException("Unbekannter TaskType: " + taskType); throw new IllegalArgumentException("Unbekannter TaskType: " + taskType);
} }
@@ -2028,7 +2079,8 @@ public class AddJobView extends Main {
saveButton.addClickListener(e -> { saveButton.addClickListener(e -> {
String templateName = templateNameField.getValue(); String templateName = templateNameField.getValue();
if (templateName == null || templateName.trim().isEmpty()) { 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; return;
} }
@@ -2042,20 +2094,20 @@ public class AddJobView extends Main {
} }
// Save template with task type information and specific data // Save template with task type information and specific data
taskTemplateService.createTemplate( taskTemplateService.createTemplate(securityService.getCurrentDatabaseUser().getId(),
securityService.getCurrentDatabaseUser().getId(), templateName.trim(), tasksCopy);
templateName.trim(),
tasksCopy
);
dialog.close(); 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) { } catch (RuntimeException ex) {
Notification.show(ex.getMessage(), 4000, Notification.Position.MIDDLE); Notification.show(ex.getMessage(), 4000, Notification.Position.MIDDLE);
} catch (Exception ex) { } catch (Exception ex) {
log.error("Error saving task template", 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 * Creates a deep copy of a task to avoid reference issues in templates Saves
* Saves all task-specific data including type and specific properties * all task-specific data including type and specific properties
*/ */
private BaseTask createTaskCopy(BaseTask original) { private BaseTask createTaskCopy(BaseTask original) {
BaseTask copy = null; BaseTask copy = null;
@@ -2102,28 +2154,23 @@ public class AddJobView extends Main {
} else if (original instanceof PhotoTask) { } else if (original instanceof PhotoTask) {
PhotoTask origTask = (PhotoTask) original; PhotoTask origTask = (PhotoTask) original;
// Copy with all photo-specific parameters // Copy with all photo-specific parameters
copy = new PhotoTask( copy = new PhotoTask(origTask.getMinPhotoCount() != null ? origTask.getMinPhotoCount() : 1,
origTask.getMinPhotoCount() != null ? origTask.getMinPhotoCount() : 1, origTask.getMaxPhotoCount() != null ? origTask.getMaxPhotoCount() : 10);
origTask.getMaxPhotoCount() != null ? origTask.getMaxPhotoCount() : 10
);
} else if (original instanceof BarcodeTask) { } else if (original instanceof BarcodeTask) {
BarcodeTask origTask = (BarcodeTask) original; BarcodeTask origTask = (BarcodeTask) original;
// Copy with all barcode-specific parameters // Copy with all barcode-specific parameters
copy = new BarcodeTask( copy = new BarcodeTask(origTask.getMinBarcodeCount() != null ? origTask.getMinBarcodeCount() : 1,
origTask.getMinBarcodeCount() != null ? origTask.getMinBarcodeCount() : 1, origTask.getMaxBarcodeCount() != null ? origTask.getMaxBarcodeCount() : 10);
origTask.getMaxBarcodeCount() != null ? origTask.getMaxBarcodeCount() : 10
);
} else if (original instanceof CommentTask) { } else if (original instanceof CommentTask) {
CommentTask origTask = (CommentTask) original; CommentTask origTask = (CommentTask) original;
// Copy with all comment-specific parameters // Copy with all comment-specific parameters
copy = new CommentTask( copy = new CommentTask(origTask.getCommentText() != null ? origTask.getCommentText() : "",
origTask.getCommentText() != null ? origTask.getCommentText() : "", origTask.isRequired());
origTask.isRequired()
);
} }
if (copy != null) { if (copy != null) {
// Copy all base task properties // Copy all base task properties
copy.setDescription(original.getDescription());
copy.setTaskOrder(original.getTaskOrder() != null ? original.getTaskOrder() : 0); copy.setTaskOrder(original.getTaskOrder() != null ? original.getTaskOrder() : 0);
copy.setCompleted(original.isCompleted()); copy.setCompleted(original.isCompleted());
copy.setCompletedAt(original.getCompletedAt()); copy.setCompletedAt(original.getCompletedAt());
@@ -2138,9 +2185,8 @@ public class AddJobView extends Main {
*/ */
private void loadTemplatesIntoComboBox(ComboBox<TaskTemplate> templateComboBox) { private void loadTemplatesIntoComboBox(ComboBox<TaskTemplate> templateComboBox) {
try { try {
List<TaskTemplate> templates = taskTemplateService.findByUserId( List<TaskTemplate> templates = taskTemplateService
securityService.getCurrentDatabaseUser().getId() .findByUserId(securityService.getCurrentDatabaseUser().getId());
);
templateComboBox.setItems(templates); templateComboBox.setItems(templates);
} catch (Exception e) { } catch (Exception e) {
log.error("Error loading templates", e); log.error("Error loading templates", e);
@@ -2154,8 +2200,8 @@ public class AddJobView extends Main {
private void loadTasksFromTemplate(TaskTemplate template, ComboBox<TaskTemplate> templateComboBox) { private void loadTasksFromTemplate(TaskTemplate template, ComboBox<TaskTemplate> templateComboBox) {
ConfirmDialog confirmDialog = new ConfirmDialog(); ConfirmDialog confirmDialog = new ConfirmDialog();
confirmDialog.setHeader("Template laden"); confirmDialog.setHeader("Template laden");
confirmDialog.setText("Möchten Sie wirklich das Template '" + template.getTemplateName() + confirmDialog.setText("Möchten Sie wirklich das Template '" + template.getTemplateName()
"' laden? Alle aktuellen Aufgaben werden ersetzt."); + "' laden? Alle aktuellen Aufgaben werden ersetzt.");
confirmDialog.setCancelable(true); confirmDialog.setCancelable(true);
confirmDialog.setCancelText("Abbrechen"); confirmDialog.setCancelText("Abbrechen");
confirmDialog.setConfirmText("Laden"); confirmDialog.setConfirmText("Laden");
@@ -2181,13 +2227,17 @@ public class AddJobView extends Main {
// Clear the combobox selection // Clear the combobox selection
templateComboBox.clear(); templateComboBox.clear();
Notification.show("Template '" + template.getTemplateName() + "' erfolgreich geladen", // Re-validate to enable submit button if all fields are valid
3000, Notification.Position.BOTTOM_END); triggerValidation();
updateTabLabels();
Notification.show("Template '" + template.getTemplateName() + "' erfolgreich geladen", 3000,
Notification.Position.BOTTOM_END);
} catch (Exception ex) { } catch (Exception ex) {
log.error("Error loading template tasks", ex); log.error("Error loading template tasks", ex);
Notification.show("Fehler beim Laden des Templates: " + ex.getMessage(), Notification.show("Fehler beim Laden des Templates: " + ex.getMessage(), 4000,
4000, Notification.Position.MIDDLE); Notification.Position.MIDDLE);
} }
}); });
@@ -2200,11 +2250,12 @@ public class AddJobView extends Main {
} }
/** /**
* Creates a task row from an existing task (used when loading templates) * Creates a task row from an existing task (used when loading templates) This
* This creates a UI row and populates it with the task's specific data * creates a UI row and populates it with the task's specific data
*/ */
private void createTaskRowFromTask(BaseTask task) { 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 // Instead, create the UI components and set them up with the loaded task
VerticalLayout taskContainer = new VerticalLayout(); VerticalLayout taskContainer = new VerticalLayout();
@@ -2251,36 +2302,46 @@ public class AddJobView extends Main {
final BaseTask[] currentTask = { task }; 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 -> { taskTypeCombo.addValueChangeListener(ev -> {
TaskType selectedType = ev.getValue(); TaskType selectedType = ev.getValue();
if (selectedType != null) { if (selectedType != null) {
BaseTask newTask = createTaskByType(selectedType); BaseTask newTask = createTaskByType(selectedType);
BaseTask oldTask = currentTask[0]; BaseTask oldTask = currentTask[0];
newTask.setDescription(oldTask.getDescription());
newTask.setCompleted(oldTask.isCompleted()); newTask.setCompleted(oldTask.isCompleted());
newTask.setCompletedAt(oldTask.getCompletedAt()); newTask.setCompletedAt(oldTask.getCompletedAt());
newTask.setCompletedBy(oldTask.getCompletedBy()); newTask.setCompletedBy(oldTask.getCompletedBy());
// Preserve task-specific properties // Preserve task-specific properties
switch (oldTask) { switch (oldTask) {
case ConfirmationTask oldConfirmationTask when newTask instanceof ConfirmationTask newConfirmationTask -> case ConfirmationTask oldConfirmationTask when newTask instanceof ConfirmationTask newConfirmationTask ->
newConfirmationTask.setButtonText(oldConfirmationTask.getButtonText()); newConfirmationTask.setButtonText(oldConfirmationTask.getButtonText());
case TodoListTask oldTodoTask when newTask instanceof TodoListTask newTodoTask -> case TodoListTask oldTodoTask when newTask instanceof TodoListTask newTodoTask ->
newTodoTask.setTodoItems(oldTodoTask.getTodoItems()); newTodoTask.setTodoItems(oldTodoTask.getTodoItems());
case PhotoTask oldPhotoTask when newTask instanceof PhotoTask newPhotoTask -> { case PhotoTask oldPhotoTask when newTask instanceof PhotoTask newPhotoTask -> {
newPhotoTask.setMinPhotoCount(oldPhotoTask.getMinPhotoCount()); newPhotoTask.setMinPhotoCount(oldPhotoTask.getMinPhotoCount());
newPhotoTask.setMaxPhotoCount(oldPhotoTask.getMaxPhotoCount()); newPhotoTask.setMaxPhotoCount(oldPhotoTask.getMaxPhotoCount());
} }
case BarcodeTask oldBarcodeTask when newTask instanceof BarcodeTask newBarcodeTask -> { case BarcodeTask oldBarcodeTask when newTask instanceof BarcodeTask newBarcodeTask -> {
newBarcodeTask.setMinBarcodeCount(oldBarcodeTask.getMinBarcodeCount()); newBarcodeTask.setMinBarcodeCount(oldBarcodeTask.getMinBarcodeCount());
newBarcodeTask.setMaxBarcodeCount(oldBarcodeTask.getMaxBarcodeCount()); newBarcodeTask.setMaxBarcodeCount(oldBarcodeTask.getMaxBarcodeCount());
} }
case CommentTask oldCommentTask when newTask instanceof CommentTask newCommentTask -> { case CommentTask oldCommentTask when newTask instanceof CommentTask newCommentTask -> {
newCommentTask.setCommentText(oldCommentTask.getCommentText()); newCommentTask.setCommentText(oldCommentTask.getCommentText());
newCommentTask.setRequired(oldCommentTask.isRequired()); newCommentTask.setRequired(oldCommentTask.isRequired());
} }
default -> {} default -> {
}
} }
// Replace in state and preserve order // 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 // Render the UI with the loaded task directly (which IS in tasksState)
TaskType taskType = getTaskTypeFromTask(task); updateTaskConfiguration(configContainer, task);
if (taskType != null) { triggerValidation();
taskTypeCombo.setValue(taskType); updateTabLabels();
updateTaskConfiguration(configContainer, task);
triggerValidation();
updateTabLabels();
}
tasksList.add(taskContainer); tasksList.add(taskContainer);
} }
@@ -2313,14 +2370,19 @@ public class AddJobView extends Main {
* Gets the TaskType enum value from a BaseTask instance * Gets the TaskType enum value from a BaseTask instance
*/ */
private TaskType getTaskTypeFromTask(BaseTask task) { private TaskType getTaskTypeFromTask(BaseTask task) {
if (task instanceof ConfirmationTask) return TaskType.CONFIRMATION; if (task instanceof ConfirmationTask)
if (task instanceof SignatureTask) return TaskType.SIGNATURE; return TaskType.CONFIRMATION;
if (task instanceof TodoListTask) return TaskType.TODOLIST; if (task instanceof SignatureTask)
if (task instanceof PhotoTask) return TaskType.PHOTO; return TaskType.SIGNATURE;
if (task instanceof BarcodeTask) return TaskType.BARCODE; if (task instanceof TodoListTask)
if (task instanceof CommentTask) return TaskType.COMMENT; 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 return TaskType.CONFIRMATION; // fallback
} }
} }

View File

@@ -16,12 +16,12 @@ import com.vaadin.flow.router.Route;
import com.vaadin.flow.theme.lumo.LumoUtility; import com.vaadin.flow.theme.lumo.LumoUtility;
import de.assecutor.votianlt.model.JobStatus; import de.assecutor.votianlt.model.JobStatus;
import de.assecutor.votianlt.repository.*; import de.assecutor.votianlt.repository.*;
import de.assecutor.votianlt.util.DateTimeFormatUtil;
import jakarta.annotation.security.RolesAllowed; import jakarta.annotation.security.RolesAllowed;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
@Route(value = "admin-dashboard", layout = de.assecutor.votianlt.pages.base.ui.view.AdminLayout.class) @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 PendingMqttMessageRepository pendingMqttMessageRepository;
private final Div statisticsContainer; private final Div statisticsContainer;
private final DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm");
@Autowired @Autowired
public AdminDashboardView( public AdminDashboardView(JobRepository jobRepository, TaskRepository taskRepository, UserRepository userRepository,
JobRepository jobRepository, AppUserRepository appUserRepository, CargoItemRepository cargoItemRepository,
TaskRepository taskRepository, PhotoRepository photoRepository, BarcodeRepository barcodeRepository,
UserRepository userRepository, SignatureRepository signatureRepository, CommentRepository commentRepository,
AppUserRepository appUserRepository,
CargoItemRepository cargoItemRepository,
PhotoRepository photoRepository,
BarcodeRepository barcodeRepository,
SignatureRepository signatureRepository,
CommentRepository commentRepository,
PendingMqttMessageRepository pendingMqttMessageRepository) { PendingMqttMessageRepository pendingMqttMessageRepository) {
this.jobRepository = jobRepository; this.jobRepository = jobRepository;
@@ -70,8 +63,8 @@ public class AdminDashboardView extends Main {
this.pendingMqttMessageRepository = pendingMqttMessageRepository; this.pendingMqttMessageRepository = pendingMqttMessageRepository;
setSizeFull(); setSizeFull();
addClassNames(LumoUtility.BoxSizing.BORDER, LumoUtility.Display.FLEX, addClassNames(LumoUtility.BoxSizing.BORDER, LumoUtility.Display.FLEX, LumoUtility.FlexDirection.COLUMN,
LumoUtility.FlexDirection.COLUMN, LumoUtility.Padding.MEDIUM); LumoUtility.Padding.MEDIUM);
// Header // Header
H1 title = new H1("Administrator Dashboard"); 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")); cards.add(createStatCard("App-Benutzer", String.valueOf(totalAppUsers), VaadinIcon.MOBILE, "purple"));
// Current time // Current time
String currentTime = LocalDateTime.now().format(dateTimeFormatter); String currentTime = DateTimeFormatUtil.formatDateTime(LocalDateTime.now());
cards.add(createStatCard("Letzte Aktualisierung", currentTime, VaadinIcon.CLOCK, "gray")); cards.add(createStatCard("Letzte Aktualisierung", currentTime, VaadinIcon.CLOCK, "gray"));
section.add(title, cards); section.add(title, cards);
@@ -240,7 +233,8 @@ public class AdminDashboardView extends Main {
// Completion rate // Completion rate
double completionRate = totalTasks > 0 ? (completedTasks * 100.0 / totalTasks) : 0; 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); section.add(title, cards);
return section; return section;
@@ -321,11 +315,8 @@ public class AdminDashboardView extends Main {
private Div createStatCard(String title, String value, VaadinIcon icon, String color) { private Div createStatCard(String title, String value, VaadinIcon icon, String color) {
Div card = new Div(); Div card = new Div();
card.addClassName(LumoUtility.Background.BASE); card.addClassName(LumoUtility.Background.BASE);
card.getStyle() card.getStyle().set("border-radius", "8px").set("padding", "1rem")
.set("border-radius", "8px") .set("box-shadow", "0 2px 4px rgba(0,0,0,0.1)").set("min-width", "200px")
.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)"); .set("border-left", "4px solid var(--lumo-" + color + "-color, #007bff)");
HorizontalLayout header = new HorizontalLayout(); HorizontalLayout header = new HorizontalLayout();

View File

@@ -66,9 +66,7 @@ public class AdminPricetableView extends VerticalLayout {
private void savePriceTable() { private void savePriceTable() {
try { try {
// Get first entry or create new one // Get first entry or create new one
PriceTable priceTable = priceTableRepository.findAll().stream() PriceTable priceTable = priceTableRepository.findAll().stream().findFirst().orElse(new PriceTable());
.findFirst()
.orElse(new PriceTable());
priceTable.setMonthlyBasePackage(monthlyBasePackage.getValue()); priceTable.setMonthlyBasePackage(monthlyBasePackage.getValue());
priceTable.setAppUsageLicense(appUsageLicense.getValue()); priceTable.setAppUsageLicense(appUsageLicense.getValue());
@@ -83,17 +81,19 @@ public class AdminPricetableView extends VerticalLayout {
private void loadPriceTable() { private void loadPriceTable() {
try { try {
PriceTable priceTable = priceTableRepository.findAll().stream() PriceTable priceTable = priceTableRepository.findAll().stream().findFirst().orElse(null);
.findFirst()
.orElse(null);
if (priceTable != null) { if (priceTable != null) {
monthlyBasePackage.setValue(priceTable.getMonthlyBasePackage() != null ? priceTable.getMonthlyBasePackage() : ""); monthlyBasePackage
appUsageLicense.setValue(priceTable.getAppUsageLicense() != null ? priceTable.getAppUsageLicense() : ""); .setValue(priceTable.getMonthlyBasePackage() != null ? priceTable.getMonthlyBasePackage() : "");
revenueParticipation.setValue(priceTable.getRevenueParticipation() != null ? priceTable.getRevenueParticipation() : ""); appUsageLicense
.setValue(priceTable.getAppUsageLicense() != null ? priceTable.getAppUsageLicense() : "");
revenueParticipation.setValue(
priceTable.getRevenueParticipation() != null ? priceTable.getRevenueParticipation() : "");
} }
} catch (Exception ex) { } 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);
} }
} }
} }

View File

@@ -15,7 +15,7 @@ import jakarta.annotation.security.RolesAllowed;
@Route(value = "dashboard", layout = MainLayout.class) @Route(value = "dashboard", layout = MainLayout.class)
@PageTitle("VotianLT - Dashboard") @PageTitle("VotianLT - Dashboard")
@RolesAllowed({"USER"}) @RolesAllowed({ "USER" })
public class AuthenticatedStartView extends VerticalLayout { public class AuthenticatedStartView extends VerticalLayout {
private final SecurityService securityService; private final SecurityService securityService;

View File

@@ -24,7 +24,6 @@ import jakarta.annotation.security.RolesAllowed;
import org.bson.types.ObjectId; import org.bson.types.ObjectId;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
@PageTitle("App-Nutzer bearbeiten") @PageTitle("App-Nutzer bearbeiten")
@Route(value = "edit-app-user", layout = de.assecutor.votianlt.pages.base.ui.view.MainLayout.class) @Route(value = "edit-app-user", layout = de.assecutor.votianlt.pages.base.ui.view.MainLayout.class)
@RolesAllowed({ "USER", "ADMIN" }) @RolesAllowed({ "USER", "ADMIN" })
@@ -194,7 +193,6 @@ public class EditAppUserView extends VerticalLayout implements HasUrlParameter<S
appUser.setPassword(originalPassword); appUser.setPassword(originalPassword);
} }
appUserService.updateAppUser(appUser); appUserService.updateAppUser(appUser);
Notification.show("App-Nutzer erfolgreich gespeichert", 3000, Notification.Position.MIDDLE); Notification.show("App-Nutzer erfolgreich gespeichert", 3000, Notification.Position.MIDDLE);
navigateBack(); navigateBack();
@@ -230,7 +228,6 @@ public class EditAppUserView extends VerticalLayout implements HasUrlParameter<S
return true; return true;
} }
private void deleteAppUser() { private void deleteAppUser() {
// Show confirmation dialog // Show confirmation dialog
com.vaadin.flow.component.dialog.Dialog confirmDialog = new com.vaadin.flow.component.dialog.Dialog(); com.vaadin.flow.component.dialog.Dialog confirmDialog = new com.vaadin.flow.component.dialog.Dialog();

View File

@@ -61,7 +61,7 @@ public class EditProfileView extends HorizontalLayout {
private Checkbox billingEnabled; private Checkbox billingEnabled;
public EditProfileView(UserService userService, UserInvoiceDataService userInvoiceDataService, public EditProfileView(UserService userService, UserInvoiceDataService userInvoiceDataService,
CustomerInvoiceService customerInvoiceService, SecurityService securityService) { CustomerInvoiceService customerInvoiceService, SecurityService securityService) {
this.userInvoiceDataService = userInvoiceDataService; this.userInvoiceDataService = userInvoiceDataService;
this.customerInvoiceService = customerInvoiceService; this.customerInvoiceService = customerInvoiceService;
this.currentUser = securityService.getCurrentDatabaseUser(); this.currentUser = securityService.getCurrentDatabaseUser();
@@ -202,24 +202,25 @@ public class EditProfileView extends HorizontalLayout {
binder.forField(companyField).asRequired("Firma ist erforderlich").bind(User::getCompany, User::setCompany); binder.forField(companyField).asRequired("Firma ist erforderlich").bind(User::getCompany, User::setCompany);
binder.forField(companyAddField).bind(User::getCompanyAddition, User::setCompanyAddition); binder.forField(companyAddField).bind(User::getCompanyAddition, User::setCompanyAddition);
binder.forField(streetField).asRequired("Straße ist erforderlich").bind(User::getStreet, User::setStreet); binder.forField(streetField).asRequired("Straße ist erforderlich").bind(User::getStreet, User::setStreet);
binder.forField(houseNumberField).asRequired("Hausnummer ist erforderlich").bind(User::getHouseNumber, User::setHouseNumber); binder.forField(houseNumberField).asRequired("Hausnummer ist erforderlich").bind(User::getHouseNumber,
User::setHouseNumber);
binder.forField(addressAddField).bind(User::getAddressAddition, User::setAddressAddition); binder.forField(addressAddField).bind(User::getAddressAddition, User::setAddressAddition);
binder.forField(zipField).asRequired("Postleitzahl ist erforderlich").bind(User::getZip, User::setZip); binder.forField(zipField).asRequired("Postleitzahl ist erforderlich").bind(User::getZip, User::setZip);
binder.forField(cityField).asRequired("Stadt ist erforderlich").bind(User::getCity, User::setCity); binder.forField(cityField).asRequired("Stadt ist erforderlich").bind(User::getCity, User::setCity);
// Personendaten binden // Personendaten binden
binder.forField(firstnameField).asRequired("Vorname ist erforderlich").bind(User::getFirstname, User::setFirstname); binder.forField(firstnameField).asRequired("Vorname ist erforderlich").bind(User::getFirstname,
User::setFirstname);
binder.forField(lastnameField).asRequired("Nachname ist erforderlich").bind(User::getName, User::setName); binder.forField(lastnameField).asRequired("Nachname ist erforderlich").bind(User::getName, User::setName);
binder.forField(phoneField).asRequired("Telefonnummer ist erforderlich").bind(User::getPhone, User::setPhone); binder.forField(phoneField).asRequired("Telefonnummer ist erforderlich").bind(User::getPhone, User::setPhone);
binder.forField(emailField).asRequired("E-Mail ist erforderlich").withValidator(new EmailValidator("Ungültige E-Mail-Adresse")) binder.forField(emailField).asRequired("E-Mail ist erforderlich")
.bind(User::getEmail, User::setEmail); .withValidator(new EmailValidator("Ungültige E-Mail-Adresse")).bind(User::getEmail, User::setEmail);
// Optionale Felder // Optionale Felder
binder.forField(mobileField).bind(User::getPhone2, User::setPhone2); binder.forField(mobileField).bind(User::getPhone2, User::setPhone2);
binder.forField(faxField).bind(User::getFax, User::setFax); binder.forField(faxField).bind(User::getFax, User::setFax);
// Abweichende Rechnungsadresse binden // Abweichende Rechnungsadresse binden
binder.forField(diffInvoiceAddress).bind( binder.forField(diffInvoiceAddress).bind(User::isDiffInvoiceAddress,
User::isDiffInvoiceAddress,
(user, value) -> user.setDiffInvoiceAddress(Boolean.TRUE.equals(value))); (user, value) -> user.setDiffInvoiceAddress(Boolean.TRUE.equals(value)));
binder.forField(invCompanyField).bind(User::getInvCompany, User::setInvCompany); binder.forField(invCompanyField).bind(User::getInvCompany, User::setInvCompany);
binder.forField(invCompanyAddField).bind(User::getInvCompanyAddition, User::setInvCompanyAddition); 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"); Span digitalProcessInfo = new Span("Aktiviert die digitale Auftragsabwicklung über die mobile App");
digitalProcessInfo.getStyle().set("font-size", "var(--lumo-font-size-s)") digitalProcessInfo.getStyle().set("font-size", "var(--lumo-font-size-s)")
.set("color", "var(--lumo-secondary-text-color)") .set("color", "var(--lumo-secondary-text-color)").set("margin-left", "var(--lumo-space-xl)");
.set("margin-left", "var(--lumo-space-xl)");
Checkbox locateAppUser = new Checkbox("App-Nutzer orten"); Checkbox locateAppUser = new Checkbox("App-Nutzer orten");
locateAppUser.setValue(currentUser.isLocationTrackingEnabled()); locateAppUser.setValue(currentUser.isLocationTrackingEnabled());
Span locateAppUserInfo = new Span("Ermöglicht die Ortung von App-Nutzern während der Auftragsausführung"); 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)") locateAppUserInfo.getStyle().set("font-size", "var(--lumo-font-size-s)")
.set("color", "var(--lumo-secondary-text-color)") .set("color", "var(--lumo-secondary-text-color)").set("margin-left", "var(--lumo-space-xl)");
.set("margin-left", "var(--lumo-space-xl)");
// Save checkbox states when changed // Save checkbox states when changed
digitalProcess.addValueChangeListener(e -> { 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"); 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)") twoFactorDescription.getStyle().set("font-size", "var(--lumo-font-size-s)")
.set("color", "var(--lumo-secondary-text-color)") .set("color", "var(--lumo-secondary-text-color)").set("margin-left", "var(--lumo-space-xl)");
.set("margin-left", "var(--lumo-space-xl)");
securityTab.add(twoFactorLayout, twoFactorDescription); securityTab.add(twoFactorLayout, twoFactorDescription);
@@ -426,10 +424,11 @@ public class EditProfileView extends HorizontalLayout {
saveProfile.addClickListener(e -> { saveProfile.addClickListener(e -> {
// Validate all required fields first // Validate all required fields first
boolean isValid = validateAllProfileFields(companyField, firstnameField, lastnameField, phoneField, boolean isValid = validateAllProfileFields(companyField, firstnameField, lastnameField, phoneField,
emailField, streetField, houseNumberField, zipField, cityField); emailField, streetField, houseNumberField, zipField, cityField);
if (!isValid) { 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; return;
} }
@@ -571,21 +570,18 @@ public class EditProfileView extends HorizontalLayout {
List<CustomerInvoiceItem> items = new ArrayList<>(); List<CustomerInvoiceItem> items = new ArrayList<>();
BigDecimal vatRate = parseVatRate(safe(taxRateField)); BigDecimal vatRate = parseVatRate(safe(taxRateField));
CustomerInvoiceItem item1 = new CustomerInvoiceItem( CustomerInvoiceItem item1 = new CustomerInvoiceItem(new BigDecimal("1"), "Stk.",
new BigDecimal("1"), "Stk.", "Beispiel-Dienstleistung 1", "Beispiel-Dienstleistung 1", new BigDecimal("100.00"), vatRate);
new BigDecimal("100.00"), vatRate); CustomerInvoiceItem item2 = new CustomerInvoiceItem(new BigDecimal("2"), "Std.",
CustomerInvoiceItem item2 = new CustomerInvoiceItem( "Beispiel-Dienstleistung 2", new BigDecimal("50.00"), vatRate);
new BigDecimal("2"), "Std.", "Beispiel-Dienstleistung 2",
new BigDecimal("50.00"), vatRate);
items.add(item1); items.add(item1);
items.add(item2); items.add(item2);
invoiceData.setItems(items); invoiceData.setItems(items);
// Calculate amounts // Calculate amounts
BigDecimal netAmount = items.stream() BigDecimal netAmount = items.stream().map(CustomerInvoiceItem::getNetTotal).reduce(BigDecimal.ZERO,
.map(CustomerInvoiceItem::getNetTotal) BigDecimal::add);
.reduce(BigDecimal.ZERO, BigDecimal::add);
BigDecimal vatAmount = netAmount.multiply(vatRate); BigDecimal vatAmount = netAmount.multiply(vatRate);
BigDecimal totalAmount = netAmount.add(vatAmount); BigDecimal totalAmount = netAmount.add(vatAmount);
@@ -664,18 +660,9 @@ public class EditProfileView extends HorizontalLayout {
} }
private void saveInvoiceData() { private void saveInvoiceData() {
currentInvoiceData = userInvoiceDataService.createOrUpdate( currentInvoiceData = userInvoiceDataService.createOrUpdate(currentUser.getId(), billingEnabled.getValue(),
currentUser.getId(), prefixField.getValue(), ustIdField.getValue(), taxNumberField.getValue(), bankNameField.getValue(),
billingEnabled.getValue(), ibanField.getValue(), taxRateField.getValue(), introTextArea.getValue(), termsTextArea.getValue());
prefixField.getValue(),
ustIdField.getValue(),
taxNumberField.getValue(),
bankNameField.getValue(),
ibanField.getValue(),
taxRateField.getValue(),
introTextArea.getValue(),
termsTextArea.getValue()
);
} }
private String safe(String value) { private String safe(String value) {
@@ -708,8 +695,8 @@ public class EditProfileView extends HorizontalLayout {
} }
private boolean validateAllProfileFields(TextField companyField, TextField firstnameField, TextField lastnameField, private boolean validateAllProfileFields(TextField companyField, TextField firstnameField, TextField lastnameField,
TextField phoneField, EmailField emailField, TextField streetField, TextField phoneField, EmailField emailField, TextField streetField, TextField houseNumberField,
TextField houseNumberField, TextField zipField, TextField cityField) { TextField zipField, TextField cityField) {
validateField(companyField, "Firma ist ein Pflichtfeld"); validateField(companyField, "Firma ist ein Pflichtfeld");
validateField(firstnameField, "Vorname ist ein Pflichtfeld"); validateField(firstnameField, "Vorname ist ein Pflichtfeld");
validateField(lastnameField, "Nachname ist ein Pflichtfeld"); validateField(lastnameField, "Nachname ist ein Pflichtfeld");

View File

@@ -12,12 +12,12 @@ import de.assecutor.votianlt.model.invoices.SystemInvoice;
import de.assecutor.votianlt.model.invoices.SystemInvoiceData; import de.assecutor.votianlt.model.invoices.SystemInvoiceData;
import de.assecutor.votianlt.model.invoices.SystemInvoiceItem; import de.assecutor.votianlt.model.invoices.SystemInvoiceItem;
import de.assecutor.votianlt.service.SystemInvoiceService; import de.assecutor.votianlt.service.SystemInvoiceService;
import de.assecutor.votianlt.util.DateTimeFormatUtil;
import jakarta.annotation.security.RolesAllowed; import jakarta.annotation.security.RolesAllowed;
import java.io.ByteArrayInputStream; import java.io.ByteArrayInputStream;
import java.text.NumberFormat; import java.text.NumberFormat;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
@@ -94,12 +94,11 @@ public class InvoicesView extends VerticalLayout {
} }
private byte[] generateSystemInvoicePdf(SystemInvoice systemInvoice) throws Exception { private byte[] generateSystemInvoicePdf(SystemInvoice systemInvoice) throws Exception {
DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("dd.MM.yyyy");
NumberFormat CURRENCY_FMT = NumberFormat.getCurrencyInstance(Locale.GERMANY); NumberFormat CURRENCY_FMT = NumberFormat.getCurrencyInstance(Locale.GERMANY);
SystemInvoiceData data = new SystemInvoiceData(); SystemInvoiceData data = new SystemInvoiceData();
data.setInvoiceNumber(systemInvoice.getId()); data.setInvoiceNumber(systemInvoice.getId());
data.setInvoiceDate(DATE_FMT.format(systemInvoice.getDatum())); data.setInvoiceDate(DateTimeFormatUtil.formatDate(systemInvoice.getDatum()));
data.setInvoiceText(systemInvoice.getBeschreibung()); data.setInvoiceText(systemInvoice.getBeschreibung());
// Empfänger aus der Zeile (nur Name in den Testdaten vorhanden) // Empfänger aus der Zeile (nur Name in den Testdaten vorhanden)

View File

@@ -26,6 +26,7 @@ import de.assecutor.votianlt.repository.JobRepository;
import de.assecutor.votianlt.repository.PhotoRepository; import de.assecutor.votianlt.repository.PhotoRepository;
import de.assecutor.votianlt.repository.SignatureRepository; import de.assecutor.votianlt.repository.SignatureRepository;
import de.assecutor.votianlt.service.JobHistoryService; import de.assecutor.votianlt.service.JobHistoryService;
import de.assecutor.votianlt.util.DateTimeFormatUtil;
import jakarta.annotation.security.RolesAllowed; import jakarta.annotation.security.RolesAllowed;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.bson.types.ObjectId; import org.bson.types.ObjectId;
@@ -294,9 +295,7 @@ public class JobHistoryView extends Main implements HasUrlParameter<String> {
if (dateTime == null) if (dateTime == null)
return ""; return "";
try { try {
java.time.format.DateTimeFormatter formatter = java.time.format.DateTimeFormatter return DateTimeFormatUtil.formatDateTime(dateTime);
.ofPattern("dd.MM.yyyy HH:mm");
return dateTime.format(formatter);
} catch (Exception e) { } catch (Exception e) {
return dateTime.toString(); return dateTime.toString();
} }

View File

@@ -42,12 +42,18 @@ import de.assecutor.votianlt.model.Signature;
import de.assecutor.votianlt.model.Barcode; import de.assecutor.votianlt.model.Barcode;
import de.assecutor.votianlt.model.Photo; import de.assecutor.votianlt.model.Photo;
import de.assecutor.votianlt.model.Comment; import de.assecutor.votianlt.model.Comment;
import de.assecutor.votianlt.model.JobStatus;
import de.assecutor.votianlt.pages.service.AppUserService; import de.assecutor.votianlt.pages.service.AppUserService;
import de.assecutor.votianlt.service.JobHistoryService;
import de.assecutor.votianlt.service.MessageService; 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 jakarta.annotation.security.RolesAllowed;
import org.bson.types.ObjectId; import org.bson.types.ObjectId;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import java.time.LocalDateTime;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
@@ -66,6 +72,7 @@ public class JobSummaryView extends Main implements HasUrlParameter<String> {
private final PhotoRepository photoRepository; private final PhotoRepository photoRepository;
private final CommentRepository commentRepository; private final CommentRepository commentRepository;
private final AppUserService appUserService; private final AppUserService appUserService;
private final JobHistoryService jobHistoryService;
@Value("${app.google.maps.api-key}") @Value("${app.google.maps.api-key}")
private String googleMapsApiKey; private String googleMapsApiKey;
@@ -76,7 +83,7 @@ public class JobSummaryView extends Main implements HasUrlParameter<String> {
public JobSummaryView(JobRepository jobRepository, CargoItemRepository cargoItemRepository, public JobSummaryView(JobRepository jobRepository, CargoItemRepository cargoItemRepository,
TaskRepository taskRepository, SignatureRepository signatureRepository, BarcodeRepository barcodeRepository, TaskRepository taskRepository, SignatureRepository signatureRepository, BarcodeRepository barcodeRepository,
PhotoRepository photoRepository, CommentRepository commentRepository, AppUserService appUserService, PhotoRepository photoRepository, CommentRepository commentRepository, AppUserService appUserService,
MessageService messageService) { MessageService messageService, JobHistoryService jobHistoryService) {
this.jobRepository = jobRepository; this.jobRepository = jobRepository;
this.cargoItemRepository = cargoItemRepository; this.cargoItemRepository = cargoItemRepository;
this.taskRepository = taskRepository; this.taskRepository = taskRepository;
@@ -85,6 +92,7 @@ public class JobSummaryView extends Main implements HasUrlParameter<String> {
this.photoRepository = photoRepository; this.photoRepository = photoRepository;
this.commentRepository = commentRepository; this.commentRepository = commentRepository;
this.appUserService = appUserService; this.appUserService = appUserService;
this.jobHistoryService = jobHistoryService;
setSizeFull(); setSizeFull();
addClassNames(LumoUtility.BoxSizing.BORDER, LumoUtility.Display.FLEX, LumoUtility.FlexDirection.COLUMN, addClassNames(LumoUtility.BoxSizing.BORDER, LumoUtility.Display.FLEX, LumoUtility.FlexDirection.COLUMN,
@@ -132,11 +140,8 @@ public class JobSummaryView extends Main implements HasUrlParameter<String> {
sendMessageButton.addClickListener(e -> { sendMessageButton.addClickListener(e -> {
// Check if job has an app user assigned // Check if job has an app user assigned
if (job.getAppUser() == null || job.getAppUser().isBlank()) { if (job.getAppUser() == null || job.getAppUser().isBlank()) {
Notification.show( Notification.show("Diesem Auftrag ist kein App-Nutzer zugeordnet", 3000, Notification.Position.MIDDLE)
"Diesem Auftrag ist kein App-Nutzer zugeordnet", .addThemeVariants(NotificationVariant.LUMO_ERROR);
3000,
Notification.Position.MIDDLE
).addThemeVariants(NotificationVariant.LUMO_ERROR);
return; return;
} }
@@ -270,6 +275,51 @@ public class JobSummaryView extends Main implements HasUrlParameter<String> {
// Google Maps Karte mit Route // Google Maps Karte mit Route
addRouteMap(job); 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() { private VerticalLayout borderedBox() {
@@ -284,9 +334,7 @@ public class JobSummaryView extends Main implements HasUrlParameter<String> {
private String formatLocalDate(java.time.LocalDate date) { private String formatLocalDate(java.time.LocalDate date) {
try { try {
java.time.format.DateTimeFormatter fmt = java.time.format.DateTimeFormatter.ofPattern("dd.MM.yyyy") return DateTimeFormatUtil.formatDate(date);
.withLocale(Locale.GERMANY);
return date.format(fmt);
} catch (Exception e) { } catch (Exception e) {
return ""; return "";
} }
@@ -406,8 +454,9 @@ public class JobSummaryView extends Main implements HasUrlParameter<String> {
+ " }" + " });" + " if (!bounds.isEmpty()) { map.fitBounds(bounds); }" + " }" + " });" + " if (!bounds.isEmpty()) { map.fitBounds(bounds); }"
+ " }});" + " }" + " if (!(window.google && window.google.maps)) {" + " }});" + " }" + " if (!(window.google && window.google.maps)) {"
+ " var s=document.createElement('script');" + " var s=document.createElement('script');"
+ " s.src='https://maps.googleapis.com/maps/api/js?key=" + getGoogleMapsApiKey() + "&libraries=places';" + " s.src='https://maps.googleapis.com/maps/api/js?key=" + getGoogleMapsApiKey()
+ " s.onload=init; document.head.appendChild(s);" + " } else { init(); }" + "})();"); + "&libraries=places';" + " s.onload=init; document.head.appendChild(s);" + " } else { init(); }"
+ "})();");
map.getElement().executeJs(js, map.getElement(), routeInfo.getElement()); map.getElement().executeJs(js, map.getElement(), routeInfo.getElement());
} }
@@ -636,13 +685,9 @@ public class JobSummaryView extends Main implements HasUrlParameter<String> {
for (Comment comment : comments) { for (Comment comment : comments) {
Div commentContainer = new Div(); Div commentContainer = new Div();
commentContainer.getStyle() commentContainer.getStyle().set("background-color", "#f5f5f5")
.set("background-color", "#f5f5f5") .set("border", "1px solid #ddd").set("border-radius", "4px").set("padding", "8px")
.set("border", "1px solid #ddd") .set("margin", "4px 0").set("font-family", "monospace")
.set("border-radius", "4px")
.set("padding", "8px")
.set("margin", "4px 0")
.set("font-family", "monospace")
.set("white-space", "pre-wrap"); .set("white-space", "pre-wrap");
Span commentText = new Span(comment.getCommentText()); Span commentText = new Span(comment.getCommentText());
@@ -658,13 +703,7 @@ public class JobSummaryView extends Main implements HasUrlParameter<String> {
} }
private String formatDateTime(java.time.LocalDateTime dateTime) { private String formatDateTime(java.time.LocalDateTime dateTime) {
try { return DateTimeFormatUtil.formatDateTime(dateTime);
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();
}
} }
private Div createTaskCard(BaseTask task, String displayName) { private Div createTaskCard(BaseTask task, String displayName) {

View File

@@ -93,8 +93,7 @@ public class LoginView extends VerticalLayout implements BeforeEnterObserver, Af
// Version display - will be set in @PostConstruct // Version display - will be set in @PostConstruct
versionSpan = new Span(""); versionSpan = new Span("");
versionSpan.getStyle().set("color", "var(--lumo-secondary-text-color)") versionSpan.getStyle().set("color", "var(--lumo-secondary-text-color)")
.set("font-size", "var(--lumo-font-size-s)") .set("font-size", "var(--lumo-font-size-s)").set("margin-top", "var(--lumo-space-m)");
.set("margin-top", "var(--lumo-space-m)");
// Inline flash message box (hidden by default) // Inline flash message box (hidden by default)
flashBox.getStyle().set("background", "var(--lumo-error-color-10pct)") 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)); .authenticate(new UsernamePasswordAuthenticationToken(username, password));
// Prüfe ob 2FA für diesen Nutzer aktiviert ist (global UND nutzer-spezifisch) // Prüfe ob 2FA für diesen Nutzer aktiviert ist (global UND nutzer-spezifisch)
boolean userTwoFactorEnabled = userRepository.findByEmail(username) boolean userTwoFactorEnabled = userRepository.findByEmail(username).map(User::isTwoFactorEnabled)
.map(User::isTwoFactorEnabled)
.orElse(true); // Standardmäßig aktiviert falls Nutzer nicht gefunden .orElse(true); // Standardmäßig aktiviert falls Nutzer nicht gefunden
if (twoFactorEnabledGlobal && userTwoFactorEnabled) { if (twoFactorEnabledGlobal && userTwoFactorEnabled) {

View File

@@ -37,6 +37,7 @@ import de.assecutor.votianlt.model.MessageType;
import de.assecutor.votianlt.pages.service.AppUserService; import de.assecutor.votianlt.pages.service.AppUserService;
import de.assecutor.votianlt.service.MessageBroadcaster; import de.assecutor.votianlt.service.MessageBroadcaster;
import de.assecutor.votianlt.service.MessageService; import de.assecutor.votianlt.service.MessageService;
import de.assecutor.votianlt.util.DateTimeFormatUtil;
import de.assecutor.votianlt.event.MessageReadStatusChangedEvent; import de.assecutor.votianlt.event.MessageReadStatusChangedEvent;
import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.ApplicationEventPublisher;
import jakarta.annotation.security.RolesAllowed; import jakarta.annotation.security.RolesAllowed;
@@ -58,7 +59,6 @@ import javax.imageio.ImageWriter;
import javax.imageio.stream.ImageOutputStream; import javax.imageio.stream.ImageOutputStream;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Base64; import java.util.Base64;
import java.util.List; import java.util.List;
import java.util.Objects; import java.util.Objects;
@@ -89,15 +89,12 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
private List<Message> currentMessages; // Current messages being displayed for redrawing private List<Message> currentMessages; // Current messages being displayed for redrawing
private Registration broadcasterRegistration; // Track listener registration private Registration broadcasterRegistration; // Track listener registration
private TextArea messageInput; 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 MAX_IMAGE_FILE_SIZE = 32 * 1024 * 1024; // 32 MB aligns with Spring settings
private static final int TARGET_IMAGE_WIDTH = 1920; private static final int TARGET_IMAGE_WIDTH = 1920;
private static final float JPEG_COMPRESSION_QUALITY = 0.8f; private static final float JPEG_COMPRESSION_QUALITY = 0.8f;
public MessageDetailsView(AppUserService appUserService, MessageService messageService, public MessageDetailsView(AppUserService appUserService, MessageService messageService,
MessageBroadcaster messageBroadcaster, MessageBroadcaster messageBroadcaster, ApplicationEventPublisher eventPublisher) {
ApplicationEventPublisher eventPublisher) {
this.appUserService = appUserService; this.appUserService = appUserService;
this.messageService = messageService; this.messageService = messageService;
this.messageBroadcaster = messageBroadcaster; this.messageBroadcaster = messageBroadcaster;
@@ -128,7 +125,8 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
log.info("MessageDetailsView - participant: {}, conversationId: {}", participantKey, conversationId); log.info("MessageDetailsView - participant: {}, conversationId: {}", participantKey, conversationId);
if (participantKey == null || conversationId == null) { 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"); event.rerouteToError(IllegalArgumentException.class, "Missing required parameters");
return; return;
} }
@@ -175,10 +173,8 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
messagesContainer.setPadding(false); messagesContainer.setPadding(false);
messagesContainer.setSpacing(false); messagesContainer.setSpacing(false);
messagesContainer.setWidthFull(); messagesContainer.setWidthFull();
messagesContainer.getStyle() messagesContainer.getStyle().set("background-color", "#f0f0f0").set("border-radius", "8px").set("padding",
.set("background-color", "#f0f0f0") "15px");
.set("border-radius", "8px")
.set("padding", "15px");
// Wrap messages container in scroller for vertical scrolling // Wrap messages container in scroller for vertical scrolling
messagesScroller = new Scroller(messagesContainer); messagesScroller = new Scroller(messagesContainer);
@@ -229,14 +225,15 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
messagesContainer.add(createMessageBubble(message, timestamp)); 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(); markVisibleMessagesAsRead();
} }
/** /**
* Marks all currently visible messages that are addressed to the logged-in user as read. * Marks all currently visible messages that are addressed to the logged-in user
* This is triggered after (re)rendering the conversation and will also update the in-memory * as read. This is triggered after (re)rendering the conversation and will also
* message objects to keep UI state consistent. * update the in-memory message objects to keep UI state consistent.
*/ */
private void markVisibleMessagesAsRead() { private void markVisibleMessagesAsRead() {
try { try {
@@ -278,27 +275,19 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
upload.setWidthFull(); upload.setWidthFull();
Span helper = new Span("Unterstützte Formate: PNG, JPG, GIF, WebP (max. 32 MB)"); Span helper = new Span("Unterstützte Formate: PNG, JPG, GIF, WebP (max. 32 MB)");
helper.getStyle() helper.getStyle().set("font-size", "12px").set("color", "#666666");
.set("font-size", "12px")
.set("color", "#666666");
Image preview = new Image(); Image preview = new Image();
preview.setAlt("Vorschau des ausgewählten Bildes"); preview.setAlt("Vorschau des ausgewählten Bildes");
preview.setVisible(false); preview.setVisible(false);
preview.setWidth(null); preview.setWidth(null);
preview.setHeight(null); preview.setHeight(null);
preview.getStyle() preview.getStyle().set("max-width", "100%").set("max-height", "320px").set("height", "auto")
.set("max-width", "100%") .set("border-radius", "12px").set("display", "inline-block");
.set("max-height", "320px")
.set("height", "auto")
.set("border-radius", "12px")
.set("display", "inline-block");
Div previewWrapper = new Div(preview); Div previewWrapper = new Div(preview);
previewWrapper.setWidthFull(); previewWrapper.setWidthFull();
previewWrapper.getStyle() previewWrapper.getStyle().set("text-align", "center").set("margin-top", "10px");
.set("text-align", "center")
.set("margin-top", "10px");
AtomicReference<String> base64Ref = new AtomicReference<>(); AtomicReference<String> base64Ref = new AtomicReference<>();
@@ -325,8 +314,8 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
base64Ref.set(null); base64Ref.set(null);
preview.setVisible(false); preview.setVisible(false);
confirmButton.setEnabled(false); confirmButton.setEnabled(false);
Notification.show("Das Bild konnte nicht verarbeitet werden.", 3000, Notification.show("Das Bild konnte nicht verarbeitet werden.", 3000, Notification.Position.MIDDLE)
Notification.Position.MIDDLE).addThemeVariants(NotificationVariant.LUMO_ERROR); .addThemeVariants(NotificationVariant.LUMO_ERROR);
return; return;
} }
@@ -400,22 +389,14 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
*/ */
private Div createDateSeparator(LocalDate date) { private Div createDateSeparator(LocalDate date) {
Div separator = new Div(); Div separator = new Div();
separator.getStyle() separator.getStyle().set("display", "flex").set("justify-content", "center").set("text-align", "center")
.set("display", "flex") .set("margin", "20px 0");
.set("justify-content", "center")
.set("text-align", "center")
.set("margin", "20px 0");
separator.setWidthFull(); separator.setWidthFull();
Span dateLabel = new Span(date.format(DATE_FORMATTER)); Span dateLabel = new Span(DateTimeFormatUtil.formatDate(date));
dateLabel.getStyle() dateLabel.getStyle().set("background-color", "#d0d0d0").set("padding", "4px 10px").set("border-radius", "12px")
.set("background-color", "#d0d0d0") .set("font-size", "12px").set("font-weight", "500").set("color", "#333333")
.set("padding", "4px 10px") .set("display", "inline-block");
.set("border-radius", "12px")
.set("font-size", "12px")
.set("font-weight", "500")
.set("color", "#333333")
.set("display", "inline-block");
separator.add(dateLabel); separator.add(dateLabel);
return separator; return separator;
@@ -426,41 +407,31 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
*/ */
private Div createMessageBubble(Message message, LocalDateTime timestamp) { private Div createMessageBubble(Message message, LocalDateTime timestamp) {
// Determine alignment based on message origin // 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; boolean isServerMessage = message.getOrigin() == MessageOrigin.SERVER;
// Container for the message (aligns left or right) // Container for the message (aligns left or right)
Div messageWrapper = new Div(); Div messageWrapper = new Div();
String alignment = isServerMessage ? "right" : "left"; String alignment = isServerMessage ? "right" : "left";
messageWrapper.getStyle() messageWrapper.getStyle().set("display", "flex")
.set("display", "flex") .set("justify-content", isServerMessage ? "flex-end" : "flex-start").set("margin", "5px 0")
.set("justify-content", isServerMessage ? "flex-end" : "flex-start") .set("width", "100%");
.set("margin", "5px 0")
.set("width", "100%");
// Message bubble // Message bubble
Div bubble = new Div(); Div bubble = new Div();
bubble.getStyle() bubble.getStyle().set("background-color", isServerMessage ? "#dcf8c6" : "#ffffff").set("padding", "10px 15px")
.set("background-color", isServerMessage ? "#dcf8c6" : "#ffffff") .set("border-radius", "18px").set("max-width", "70%").set("box-shadow", "0 1px 2px rgba(0,0,0,0.1)")
.set("padding", "10px 15px") .set("word-wrap", "break-word").set("white-space", "pre-wrap").set("text-align", alignment);
.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) // Message content component (text or media)
Component contentComponent = createContentComponent(message, alignment); Component contentComponent = createContentComponent(message, alignment);
// Timestamp // Timestamp
Span timeSpan = new Span(timestamp.format(TIME_FORMATTER)); Span timeSpan = new Span(DateTimeFormatUtil.formatTime(timestamp));
timeSpan.getStyle() timeSpan.getStyle().set("font-size", "11px").set("color", isServerMessage ? "#666666" : "#999999")
.set("font-size", "11px") .set("display", "block").set("text-align", alignment);
.set("color", isServerMessage ? "#666666" : "#999999")
.set("display", "block")
.set("text-align", alignment);
bubble.add(contentComponent, timeSpan); bubble.add(contentComponent, timeSpan);
messageWrapper.add(bubble); messageWrapper.add(bubble);
@@ -471,30 +442,24 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
private Component createContentComponent(Message message, String alignment) { private Component createContentComponent(Message message, String alignment) {
MessageContentType contentType = message.getContentType(); MessageContentType contentType = message.getContentType();
return switch (contentType) { return switch (contentType) {
case IMAGE -> createImageContent(message.getContent(), alignment); case IMAGE -> createImageContent(message.getContent(), alignment);
case TEXT -> createTextContent(message.getContent(), alignment); case TEXT -> createTextContent(message.getContent(), alignment);
}; };
} }
private Component createTextContent(String contentValue, String alignment) { private Component createTextContent(String contentValue, String alignment) {
Div contentDiv = new Div(); Div contentDiv = new Div();
String content = Optional.ofNullable(contentValue).filter(value -> !value.isBlank()) String content = Optional.ofNullable(contentValue).filter(value -> !value.isBlank()).orElse("(kein Inhalt)");
.orElse("(kein Inhalt)");
contentDiv.setText(content); contentDiv.setText(content);
contentDiv.getStyle() contentDiv.getStyle().set("font-size", "14px").set("color", "#000000").set("margin-bottom", "5px")
.set("font-size", "14px") .set("text-align", alignment);
.set("color", "#000000")
.set("margin-bottom", "5px")
.set("text-align", alignment);
return contentDiv; return contentDiv;
} }
private Component createImageContent(String base64Value, String alignment) { private Component createImageContent(String base64Value, String alignment) {
Div wrapper = new Div(); Div wrapper = new Div();
wrapper.getStyle() wrapper.getStyle().set("margin-bottom", "5px").set("display", "flex").set("justify-content",
.set("margin-bottom", "5px") "right".equals(alignment) ? "flex-end" : "flex-start");
.set("display", "flex")
.set("justify-content", "right".equals(alignment) ? "flex-end" : "flex-start");
String trimmed = Optional.ofNullable(base64Value).map(String::trim).orElse(""); String trimmed = Optional.ofNullable(base64Value).map(String::trim).orElse("");
if (trimmed.isEmpty()) { if (trimmed.isEmpty()) {
@@ -509,12 +474,8 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
} }
Image image = new Image(dataUrl, "Nachrichtenbild"); Image image = new Image(dataUrl, "Nachrichtenbild");
image.getStyle() image.getStyle().set("max-width", "100%").set("border-radius", "12px").set("display", "block")
.set("max-width", "100%") .set("max-height", "320px").set("height", "auto");
.set("border-radius", "12px")
.set("display", "block")
.set("max-height", "320px")
.set("height", "auto");
image.getElement().setAttribute("loading", "lazy"); image.getElement().setAttribute("loading", "lazy");
wrapper.add(image); wrapper.add(image);
@@ -676,18 +637,12 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
i18n.setError(error); i18n.setError(error);
UploadI18N.Uploading uploading = new UploadI18N.Uploading(); UploadI18N.Uploading uploading = new UploadI18N.Uploading();
uploading.setStatus(new UploadI18N.Uploading.Status() uploading.setStatus(new UploadI18N.Uploading.Status().setConnecting("Verbindung wird aufgebaut...")
.setConnecting("Verbindung wird aufgebaut...") .setStalled("Upload pausiert").setProcessing("Verarbeitung...").setHeld("Warten auf Upload..."));
.setStalled("Upload pausiert") uploading.setRemainingTime(new UploadI18N.Uploading.RemainingTime().setPrefix("Verbleibende Zeit: ")
.setProcessing("Verarbeitung...")
.setHeld("Warten auf Upload..."));
uploading.setRemainingTime(new UploadI18N.Uploading.RemainingTime()
.setPrefix("Verbleibende Zeit: ")
.setUnknown("Verbleibende Zeit unbekannt")); .setUnknown("Verbleibende Zeit unbekannt"));
uploading.setError(new UploadI18N.Uploading.Error() uploading.setError(new UploadI18N.Uploading.Error().setServerUnavailable("Server nicht erreichbar")
.setServerUnavailable("Server nicht erreichbar") .setUnexpectedServerError("Unerwarteter Serverfehler").setForbidden("Upload nicht erlaubt"));
.setUnexpectedServerError("Unerwarteter Serverfehler")
.setForbidden("Upload nicht erlaubt"));
i18n.setUploading(uploading); i18n.setUploading(uploading);
return i18n; return i18n;
@@ -704,9 +659,7 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
if (scrollAnchor == null) { if (scrollAnchor == null) {
scrollAnchor = new Div(); scrollAnchor = new Div();
scrollAnchor.setId("scroll-anchor"); scrollAnchor.setId("scroll-anchor");
scrollAnchor.getStyle() scrollAnchor.getStyle().set("height", "1px").set("width", "100%");
.set("height", "1px")
.set("width", "100%");
} }
if (scrollAnchor.getParent().isEmpty()) { if (scrollAnchor.getParent().isEmpty()) {
@@ -748,7 +701,6 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
return layout; return layout;
} }
private HorizontalLayout createMessageInputArea() { private HorizontalLayout createMessageInputArea() {
messageInput = new TextArea(); messageInput = new TextArea();
messageInput.setPlaceholder("Nachricht eingeben..."); messageInput.setPlaceholder("Nachricht eingeben...");
@@ -803,12 +755,11 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
Message saved; Message saved;
if (jobConversation) { if (jobConversation) {
// participantKey = AppUser ID (receiver) // participantKey = AppUser ID (receiver)
saved = messageService.sendJobMessageToClient(payload, participantKey, saved = messageService.sendJobMessageToClient(payload, participantKey, contentType, jobIdContext,
contentType, jobIdContext, jobNumberContext); jobNumberContext);
} else { } else {
// participantKey = AppUser ID (receiver) // participantKey = AppUser ID (receiver)
saved = messageService.sendGeneralMessageToClient(payload, participantKey, saved = messageService.sendGeneralMessageToClient(payload, participantKey, contentType);
contentType);
} }
// Mark own outgoing message as read immediately // Mark own outgoing message as read immediately
@@ -831,8 +782,7 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
} catch (Exception ex) { } catch (Exception ex) {
log.error("Failed to send message to {}: {}", participantKey, ex.getMessage(), ex); log.error("Failed to send message to {}: {}", participantKey, ex.getMessage(), ex);
Notification.show("Nachricht konnte nicht gesendet werden: " + ex.getMessage(), 4000, Notification.show("Nachricht konnte nicht gesendet werden: " + ex.getMessage(), 4000,
Notification.Position.MIDDLE) Notification.Position.MIDDLE).addThemeVariants(NotificationVariant.LUMO_ERROR);
.addThemeVariants(NotificationVariant.LUMO_ERROR);
} }
} }
@@ -846,8 +796,7 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
} catch (IllegalArgumentException ex) { } catch (IllegalArgumentException ex) {
return appUserService.findAll().stream() return appUserService.findAll().stream()
.filter(user -> participantKey.equals(user.getEmail()) || participantKey.equals(user.getAppCode())) .filter(user -> participantKey.equals(user.getEmail()) || participantKey.equals(user.getAppCode()))
.findFirst() .findFirst().orElse(null);
.orElse(null);
} }
} }
@@ -856,14 +805,15 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
return List.of(); return List.of();
} }
if ("general".equalsIgnoreCase(conversationId)) { if ("general".equalsIgnoreCase(conversationId)) {
return messages.stream() return messages.stream().filter(
.filter(msg -> Optional.ofNullable(msg.getMessageType()).orElse(MessageType.GENERAL) == MessageType.GENERAL) msg -> Optional.ofNullable(msg.getMessageType()).orElse(MessageType.GENERAL) == MessageType.GENERAL)
.collect(Collectors.toList()); .collect(Collectors.toList());
} }
if (conversationId.startsWith("job-")) { if (conversationId.startsWith("job-")) {
String token = conversationId.substring(4); String token = conversationId.substring(4);
return messages.stream() 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)) && matchesJobConversation(msg, token))
.collect(Collectors.toList()); .collect(Collectors.toList());
} }
@@ -879,8 +829,7 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
String jobId = Optional.ofNullable(message.getJobIdAsString()).orElse(""); String jobId = Optional.ofNullable(message.getJobIdAsString()).orElse("");
return sanitize(jobNumber).equalsIgnoreCase(normalizedToken) return sanitize(jobNumber).equalsIgnoreCase(normalizedToken)
|| sanitize(jobId).equalsIgnoreCase(normalizedToken) || sanitize(jobId).equalsIgnoreCase(normalizedToken) || jobNumber.equalsIgnoreCase(token)
|| jobNumber.equalsIgnoreCase(token)
|| jobId.equalsIgnoreCase(token); || jobId.equalsIgnoreCase(token);
} }
@@ -912,11 +861,8 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
} }
if (messages != null && (jobNumberContext == null || jobNumberContext.isBlank())) { if (messages != null && (jobNumberContext == null || jobNumberContext.isBlank())) {
jobNumberContext = messages.stream() jobNumberContext = messages.stream().map(Message::getJobNumber)
.map(Message::getJobNumber) .filter(value -> value != null && !value.isBlank()).findFirst().orElse(jobNumberContext);
.filter(value -> value != null && !value.isBlank())
.findFirst()
.orElse(jobNumberContext);
} }
if (jobIdContext == null || jobNumberContext == null || jobNumberContext.isBlank()) { if (jobIdContext == null || jobNumberContext == null || jobNumberContext.isBlank()) {
@@ -927,8 +873,7 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
if (jobOptional.isPresent()) { if (jobOptional.isPresent()) {
Job job = jobOptional.get(); Job job = jobOptional.get();
jobIdContext = Optional.ofNullable(job.getId()).orElse(jobIdContext); jobIdContext = Optional.ofNullable(job.getId()).orElse(jobIdContext);
jobNumberContext = Optional.ofNullable(job.getJobNumber()) jobNumberContext = Optional.ofNullable(job.getJobNumber()).filter(value -> !value.isBlank())
.filter(value -> !value.isBlank())
.orElse(jobNumberContext); .orElse(jobNumberContext);
} }
} }
@@ -955,11 +900,13 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
if (conversationId.startsWith("job-")) { if (conversationId.startsWith("job-")) {
if (messages != null && !messages.isEmpty()) { if (messages != null && !messages.isEmpty()) {
for (Message message : messages) { 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) { if (jobNumber != null) {
return "Auftrag " + jobNumber; 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) { if (jobId != null) {
return "Auftrag " + jobId; return "Auftrag " + jobId;
} }
@@ -975,8 +922,8 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
} }
/** /**
* Called when the view is attached to the UI * Called when the view is attached to the UI Registers listener for incoming
* Registers listener for incoming messages * messages
*/ */
@Override @Override
protected void onAttach(AttachEvent attachEvent) { 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 * Called when the view is detached from the UI Unregisters listener to prevent
* Unregisters listener to prevent memory leaks * memory leaks
*/ */
@Override @Override
protected void onDetach(DetachEvent detachEvent) { protected void onDetach(DetachEvent detachEvent) {
@@ -1006,8 +953,8 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
} }
/** /**
* Handle incoming message broadcast * Handle incoming message broadcast Filters messages to only show those
* Filters messages to only show those belonging to the current conversation * belonging to the current conversation
*/ */
private void handleIncomingMessage(UI ui, Message message) { private void handleIncomingMessage(UI ui, Message message) {
if (message == null || participantKey == null || conversationId == null) { if (message == null || participantKey == null || conversationId == null) {
@@ -1057,6 +1004,12 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
ensureScrollAnchor(); ensureScrollAnchor();
scrollToBottom(); 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"); log.info("Messages re-rendered with new message");
} }
} catch (Exception e) { } 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;
}
} }

View File

@@ -21,11 +21,13 @@ import de.assecutor.votianlt.model.Message;
import de.assecutor.votianlt.model.MessageContentType; import de.assecutor.votianlt.model.MessageContentType;
import de.assecutor.votianlt.model.MessageOrigin; import de.assecutor.votianlt.model.MessageOrigin;
import de.assecutor.votianlt.pages.service.AppUserService; import de.assecutor.votianlt.pages.service.AppUserService;
import de.assecutor.votianlt.service.MessageBroadcaster;
import de.assecutor.votianlt.service.MessageService; import de.assecutor.votianlt.service.MessageService;
import de.assecutor.votianlt.util.DateTimeFormatUtil;
import jakarta.annotation.security.RolesAllowed; import jakarta.annotation.security.RolesAllowed;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.bson.types.ObjectId;
import java.time.format.DateTimeFormatter;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Comparator; import java.util.Comparator;
@@ -47,15 +49,19 @@ public class MessagesView extends Main {
private final MessageService messageService; private final MessageService messageService;
private final AppUserService appUserService; private final AppUserService appUserService;
private final MessageBroadcaster messageBroadcaster;
private Grid<ClientMessageSummary> clientGrid; private Grid<ClientMessageSummary> clientGrid;
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm");
private final AtomicBoolean loading = new AtomicBoolean(false); private final AtomicBoolean loading = new AtomicBoolean(false);
private Registration pollRegistration; 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.messageService = messageService;
this.appUserService = appUserService; this.appUserService = appUserService;
this.messageBroadcaster = messageBroadcaster;
// Create main layout // Create main layout
VerticalLayout layout = new VerticalLayout(); VerticalLayout layout = new VerticalLayout();
@@ -120,9 +126,9 @@ public class MessagesView extends Main {
return new Span("0"); return new Span("0");
})).setHeader("Ungelesen").setWidth("100px").setFlexGrow(0); })).setHeader("Ungelesen").setWidth("100px").setFlexGrow(0);
grid.addColumn(summary -> grid.addColumn(summary -> summary.getLastMessageDate() != null
summary.getLastMessageDate() != null ? summary.getLastMessageDate().format(DATE_FORMATTER) : "-" ? DateTimeFormatUtil.formatDateTime(summary.getLastMessageDate())
).setHeader("Letzte Nachricht").setAutoWidth(true); : "-").setHeader("Letzte Nachricht").setAutoWidth(true);
grid.addColumn(new ComponentRenderer<>(summary -> { grid.addColumn(new ComponentRenderer<>(summary -> {
String preview = summary.getLastMessagePreview(); String preview = summary.getLastMessagePreview();
@@ -157,7 +163,7 @@ public class MessagesView extends Main {
} catch (Exception e) { } catch (Exception e) {
log.error("Error loading client summaries: {}", e.getMessage(), e); log.error("Error loading client summaries: {}", e.getMessage(), e);
Notification.show("Fehler beim Laden der Nachrichten", 3000, Notification.Position.MIDDLE) Notification.show("Fehler beim Laden der Nachrichten", 3000, Notification.Position.MIDDLE)
.addThemeVariants(NotificationVariant.LUMO_ERROR); .addThemeVariants(NotificationVariant.LUMO_ERROR);
} finally { } finally {
loading.set(false); loading.set(false);
} }
@@ -199,20 +205,17 @@ public class MessagesView extends Main {
continue; continue;
} }
conversation.sort(Comparator.comparing(Message::getCreatedAt, conversation.sort(Comparator
Comparator.nullsLast(LocalDateTime::compareTo)).reversed()); .comparing(Message::getCreatedAt, Comparator.nullsLast(LocalDateTime::compareTo)).reversed());
Message latest = conversation.stream() Message latest = conversation.stream().filter(msg -> msg.getCreatedAt() != null).findFirst()
.filter(msg -> msg.getCreatedAt() != null)
.findFirst()
.orElse(conversation.get(0)); .orElse(conversation.get(0));
LocalDateTime lastDate = latest.getCreatedAt(); LocalDateTime lastDate = latest.getCreatedAt();
String preview = resolvePreview(latest); String preview = resolvePreview(latest);
int totalMessages = conversation.size(); int totalMessages = conversation.size();
int unreadCount = (int) conversation.stream() int unreadCount = (int) conversation.stream()
.filter(msg -> msg.getOrigin() == MessageOrigin.CLIENT && !msg.isRead()) .filter(msg -> msg.getOrigin() == MessageOrigin.CLIENT && !msg.isRead()).count();
.count();
summary.setTotalMessages(summary.getTotalMessages() + totalMessages); summary.setTotalMessages(summary.getTotalMessages() + totalMessages);
summary.setUnreadCount(summary.getUnreadCount() + unreadCount); summary.setUnreadCount(summary.getUnreadCount() + unreadCount);
@@ -235,8 +238,9 @@ public class MessagesView extends Main {
} }
List<ClientMessageSummary> summaries = new ArrayList<>(summaryMap.values()); List<ClientMessageSummary> summaries = new ArrayList<>(summaryMap.values());
summaries.sort(Comparator.comparing(ClientMessageSummary::getLastMessageDate, summaries.sort(Comparator
Comparator.nullsLast(LocalDateTime::compareTo)).reversed()); .comparing(ClientMessageSummary::getLastMessageDate, Comparator.nullsLast(LocalDateTime::compareTo))
.reversed());
return summaries; return summaries;
} }
@@ -249,9 +253,7 @@ public class MessagesView extends Main {
return "[Bildnachricht]"; return "[Bildnachricht]";
} }
return Optional.ofNullable(message.getContent()) return Optional.ofNullable(message.getContent()).filter(content -> !content.isBlank()).orElse("(kein Inhalt)");
.filter(content -> !content.isBlank())
.orElse("(kein Inhalt)");
} }
private String resolveParticipantKey(Message message) { private String resolveParticipantKey(Message message) {
@@ -326,6 +328,12 @@ public class MessagesView extends Main {
UI ui = attachEvent.getUI(); UI ui = attachEvent.getUI();
ui.setPollInterval(POLL_INTERVAL_MS); ui.setPollInterval(POLL_INTERVAL_MS);
pollRegistration = ui.addPollListener(event -> loadClientSummaries()); pollRegistration = ui.addPollListener(event -> loadClientSummaries());
// Register broadcaster for real-time notifications
broadcasterRegistration = messageBroadcaster.register(message -> {
handleIncomingMessage(ui, message);
});
loadClientSummaries(); loadClientSummaries();
} }
@@ -336,6 +344,96 @@ public class MessagesView extends Main {
pollRegistration.remove(); pollRegistration.remove();
pollRegistration = null; pollRegistration = null;
} }
if (broadcasterRegistration != null) {
broadcasterRegistration.remove();
broadcasterRegistration = null;
}
detachEvent.getUI().setPollInterval(-1); 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;
}
} }

View File

@@ -23,13 +23,13 @@ import com.vaadin.flow.component.UI;
import com.vaadin.flow.server.StreamResource; import com.vaadin.flow.server.StreamResource;
import com.vaadin.flow.server.StreamRegistration; import com.vaadin.flow.server.StreamRegistration;
import de.assecutor.votianlt.service.SystemInvoiceService; 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.SystemInvoiceData;
import de.assecutor.votianlt.model.invoices.SystemInvoiceItem; import de.assecutor.votianlt.model.invoices.SystemInvoiceItem;
import java.io.ByteArrayInputStream; import java.io.ByteArrayInputStream;
import java.text.NumberFormat; import java.text.NumberFormat;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
@@ -50,7 +50,6 @@ public class MyInvoicesView extends Main {
private final Div emptyState = new Div(); private final Div emptyState = new Div();
private final SystemInvoiceService systemInvoiceService; 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); private static final NumberFormat CURRENCY_FMT = NumberFormat.getCurrencyInstance(Locale.GERMANY);
public MyInvoicesView(SystemInvoiceService systemInvoiceService) { public MyInvoicesView(SystemInvoiceService systemInvoiceService) {
@@ -76,9 +75,7 @@ public class MyInvoicesView extends Main {
private Component createTopCards() { private Component createTopCards() {
// Container mit responsiven Spalten // Container mit responsiven Spalten
Div container = new Div(); Div container = new Div();
container.getStyle() container.getStyle().set("display", "grid").set("grid-template-columns", "repeat(auto-fit, minmax(280px, 1fr))")
.set("display", "grid")
.set("grid-template-columns", "repeat(auto-fit, minmax(280px, 1fr))")
.set("gap", "var(--lumo-space-m)"); .set("gap", "var(--lumo-space-m)");
// Karte: Offene Rechnungen // Karte: Offene Rechnungen
@@ -143,7 +140,8 @@ public class MyInvoicesView extends Main {
grid.addColumn(new ComponentRenderer<>(row -> statusBadge(row.status()))).setHeader("Status").setAutoWidth(true) grid.addColumn(new ComponentRenderer<>(row -> statusBadge(row.status()))).setHeader("Status").setAutoWidth(true)
.setFlexGrow(0); .setFlexGrow(0);
grid.addColumn(MyInvoicesView::formatInvoiceNumber).setHeader("Rechnungsnummer").setAutoWidth(true); 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) grid.addColumn(row -> CURRENCY_FMT.format(row.amount())).setHeader("Betrag").setAutoWidth(true)
.setTextAlign(ColumnTextAlign.END).setFlexGrow(0); .setTextAlign(ColumnTextAlign.END).setFlexGrow(0);
grid.setAllRowsVisible(true); grid.setAllRowsVisible(true);
@@ -276,7 +274,7 @@ public class MyInvoicesView extends Main {
private byte[] generateSystemInvoicePdf(MyInvoiceRow row) throws Exception { private byte[] generateSystemInvoicePdf(MyInvoiceRow row) throws Exception {
SystemInvoiceData data = new SystemInvoiceData(); SystemInvoiceData data = new SystemInvoiceData();
data.setInvoiceNumber(row.invoiceNumber()); data.setInvoiceNumber(row.invoiceNumber());
data.setInvoiceDate(DATE_FMT.format(row.date())); data.setInvoiceDate(DateTimeFormatUtil.formatDate(row.date()));
data.setInvoiceText("Rechnung " + row.invoiceNumber()); data.setInvoiceText("Rechnung " + row.invoiceNumber());
// Minimal recipient information // Minimal recipient information

View File

@@ -61,8 +61,7 @@ public class PdfTestView extends VerticalLayout {
try { try {
byte[] pdfBytes = systemInvoiceService.generateInvoicePdfFromHtml(); byte[] pdfBytes = systemInvoiceService.generateInvoicePdfFromHtml();
StreamResource resource = new StreamResource("vlt-invoice.pdf", StreamResource resource = new StreamResource("vlt-invoice.pdf", () -> new ByteArrayInputStream(pdfBytes));
() -> new ByteArrayInputStream(pdfBytes));
resource.setContentType("application/pdf"); resource.setContentType("application/pdf");
getUI().ifPresent(ui -> { getUI().ifPresent(ui -> {
@@ -72,8 +71,8 @@ public class PdfTestView extends VerticalLayout {
Notification.show("PDF aus HTML erfolgreich generiert!", 3000, Notification.Position.BOTTOM_CENTER); Notification.show("PDF aus HTML erfolgreich generiert!", 3000, Notification.Position.BOTTOM_CENTER);
} catch (Exception ex) { } catch (Exception ex) {
Notification.show("Fehler beim Generieren des PDFs aus HTML: " + ex.getMessage(), Notification.show("Fehler beim Generieren des PDFs aus HTML: " + ex.getMessage(), 5000,
5000, Notification.Position.BOTTOM_CENTER); Notification.Position.BOTTOM_CENTER);
} }
} }
@@ -92,8 +91,8 @@ public class PdfTestView extends VerticalLayout {
Notification.show("Customer PDF erfolgreich generiert!", 3000, Notification.Position.BOTTOM_CENTER); Notification.show("Customer PDF erfolgreich generiert!", 3000, Notification.Position.BOTTOM_CENTER);
} catch (Exception ex) { } catch (Exception ex) {
Notification.show("Fehler beim Generieren des Customer PDFs: " + ex.getMessage(), Notification.show("Fehler beim Generieren des Customer PDFs: " + ex.getMessage(), 5000,
5000, Notification.Position.BOTTOM_CENTER); Notification.Position.BOTTOM_CENTER);
} }
} }
} }

View File

@@ -1,26 +1,43 @@
package de.assecutor.votianlt.pages.view; 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.Button;
import com.vaadin.flow.component.button.ButtonVariant;
import com.vaadin.flow.component.combobox.ComboBox; 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.grid.Grid;
import com.vaadin.flow.component.html.Anchor; import com.vaadin.flow.component.html.Anchor;
import com.vaadin.flow.component.html.H2; 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.HorizontalLayout;
import com.vaadin.flow.component.orderedlayout.VerticalLayout; import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.component.textfield.TextField; import com.vaadin.flow.component.textfield.TextField;
import com.vaadin.flow.server.StreamResource; import com.vaadin.flow.server.StreamResource;
import com.vaadin.flow.shared.Registration;
import com.vaadin.flow.router.PageTitle; import com.vaadin.flow.router.PageTitle;
import com.vaadin.flow.router.Route; import com.vaadin.flow.router.Route;
import de.assecutor.votianlt.model.Job; import de.assecutor.votianlt.model.Job;
import de.assecutor.votianlt.model.JobStatus; import de.assecutor.votianlt.model.JobStatus;
import de.assecutor.votianlt.repository.JobRepository; 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 jakarta.annotation.security.RolesAllowed;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import java.time.LocalDateTime;
@PageTitle("Aufträge") @PageTitle("Aufträge")
@Route(value = "jobs", layout = de.assecutor.votianlt.pages.base.ui.view.MainLayout.class) @Route(value = "jobs", layout = de.assecutor.votianlt.pages.base.ui.view.MainLayout.class)
@RolesAllowed({ "USER" }) @RolesAllowed({ "USER" })
@Slf4j
public class ShowJobsView extends VerticalLayout { public class ShowJobsView extends VerticalLayout {
private final DatePicker startDate = new DatePicker("Startdatum"); 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 TextField searchField = new TextField("Auftragsnummer suchen");
private final ComboBox<String> statusFilter = new ComboBox<>("Status"); private final ComboBox<String> statusFilter = new ComboBox<>("Status");
private final JobRepository jobRepository; private final JobRepository jobRepository;
private final JobHistoryService jobHistoryService;
private final SecurityService securityService;
private final JobBroadcaster jobBroadcaster;
private final Grid<Job> grid = new Grid<>(Job.class, false); private final Grid<Job> grid = new Grid<>(Job.class, false);
private Registration broadcasterRegistration;
@Autowired @Autowired
public ShowJobsView(JobRepository jobRepository) { public ShowJobsView(JobRepository jobRepository, JobHistoryService jobHistoryService,
SecurityService securityService, JobBroadcaster jobBroadcaster) {
this.jobRepository = jobRepository; this.jobRepository = jobRepository;
this.jobHistoryService = jobHistoryService;
this.securityService = securityService;
this.jobBroadcaster = jobBroadcaster;
setSizeFull(); setSizeFull();
setPadding(true); setPadding(true);
setSpacing(true); setSpacing(true);
@@ -78,11 +103,28 @@ public class ShowJobsView extends VerticalLayout {
endDate.addValueChangeListener(e -> loadData()); endDate.addValueChangeListener(e -> loadData());
// Configure grid columns: Auftraggeber, Auftragsnummer, Auftragsdatum, Zielort // 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::getJobNumber).setHeader("Auftragsnummer").setAutoWidth(true).setSortable(true);
grid.addColumn(Job::getCreatedAt).setHeader("Auftragsdatum").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); 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.setMultiSort(true);
grid.setSizeFull(); grid.setSizeFull();
@@ -103,6 +145,32 @@ public class ShowJobsView extends VerticalLayout {
loadData(); 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() { private void loadData() {
var start = startDate.getValue(); var start = startDate.getValue();
var end = endDate.getValue(); var end = endDate.getValue();
@@ -132,8 +200,10 @@ public class ShowJobsView extends VerticalLayout {
// wenn // wenn
// leer // leer
// Verwende die erweiterte Suchmethode // Verwende die erweiterte Suchmethode, gefiltert nach aktuellem Benutzer
var filteredJobs = jobRepository.findWithFilters(startDt, endDt, jobNumberPattern, statusList); String currentUserId = securityService.getCurrentUserId().toHexString();
var filteredJobs = jobRepository.findWithFiltersByCreatedBy(currentUserId, startDt, endDt, jobNumberPattern,
statusList);
grid.setItems(filteredJobs); grid.setItems(filteredJobs);
} }
@@ -193,4 +263,75 @@ public class ShowJobsView extends VerticalLayout {
} }
return customerSelection.trim(); 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);
}
} }

View File

@@ -330,8 +330,7 @@ public class StartView extends VerticalLayout implements BeforeEnterObserver {
// Versionsnummer // Versionsnummer
Span versionSpan = new Span("Version " + appVersion); Span versionSpan = new Span("Version " + appVersion);
versionSpan.getStyle().set("color", "var(--lumo-secondary-text-color)") versionSpan.getStyle().set("color", "var(--lumo-secondary-text-color)")
.set("font-size", "var(--lumo-font-size-s)") .set("font-size", "var(--lumo-font-size-s)").set("margin-top", "var(--lumo-space-l)");
.set("margin-top", "var(--lumo-space-l)");
footer.add(companyTitle, companyInfo, ctaText, slogan, versionSpan); footer.add(companyTitle, companyInfo, ctaText, slogan, versionSpan);
return footer; return footer;

View File

@@ -20,11 +20,11 @@ import com.vaadin.flow.component.textfield.TextField;
import com.vaadin.flow.router.PageTitle; import com.vaadin.flow.router.PageTitle;
import com.vaadin.flow.router.Route; import com.vaadin.flow.router.Route;
import de.assecutor.votianlt.ai.service.AiStatisticsService; import de.assecutor.votianlt.ai.service.AiStatisticsService;
import de.assecutor.votianlt.util.DateTimeFormatUtil;
import jakarta.annotation.security.RolesAllowed; import jakarta.annotation.security.RolesAllowed;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.UUID; import java.util.UUID;
@PageTitle("KI-Statistiken") @PageTitle("KI-Statistiken")
@@ -37,7 +37,6 @@ public class StatisticsView extends VerticalLayout {
private final AiStatisticsService aiStatisticsService; private final AiStatisticsService aiStatisticsService;
private final VerticalLayout chatContainer; private final VerticalLayout chatContainer;
private final TextField promptField; private final TextField promptField;
private final DateTimeFormatter timeFormatter = DateTimeFormatter.ofPattern("HH:mm");
public StatisticsView(AiStatisticsService aiStatisticsService) { public StatisticsView(AiStatisticsService aiStatisticsService) {
this.aiStatisticsService = aiStatisticsService; this.aiStatisticsService = aiStatisticsService;
@@ -69,7 +68,6 @@ public class StatisticsView extends VerticalLayout {
scroller.setScrollDirection(Scroller.ScrollDirection.VERTICAL); scroller.setScrollDirection(Scroller.ScrollDirection.VERTICAL);
scroller.getStyle().set("background", "var(--lumo-contrast-5pct)"); scroller.getStyle().set("background", "var(--lumo-contrast-5pct)");
add(scroller); add(scroller);
setFlexGrow(1, scroller); setFlexGrow(1, scroller);
@@ -83,9 +81,8 @@ public class StatisticsView extends VerticalLayout {
header.setWidthFull(); header.setWidthFull();
header.setPadding(true); header.setPadding(true);
header.setAlignItems(FlexComponent.Alignment.CENTER); header.setAlignItems(FlexComponent.Alignment.CENTER);
header.getStyle() header.getStyle().set("background", "var(--lumo-base-color)").set("border-bottom",
.set("background", "var(--lumo-base-color)") "1px solid var(--lumo-contrast-10pct)");
.set("border-bottom", "1px solid var(--lumo-contrast-10pct)");
Icon aiIcon = VaadinIcon.MAGIC.create(); Icon aiIcon = VaadinIcon.MAGIC.create();
aiIcon.getStyle().set("color", "var(--lumo-primary-color)"); 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)"); 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"); Span subtitle = new Span("Frage mich zu Aufträgen, Umsätzen und Statistiken");
subtitle.getStyle() subtitle.getStyle().set("color", "var(--lumo-secondary-text-color)").set("font-size", "var(--lumo-font-size-s)")
.set("color", "var(--lumo-secondary-text-color)")
.set("font-size", "var(--lumo-font-size-s)")
.set("margin-left", "var(--lumo-space-m)"); .set("margin-left", "var(--lumo-space-m)");
header.add(aiIcon, title, subtitle); header.add(aiIcon, title, subtitle);
@@ -109,9 +104,8 @@ public class StatisticsView extends VerticalLayout {
inputArea.setPadding(true); inputArea.setPadding(true);
inputArea.setSpacing(true); inputArea.setSpacing(true);
inputArea.setAlignItems(FlexComponent.Alignment.CENTER); inputArea.setAlignItems(FlexComponent.Alignment.CENTER);
inputArea.getStyle() inputArea.getStyle().set("background", "var(--lumo-base-color)").set("border-top",
.set("background", "var(--lumo-base-color)") "1px solid var(--lumo-contrast-10pct)");
.set("border-top", "1px solid var(--lumo-contrast-10pct)");
Button sendButton = new Button(VaadinIcon.PAPERPLANE.create()); Button sendButton = new Button(VaadinIcon.PAPERPLANE.create());
sendButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY); sendButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
@@ -119,9 +113,11 @@ public class StatisticsView extends VerticalLayout {
sendButton.getStyle().set("min-width", "50px"); sendButton.getStyle().set("min-width", "50px");
// Quick Action Buttons // 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 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); HorizontalLayout quickActions = new HorizontalLayout(jobCountBtn, revenueBtn, trendBtn);
quickActions.setSpacing(true); quickActions.setSpacing(true);
@@ -194,30 +190,22 @@ public class StatisticsView extends VerticalLayout {
Div messageDiv = new Div(); Div messageDiv = new Div();
messageDiv.addClassName("chat-message"); messageDiv.addClassName("chat-message");
messageDiv.addClassName("user-message"); messageDiv.addClassName("user-message");
messageDiv.getStyle() messageDiv.getStyle().set("display", "flex").set("justify-content", "flex-end").set("margin-bottom",
.set("display", "flex") "var(--lumo-space-m)");
.set("justify-content", "flex-end")
.set("margin-bottom", "var(--lumo-space-m)");
Div bubble = new Div(); Div bubble = new Div();
bubble.getStyle() bubble.getStyle().set("background", "var(--lumo-primary-color)")
.set("background", "var(--lumo-primary-color)")
.set("color", "var(--lumo-primary-contrast-color)") .set("color", "var(--lumo-primary-contrast-color)")
.set("padding", "var(--lumo-space-s) var(--lumo-space-m)") .set("padding", "var(--lumo-space-s) var(--lumo-space-m)")
.set("border-radius", "var(--lumo-border-radius-l)") .set("border-radius", "var(--lumo-border-radius-l)").set("max-width", "70%")
.set("max-width", "70%")
.set("word-wrap", "break-word"); .set("word-wrap", "break-word");
Paragraph text = new Paragraph(message); Paragraph text = new Paragraph(message);
text.getStyle().set("margin", "0"); text.getStyle().set("margin", "0");
Span time = new Span(LocalDateTime.now().format(timeFormatter)); Span time = new Span(DateTimeFormatUtil.formatTime(LocalDateTime.now()));
time.getStyle() time.getStyle().set("font-size", "var(--lumo-font-size-xs)").set("opacity", "0.7").set("display", "block")
.set("font-size", "var(--lumo-font-size-xs)") .set("text-align", "right").set("margin-top", "var(--lumo-space-xs)");
.set("opacity", "0.7")
.set("display", "block")
.set("text-align", "right")
.set("margin-top", "var(--lumo-space-xs)");
bubble.add(text, time); bubble.add(text, time);
messageDiv.add(bubble); messageDiv.add(bubble);
@@ -228,18 +216,13 @@ public class StatisticsView extends VerticalLayout {
Div messageDiv = new Div(); Div messageDiv = new Div();
messageDiv.addClassName("chat-message"); messageDiv.addClassName("chat-message");
messageDiv.addClassName("ai-message"); messageDiv.addClassName("ai-message");
messageDiv.getStyle() messageDiv.getStyle().set("display", "flex").set("justify-content", "flex-start").set("margin-bottom",
.set("display", "flex") "var(--lumo-space-m)");
.set("justify-content", "flex-start")
.set("margin-bottom", "var(--lumo-space-m)");
Div bubble = new Div(); Div bubble = new Div();
bubble.getStyle() bubble.getStyle().set("background", "var(--lumo-base-color)")
.set("background", "var(--lumo-base-color)") .set("border", "1px solid var(--lumo-contrast-10pct)").set("padding", "var(--lumo-space-m)")
.set("border", "1px solid var(--lumo-contrast-10pct)") .set("border-radius", "var(--lumo-border-radius-l)").set("max-width", "85%")
.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)"); .set("box-shadow", "var(--lumo-box-shadow-xs)");
// AI Icon // AI Icon
@@ -252,9 +235,7 @@ public class StatisticsView extends VerticalLayout {
aiIcon.getStyle().set("color", "var(--lumo-primary-color)"); aiIcon.getStyle().set("color", "var(--lumo-primary-color)");
Span aiLabel = new Span("KI-Assistent"); Span aiLabel = new Span("KI-Assistent");
aiLabel.getStyle() aiLabel.getStyle().set("font-weight", "bold").set("font-size", "var(--lumo-font-size-s)");
.set("font-weight", "bold")
.set("font-size", "var(--lumo-font-size-s)");
header.add(aiIcon, aiLabel); header.add(aiIcon, aiLabel);
bubble.add(header); bubble.add(header);
@@ -273,20 +254,15 @@ public class StatisticsView extends VerticalLayout {
if (response.chartData() != null && !response.chartData().isEmpty()) { if (response.chartData() != null && !response.chartData().isEmpty()) {
Div chartContainer = createChart(response.chartType(), response.chartData()); Div chartContainer = createChart(response.chartType(), response.chartData());
if (chartContainer != null) { if (chartContainer != null) {
chartContainer.getStyle() chartContainer.getStyle().set("margin-top", "var(--lumo-space-m)").set("height", "300px");
.set("margin-top", "var(--lumo-space-m)")
.set("height", "300px");
bubble.add(chartContainer); bubble.add(chartContainer);
} }
} }
// Timestamp // Timestamp
Span time = new Span(LocalDateTime.now().format(timeFormatter)); Span time = new Span(DateTimeFormatUtil.formatTime(LocalDateTime.now()));
time.getStyle() time.getStyle().set("font-size", "var(--lumo-font-size-xs)").set("color", "var(--lumo-secondary-text-color)")
.set("font-size", "var(--lumo-font-size-xs)") .set("display", "block").set("margin-top", "var(--lumo-space-s)");
.set("color", "var(--lumo-secondary-text-color)")
.set("display", "block")
.set("margin-top", "var(--lumo-space-s)");
bubble.add(time); bubble.add(time);
messageDiv.add(bubble); messageDiv.add(bubble);
@@ -295,18 +271,14 @@ public class StatisticsView extends VerticalLayout {
private void addErrorMessage(String message) { private void addErrorMessage(String message) {
Div messageDiv = new Div(); Div messageDiv = new Div();
messageDiv.getStyle() messageDiv.getStyle().set("display", "flex").set("justify-content", "flex-start").set("margin-bottom",
.set("display", "flex") "var(--lumo-space-m)");
.set("justify-content", "flex-start")
.set("margin-bottom", "var(--lumo-space-m)");
Div bubble = new Div(); Div bubble = new Div();
bubble.getStyle() bubble.getStyle().set("background", "var(--lumo-error-color-10pct)")
.set("background", "var(--lumo-error-color-10pct)")
.set("border", "1px solid var(--lumo-error-color)") .set("border", "1px solid var(--lumo-error-color)")
.set("padding", "var(--lumo-space-s) var(--lumo-space-m)") .set("padding", "var(--lumo-space-s) var(--lumo-space-m)")
.set("border-radius", "var(--lumo-border-radius-l)") .set("border-radius", "var(--lumo-border-radius-l)").set("max-width", "70%");
.set("max-width", "70%");
Icon errorIcon = VaadinIcon.EXCLAMATION_CIRCLE.create(); Icon errorIcon = VaadinIcon.EXCLAMATION_CIRCLE.create();
errorIcon.setSize("16px"); errorIcon.setSize("16px");
@@ -326,22 +298,17 @@ public class StatisticsView extends VerticalLayout {
private Div createLoadingMessage() { private Div createLoadingMessage() {
Div messageDiv = new Div(); Div messageDiv = new Div();
messageDiv.getStyle() messageDiv.getStyle().set("display", "flex").set("justify-content", "flex-start").set("margin-bottom",
.set("display", "flex") "var(--lumo-space-m)");
.set("justify-content", "flex-start")
.set("margin-bottom", "var(--lumo-space-m)");
Div bubble = new Div(); Div bubble = new Div();
bubble.getStyle() bubble.getStyle().set("background", "var(--lumo-base-color)")
.set("background", "var(--lumo-base-color)")
.set("border", "1px solid var(--lumo-contrast-10pct)") .set("border", "1px solid var(--lumo-contrast-10pct)")
.set("padding", "var(--lumo-space-s) var(--lumo-space-m)") .set("padding", "var(--lumo-space-s) var(--lumo-space-m)")
.set("border-radius", "var(--lumo-border-radius-l)"); .set("border-radius", "var(--lumo-border-radius-l)");
Span dots = new Span("Analysiere..."); Span dots = new Span("Analysiere...");
dots.getStyle() dots.getStyle().set("color", "var(--lumo-secondary-text-color)").set("font-style", "italic");
.set("color", "var(--lumo-secondary-text-color)")
.set("font-style", "italic");
bubble.add(dots); bubble.add(dots);
messageDiv.add(bubble); messageDiv.add(bubble);
@@ -356,10 +323,8 @@ public class StatisticsView extends VerticalLayout {
String canvasId = "chart-" + UUID.randomUUID().toString().substring(0, 8); String canvasId = "chart-" + UUID.randomUUID().toString().substring(0, 8);
Div chartContainer = new Div(); Div chartContainer = new Div();
chartContainer.addClassName("chart-wrapper"); chartContainer.addClassName("chart-wrapper");
chartContainer.getStyle() chartContainer.getStyle().set("background", "var(--lumo-contrast-5pct)")
.set("background", "var(--lumo-contrast-5pct)") .set("border-radius", "var(--lumo-border-radius-m)").set("padding", "var(--lumo-space-s)");
.set("border-radius", "var(--lumo-border-radius-m)")
.set("padding", "var(--lumo-space-s)");
chartContainer.getElement().setProperty("innerHTML", chartContainer.getElement().setProperty("innerHTML",
"<canvas id='" + canvasId + "' style='width: 100%; height: 100%;'></canvas>"); "<canvas id='" + canvasId + "' style='width: 100%; height: 100%;'></canvas>");
@@ -410,277 +375,274 @@ public class StatisticsView extends VerticalLayout {
private String getChartOptions(String chartType) { private String getChartOptions(String chartType) {
// Gradient und moderne Farben für verschiedene Chart-Typen // Gradient und moderne Farben für verschiedene Chart-Typen
return switch (chartType) { return switch (chartType) {
case "line" -> """ case "line" -> """
{ {
responsive: true, responsive: true,
maintainAspectRatio: false, maintainAspectRatio: false,
interaction: { interaction: {
intersect: false, intersect: false,
mode: 'index' mode: 'index'
}, },
plugins: { plugins: {
legend: { legend: {
position: 'bottom', position: 'bottom',
labels: { labels: {
usePointStyle: true, usePointStyle: true,
padding: 20 padding: 20
}
},
tooltip: {
backgroundColor: 'rgba(0,0,0,0.8)',
titleFont: { size: 14, weight: 'bold' },
bodyFont: { size: 13 },
padding: 12,
cornerRadius: 8
} }
}, },
scales: { tooltip: {
y: { backgroundColor: 'rgba(0,0,0,0.8)',
beginAtZero: true, titleFont: { size: 14, weight: 'bold' },
grid: { bodyFont: { size: 13 },
color: 'rgba(0,0,0,0.05)' padding: 12,
} cornerRadius: 8
},
x: {
grid: {
display: false
}
}
},
elements: {
line: {
tension: 0.4,
borderWidth: 3
},
point: {
radius: 4,
hoverRadius: 6,
hitRadius: 10
}
},
animation: {
duration: 1000,
easing: 'easeOutQuart'
} }
} },
"""; scales: {
case "bar" -> """ y: {
{ beginAtZero: true,
responsive: true, grid: {
maintainAspectRatio: false, color: 'rgba(0,0,0,0.05)'
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: { x: {
y: { grid: {
beginAtZero: true, display: false
grid: {
color: 'rgba(0,0,0,0.05)'
}
},
x: {
grid: {
display: false
}
} }
},
elements: {
bar: {
borderRadius: 6,
borderSkipped: false
}
},
animation: {
duration: 800,
easing: 'easeOutQuart'
} }
} },
"""; elements: {
case "doughnut" -> """ line: {
{ tension: 0.4,
responsive: true, borderWidth: 3
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%', point: {
animation: { radius: 4,
animateRotate: true, hoverRadius: 6,
animateScale: true, hitRadius: 10
duration: 1000,
easing: 'easeOutQuart'
} }
},
animation: {
duration: 1000,
easing: 'easeOutQuart'
} }
"""; }
case "pie" -> """ """;
{ case "bar" -> """
responsive: true, {
maintainAspectRatio: false, responsive: true,
plugins: { maintainAspectRatio: false,
legend: { plugins: {
position: 'right', legend: {
labels: { position: 'bottom',
usePointStyle: true, labels: {
padding: 15, usePointStyle: true,
font: { size: 12 } padding: 20
}
},
tooltip: {
backgroundColor: 'rgba(0,0,0,0.8)',
titleFont: { size: 14, weight: 'bold' },
bodyFont: { size: 13 },
padding: 12,
cornerRadius: 8
} }
}, },
animation: { tooltip: {
animateRotate: true, backgroundColor: 'rgba(0,0,0,0.8)',
animateScale: true, titleFont: { size: 14, weight: 'bold' },
duration: 1000, bodyFont: { size: 13 },
easing: 'easeOutQuart' padding: 12,
cornerRadius: 8
} }
} },
"""; scales: {
case "radar" -> """ y: {
{ beginAtZero: true,
responsive: true, grid: {
maintainAspectRatio: false, color: 'rgba(0,0,0,0.05)'
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: { x: {
r: { grid: {
beginAtZero: true, display: false
grid: {
color: 'rgba(0,0,0,0.1)'
},
pointLabels: {
font: { size: 12 }
}
} }
},
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" -> """ """;
{ case "doughnut" -> """
responsive: true, {
maintainAspectRatio: false, responsive: true,
plugins: { maintainAspectRatio: false,
legend: { plugins: {
position: 'right', legend: {
labels: { position: 'right',
usePointStyle: true, labels: {
padding: 15, usePointStyle: true,
font: { size: 12 } 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: { pointLabels: {
backgroundColor: 'rgba(0,0,0,0.8)', font: { size: 12 }
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'
} }
},
elements: {
line: {
borderWidth: 2
},
point: {
radius: 4,
hoverRadius: 6
}
},
animation: {
duration: 1000,
easing: 'easeOutQuart'
} }
"""; }
default -> """ """;
{ case "polarArea" -> """
responsive: true, {
maintainAspectRatio: false, responsive: true,
plugins: { maintainAspectRatio: false,
legend: { plugins: {
position: 'bottom' legend: {
position: 'right',
labels: {
usePointStyle: true,
padding: 15,
font: { size: 12 }
} }
}, },
animation: { tooltip: {
duration: 800 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) { private String formatMarkdown(String text) {
if (text == null) return ""; if (text == null)
return "";
// Einfache Markdown-Formatierung // Einfache Markdown-Formatierung
return text return text.replace("\n", "<br>").replaceAll("\\*\\*(.+?)\\*\\*", "<strong>$1</strong>")
.replace("\n", "<br>") .replaceAll("\\*(.+?)\\*", "<em>$1</em>").replaceAll("`(.+?)`", "<code>$1</code>");
.replaceAll("\\*\\*(.+?)\\*\\*", "<strong>$1</strong>")
.replaceAll("\\*(.+?)\\*", "<em>$1</em>")
.replaceAll("`(.+?)`", "<code>$1</code>");
} }
private void scrollToBottom() { private void scrollToBottom() {
chatContainer.getElement().executeJs( chatContainer.getElement().executeJs("this.parentElement.scrollTop = this.parentElement.scrollHeight");
"this.parentElement.scrollTop = this.parentElement.scrollHeight");
} }
@Override @Override

View File

@@ -21,12 +21,12 @@ import de.assecutor.votianlt.model.MessageOrigin;
import de.assecutor.votianlt.model.MessageType; import de.assecutor.votianlt.model.MessageType;
import de.assecutor.votianlt.pages.service.AppUserService; import de.assecutor.votianlt.pages.service.AppUserService;
import de.assecutor.votianlt.service.MessageService; import de.assecutor.votianlt.service.MessageService;
import de.assecutor.votianlt.util.DateTimeFormatUtil;
import jakarta.annotation.security.RolesAllowed; import jakarta.annotation.security.RolesAllowed;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.bson.types.ObjectId; import org.bson.types.ObjectId;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Comparator; import java.util.Comparator;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
@@ -46,7 +46,6 @@ public class UserMessagesView extends Main implements HasUrlParameter<String> {
private String participantKey; private String participantKey;
private VerticalLayout contentLayout; private VerticalLayout contentLayout;
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm");
public UserMessagesView(AppUserService appUserService, MessageService messageService) { public UserMessagesView(AppUserService appUserService, MessageService messageService) {
this.appUserService = appUserService; this.appUserService = appUserService;
@@ -79,15 +78,15 @@ public class UserMessagesView extends Main implements HasUrlParameter<String> {
log.debug("Could not resolve AppUser for participant key {}: {}", participantKey, e.getMessage()); log.debug("Could not resolve AppUser for participant key {}: {}", participantKey, e.getMessage());
} }
String clientName = client != null ? String clientName = client != null ? client.getVorname() + " " + client.getNachname()
client.getVorname() + " " + client.getNachname() : Optional.ofNullable(participantKey).orElse("Unbekannter Teilnehmer"); : Optional.ofNullable(participantKey).orElse("Unbekannter Teilnehmer");
HorizontalLayout headerLayout = createHeaderLayout(clientName); HorizontalLayout headerLayout = createHeaderLayout(clientName);
contentLayout.add(headerLayout); contentLayout.add(headerLayout);
List<Message> conversation = messageService.getMessagesForAppUserAscending(participantKey); List<Message> conversation = messageService.getMessagesForAppUserAscending(participantKey);
Map<MessageType, List<Message>> messagesByType = conversation.stream() Map<MessageType, List<Message>> messagesByType = conversation.stream().collect(Collectors
.collect(Collectors.groupingBy(message -> Optional.ofNullable(message.getMessageType()).orElse(MessageType.GENERAL))); .groupingBy(message -> Optional.ofNullable(message.getMessageType()).orElse(MessageType.GENERAL)));
VerticalLayout generalSection = createGeneralMessagesSection(messagesByType.get(MessageType.GENERAL)); VerticalLayout generalSection = createGeneralMessagesSection(messagesByType.get(MessageType.GENERAL));
@@ -127,24 +126,18 @@ public class UserMessagesView extends Main implements HasUrlParameter<String> {
sortedMessages.addAll(generalMessages); 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); Message latest = sortedMessages.isEmpty() ? null : sortedMessages.get(sortedMessages.size() - 1);
int unreadCount = (int) sortedMessages.stream() int unreadCount = (int) sortedMessages.stream()
.filter(message -> message.getOrigin() == MessageOrigin.CLIENT && !message.isRead()) .filter(message -> message.getOrigin() == MessageOrigin.CLIENT && !message.isRead()).count();
.count();
int messageCount = sortedMessages.size(); int messageCount = sortedMessages.size();
LocalDateTime lastMessageTime = latest != null ? latest.getCreatedAt() : null; LocalDateTime lastMessageTime = latest != null ? latest.getCreatedAt() : null;
String preview = resolvePreview(latest); String preview = resolvePreview(latest);
section.add(createMessageCard( section.add(createMessageCard("Allgemeine Unterhaltung", preview, lastMessageTime, messageCount, unreadCount,
"Allgemeine Unterhaltung", "general"));
preview,
lastMessageTime,
messageCount,
unreadCount,
"general"
));
return section; return section;
} }
@@ -173,18 +166,11 @@ public class UserMessagesView extends Main implements HasUrlParameter<String> {
messages.sort(Comparator.comparing(Message::getCreatedAt, Comparator.nullsLast(LocalDateTime::compareTo))); messages.sort(Comparator.comparing(Message::getCreatedAt, Comparator.nullsLast(LocalDateTime::compareTo)));
Message latest = messages.get(messages.size() - 1); Message latest = messages.get(messages.size() - 1);
int unreadCount = (int) messages.stream() int unreadCount = (int) messages.stream()
.filter(message -> message.getOrigin() == MessageOrigin.CLIENT && !message.isRead()) .filter(message -> message.getOrigin() == MessageOrigin.CLIENT && !message.isRead()).count();
.count();
String conversationTitle = "Auftrag " + jobKey; String conversationTitle = "Auftrag " + jobKey;
section.add(createMessageCard( section.add(createMessageCard(conversationTitle, resolvePreview(latest), latest.getCreatedAt(),
conversationTitle, messages.size(), unreadCount, "job-" + sanitizeConversationId(jobKey)));
resolvePreview(latest),
latest.getCreatedAt(),
messages.size(),
unreadCount,
"job-" + sanitizeConversationId(jobKey)
));
}); });
return section; return section;
@@ -199,14 +185,11 @@ public class UserMessagesView extends Main implements HasUrlParameter<String> {
return "[Bildnachricht]"; return "[Bildnachricht]";
} }
return Optional.ofNullable(message.getContent()) return Optional.ofNullable(message.getContent()).map(String::trim).orElse("");
.map(String::trim)
.orElse("");
} }
private Div createMessageCard(String conversationTitle, String lastMessagePreview, private Div createMessageCard(String conversationTitle, String lastMessagePreview, LocalDateTime lastMessageTime,
LocalDateTime lastMessageTime, int messageCount, int messageCount, int unreadCount, String conversationId) {
int unreadCount, String conversationId) {
Div card = new Div(); Div card = new Div();
card.setWidthFull(); card.setWidthFull();
card.getStyle().set("padding", "15px"); card.getStyle().set("padding", "15px");
@@ -251,7 +234,8 @@ public class UserMessagesView extends Main implements HasUrlParameter<String> {
titleRow.expand(titleSpan); titleRow.expand(titleSpan);
// Preview text // 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("color", "#666666");
preview.getStyle().set("font-size", "14px"); preview.getStyle().set("font-size", "14px");
@@ -259,7 +243,7 @@ public class UserMessagesView extends Main implements HasUrlParameter<String> {
HorizontalLayout metaRow = new HorizontalLayout(); HorizontalLayout metaRow = new HorizontalLayout();
metaRow.setWidthFull(); 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("color", "#999999");
timeSpan.getStyle().set("font-size", "12px"); timeSpan.getStyle().set("font-size", "12px");
@@ -278,7 +262,8 @@ public class UserMessagesView extends Main implements HasUrlParameter<String> {
card.add(cardContent); card.add(cardContent);
// Click listener to navigate to message details // 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; return card;
} }

View File

@@ -92,10 +92,10 @@ public interface JobRepository extends MongoRepository<Job, ObjectId> {
long countByIsDraftTrue(); long countByIsDraftTrue();
/** /**
* Findet alle nicht abgeschlossenen Aufträge, die einem bestimmten App-Nutzer zugewiesen sind. * Findet alle nicht abgeschlossenen Aufträge, die einem bestimmten App-Nutzer
* Excludes jobs with status COMPLETED or CANCELLED. * zugewiesen sind. Excludes jobs with status COMPLETED or CANCELLED. Uses
* Uses explicit query because @Field("app_user") annotation is not always * explicit query because @Field("app_user") annotation is not always respected
* respected by Spring Data MongoDB query derivation. * by Spring Data MongoDB query derivation.
*/ */
@Query("{'app_user': ?0, 'status': {'$nin': ['COMPLETED', 'CANCELLED']}}") @Query("{'app_user': ?0, 'status': {'$nin': ['COMPLETED', 'CANCELLED']}}")
List<Job> findByAppUser(String appUser); List<Job> findByAppUser(String appUser);
@@ -109,8 +109,17 @@ public interface JobRepository extends MongoRepository<Job, ObjectId> {
/** /**
* Erweiterte Suche: Zeitraum, Auftragsnummer und Status kombiniert * Erweiterte Suche: Zeitraum, Auftragsnummer und Status kombiniert
*/ */
@Query("{'createdAt': {'$gte': ?0, '$lte': ?1}, " @Query("{'createdAt': {'$gte': ?0, '$lte': ?1}, " + "'jobNumber': {'$regex': ?2, '$options': 'i'}, "
+ "'jobNumber': {'$regex': ?2, '$options': 'i'}, " + "'status': {'$in': ?3}}") + "'status': {'$in': ?3}}")
List<Job> findWithFilters(LocalDateTime startDate, LocalDateTime endDate, String jobNumberPattern, List<Job> findWithFilters(LocalDateTime startDate, LocalDateTime endDate, String jobNumberPattern,
List<JobStatus> statusList); List<JobStatus> 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<Job> findWithFiltersByCreatedBy(String createdBy, LocalDateTime startDate, LocalDateTime endDate,
String jobNumberPattern, List<JobStatus> statusList);
} }

View File

@@ -13,12 +13,14 @@ import java.util.List;
public interface MessageRepository extends MongoRepository<Message, ObjectId> { public interface MessageRepository extends MongoRepository<Message, ObjectId> {
/** /**
* 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<Message> findByReceiverOrderByCreatedAtAsc(String receiver); List<Message> 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<Message> findByReceiverOrderByCreatedAtDesc(String receiver); List<Message> findByReceiverOrderByCreatedAtDesc(String receiver);

View File

@@ -27,7 +27,8 @@ public interface PendingDeliveryRepository extends MongoRepository<PendingDelive
List<PendingDelivery> findByStatus(DeliveryStatus status); List<PendingDelivery> 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<PendingDelivery> findByStatusAndNextRetryAtBefore(DeliveryStatus status, LocalDateTime dateTime); List<PendingDelivery> findByStatusAndNextRetryAtBefore(DeliveryStatus status, LocalDateTime dateTime);
@@ -66,4 +67,3 @@ public interface PendingDeliveryRepository extends MongoRepository<PendingDelive
*/ */
void deleteByCreatedAtBefore(LocalDateTime dateTime); void deleteByCreatedAtBefore(LocalDateTime dateTime);
} }

View File

@@ -17,8 +17,8 @@ import java.util.Set;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
/** /**
* Service for managing client connections via Ping/Pong mechanism. * Service for managing client connections via Ping/Pong mechanism. Tracks
* Tracks connected clients and periodically checks their connectivity. * connected clients and periodically checks their connectivity.
*/ */
@Service @Service
@Slf4j @Slf4j
@@ -27,14 +27,8 @@ public class ClientConnectionService {
/** /**
* Represents the connection state of a client. * Represents the connection state of a client.
*/ */
public record ClientState( public record ClientState(String clientId, String userId, boolean connected, Instant lastPingSent,
String clientId, Instant lastPongReceived, Instant connectedAt) {
String userId,
boolean connected,
Instant lastPingSent,
Instant lastPongReceived,
Instant connectedAt
) {
public ClientState withPingSent(Instant pingSent) { public ClientState withPingSent(Instant pingSent) {
return new ClientState(clientId, userId, connected, pingSent, lastPongReceived, connectedAt); return new ClientState(clientId, userId, connected, pingSent, lastPongReceived, connectedAt);
} }
@@ -60,7 +54,7 @@ public class ClientConnectionService {
private int pingTimeoutSeconds; private int pingTimeoutSeconds;
public ClientConnectionService(PluginManager pluginManager, ObjectMapper objectMapper, public ClientConnectionService(PluginManager pluginManager, ObjectMapper objectMapper,
@Lazy MessageDeliveryService messageDeliveryService) { @Lazy MessageDeliveryService messageDeliveryService) {
this.pluginManager = pluginManager; this.pluginManager = pluginManager;
this.objectMapper = objectMapper; this.objectMapper = objectMapper;
this.messageDeliveryService = messageDeliveryService; this.messageDeliveryService = messageDeliveryService;
@@ -69,8 +63,10 @@ public class ClientConnectionService {
/** /**
* Registers a client as connected after successful login. * Registers a client as connected after successful login.
* *
* @param clientId The unique client identifier * @param clientId
* @param userId The user ID associated with this client * The unique client identifier
* @param userId
* The user ID associated with this client
*/ */
public void registerClient(String clientId, String userId) { public void registerClient(String clientId, String userId) {
if (clientId == null || clientId.isBlank()) { if (clientId == null || clientId.isBlank()) {
@@ -84,8 +80,8 @@ public class ClientConnectionService {
Instant now = Instant.now(); Instant now = Instant.now();
ClientState state = new ClientState(clientId, userId, true, null, now, now); ClientState state = new ClientState(clientId, userId, true, null, now, now);
connectedClients.put(clientId, state); connectedClients.put(clientId, state);
log.info("[ClientConnectionService] Client registered: clientId={}, userId={}, totalClients={}", log.info("[ClientConnectionService] Client registered: clientId={}, userId={}, totalClients={}", clientId,
clientId, userId, connectedClients.size()); userId, connectedClients.size());
// If client was previously disconnected, retry pending messages // If client was previously disconnected, retry pending messages
if (wasDisconnected) { if (wasDisconnected) {
@@ -97,7 +93,8 @@ public class ClientConnectionService {
/** /**
* Unregisters a client (e.g., on explicit logout). * Unregisters a client (e.g., on explicit logout).
* *
* @param clientId The client identifier to unregister * @param clientId
* The client identifier to unregister
*/ */
public void unregisterClient(String clientId) { public void unregisterClient(String clientId) {
ClientState removed = connectedClients.remove(clientId); ClientState removed = connectedClients.remove(clientId);
@@ -107,10 +104,11 @@ public class ClientConnectionService {
} }
/** /**
* Handles a pong response from a client. * Handles a pong response from a client. Searches by both clientId and userId
* Searches by both clientId and userId since pong is sent to /server/{userId}/pong. * since pong is sent to /server/{userId}/pong.
* *
* @param id The client or user identifier that sent the pong * @param id
* The client or user identifier that sent the pong
*/ */
public void handlePong(String id) { public void handlePong(String id) {
if (id == null || id.isBlank()) { if (id == null || id.isBlank()) {
@@ -149,10 +147,11 @@ public class ClientConnectionService {
} }
/** /**
* Checks if a client is currently connected. * Checks if a client is currently connected. Searches by both clientId and
* Searches by both clientId and userId. * userId.
* *
* @param id The client or user identifier * @param id
* The client or user identifier
* @return true if the client is connected * @return true if the client is connected
*/ */
public boolean isClientConnected(String id) { public boolean isClientConnected(String id) {
@@ -165,8 +164,7 @@ public class ClientConnectionService {
return true; return true;
} }
// Then search by userId // Then search by userId
return connectedClients.values().stream() return connectedClients.values().stream().anyMatch(s -> s.connected() && id.equals(s.userId()));
.anyMatch(s -> s.connected() && id.equals(s.userId()));
} }
/** /**
@@ -175,16 +173,15 @@ public class ClientConnectionService {
* @return Set of connected client IDs * @return Set of connected client IDs
*/ */
public Set<String> getConnectedClientIds() { public Set<String> getConnectedClientIds() {
return connectedClients.entrySet().stream() return connectedClients.entrySet().stream().filter(e -> e.getValue().connected()).map(Map.Entry::getKey)
.filter(e -> e.getValue().connected())
.map(Map.Entry::getKey)
.collect(java.util.stream.Collectors.toSet()); .collect(java.util.stream.Collectors.toSet());
} }
/** /**
* Gets the connection state for a specific client. * 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 * @return ClientState or null if not found
*/ */
public ClientState getClientState(String clientId) { public ClientState getClientState(String clientId) {
@@ -192,8 +189,8 @@ public class ClientConnectionService {
} }
/** /**
* Scheduled task to send pings to all connected clients. * Scheduled task to send pings to all connected clients. Runs based on the
* Runs based on the configured interval (app.client.ping.interval-seconds). * configured interval (app.client.ping.interval-seconds).
*/ */
@Scheduled(fixedRateString = "${app.client.ping.interval-seconds:15}000") @Scheduled(fixedRateString = "${app.client.ping.interval-seconds:15}000")
public void sendPingsToAllClients() { public void sendPingsToAllClients() {
@@ -228,8 +225,8 @@ public class ClientConnectionService {
// Client did not respond in time - mark as disconnected // Client did not respond in time - mark as disconnected
ClientState disconnectedState = state.withConnected(false); ClientState disconnectedState = state.withConnected(false);
connectedClients.put(clientId, disconnectedState); connectedClients.put(clientId, disconnectedState);
log.warn("Client timed out, marking as disconnected: clientId={}, userId={}", log.warn("Client timed out, marking as disconnected: clientId={}, userId={}", clientId,
clientId, state.userId()); state.userId());
continue; continue;
} }
} }
@@ -246,22 +243,17 @@ public class ClientConnectionService {
/** /**
* Sends a ping message to a specific user. * 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) { private void sendPing(String userId) {
try { try {
Map<String, Object> pingPayload = Map.of( Map<String, Object> pingPayload = Map.of("type", "ping", "timestamp", Instant.now().toEpochMilli());
"type", "ping",
"timestamp", Instant.now().toEpochMilli()
);
String json = objectMapper.writeValueAsString(pingPayload); String json = objectMapper.writeValueAsString(pingPayload);
byte[] payload = json.getBytes(StandardCharsets.UTF_8); byte[] payload = json.getBytes(StandardCharsets.UTF_8);
SendOptions options = SendOptions.builder() SendOptions options = SendOptions.builder().qos(1).retained(false).build();
.qos(1)
.retained(false)
.build();
pluginManager.sendToClient(userId, "ping", payload, options); pluginManager.sendToClient(userId, "ping", payload, options);
@@ -276,9 +268,7 @@ public class ClientConnectionService {
* @return Number of connected clients * @return Number of connected clients
*/ */
public int getConnectedClientCount() { public int getConnectedClientCount() {
return (int) connectedClients.values().stream() return (int) connectedClients.values().stream().filter(ClientState::connected).count();
.filter(ClientState::connected)
.count();
} }
/** /**

View File

@@ -85,15 +85,12 @@ public class CustomerInvoiceService {
List<CustomerInvoiceItem> items = new ArrayList<>(); List<CustomerInvoiceItem> items = new ArrayList<>();
BigDecimal vatRate = new BigDecimal("0.19"); // 19% MwSt. BigDecimal vatRate = new BigDecimal("0.19"); // 19% MwSt.
CustomerInvoiceItem item1 = new CustomerInvoiceItem( CustomerInvoiceItem item1 = new CustomerInvoiceItem(new BigDecimal("2"), "Std.", "Transportdienstleistung",
new BigDecimal("2"), "Std.", "Transportdienstleistung", new BigDecimal("85.00"), vatRate);
new BigDecimal("85.00"), vatRate); CustomerInvoiceItem item2 = new CustomerInvoiceItem(new BigDecimal("1"), "Stk.", "Logistikkoordination",
CustomerInvoiceItem item2 = new CustomerInvoiceItem( new BigDecimal("120.00"), vatRate);
new BigDecimal("1"), "Stk.", "Logistikkoordination", CustomerInvoiceItem item3 = new CustomerInvoiceItem(new BigDecimal("50"), "km", "Kilometergebühr",
new BigDecimal("120.00"), vatRate); new BigDecimal("0.60"), vatRate);
CustomerInvoiceItem item3 = new CustomerInvoiceItem(
new BigDecimal("50"), "km", "Kilometergebühr",
new BigDecimal("0.60"), vatRate);
items.add(item1); items.add(item1);
items.add(item2); items.add(item2);
@@ -101,9 +98,8 @@ public class CustomerInvoiceService {
invoiceData.setItems(items); invoiceData.setItems(items);
// Beträge berechnen // Beträge berechnen
BigDecimal netAmount = items.stream() BigDecimal netAmount = items.stream().map(CustomerInvoiceItem::getNetTotal).reduce(BigDecimal.ZERO,
.map(CustomerInvoiceItem::getNetTotal) BigDecimal::add);
.reduce(BigDecimal.ZERO, BigDecimal::add);
BigDecimal vatAmount = netAmount.multiply(vatRate); BigDecimal vatAmount = netAmount.multiply(vatRate);
BigDecimal totalAmount = netAmount.add(vatAmount); BigDecimal totalAmount = netAmount.add(vatAmount);
@@ -190,17 +186,13 @@ public class CustomerInvoiceService {
StringBuilder itemRows = new StringBuilder(); StringBuilder itemRows = new StringBuilder();
for (CustomerInvoiceItem item : data.getItems()) { for (CustomerInvoiceItem item : data.getItems()) {
itemRows.append("<tr>"); itemRows.append("<tr>");
itemRows.append("<td style='text-align: center;'>") itemRows.append("<td style='text-align: center;'>").append(formatDecimal(item.getQuantity()))
.append(formatDecimal(item.getQuantity())) .append(" ").append(nvl(item.getUnit())).append("</td>");
.append(" ").append(nvl(item.getUnit()))
.append("</td>");
itemRows.append("<td>").append(nvl(item.getDescription())).append("</td>"); itemRows.append("<td>").append(nvl(item.getDescription())).append("</td>");
itemRows.append("<td style='text-align: right;'>") itemRows.append("<td style='text-align: right;'>").append(formatCurrency(item.getUnitPrice()))
.append(formatCurrency(item.getUnitPrice())) .append("</td>");
.append("</td>"); itemRows.append("<td style='text-align: right;'>").append(formatCurrency(item.getNetTotal()))
itemRows.append("<td style='text-align: right;'>") .append("</td>");
.append(formatCurrency(item.getNetTotal()))
.append("</td>");
itemRows.append("</tr>"); itemRows.append("</tr>");
} }
filledHtml = filledHtml.replace("<!-- ITEM_ROWS -->", itemRows.toString()); filledHtml = filledHtml.replace("<!-- ITEM_ROWS -->", itemRows.toString());
@@ -231,12 +223,14 @@ public class CustomerInvoiceService {
} }
private String formatCurrency(BigDecimal amount) { private String formatCurrency(BigDecimal amount) {
if (amount == null) return "0,00 €"; if (amount == null)
return "0,00 €";
return NumberFormat.getCurrencyInstance(Locale.GERMANY).format(amount); return NumberFormat.getCurrencyInstance(Locale.GERMANY).format(amount);
} }
private String formatDecimal(BigDecimal value) { private String formatDecimal(BigDecimal value) {
if (value == null) return "0"; if (value == null)
return "0";
return NumberFormat.getNumberInstance(Locale.GERMANY).format(value); return NumberFormat.getNumberInstance(Locale.GERMANY).format(value);
} }

View File

@@ -285,8 +285,8 @@ public class EmailService {
body.append("ein neuer Job wurde erfolgreich erstellt:\n\n"); body.append("ein neuer Job wurde erfolgreich erstellt:\n\n");
body.append("Job: ").append(job.getJobNumber() != null ? job.getJobNumber() : "Unbekannt").append("\n"); body.append("Job: ").append(job.getJobNumber() != null ? job.getJobNumber() : "Unbekannt").append("\n");
if (job.getDeliveryCompany() != null) { if (job.getCustomerSelection() != null && !job.getCustomerSelection().isBlank()) {
body.append("Kunde: ").append(job.getDeliveryCompany()).append("\n"); body.append("Auftraggeber: ").append(job.getCustomerSelection()).append("\n");
} }
if (job.getPickupCity() != null || job.getDeliveryCity() != null) { if (job.getPickupCity() != null || job.getDeliveryCity() != null) {

View File

@@ -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<Consumer<Job>> 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<Job> 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<Job> 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);
}
}

View File

@@ -19,8 +19,8 @@ import java.util.Map;
import java.util.Set; import java.util.Set;
/** /**
* Service for job statistics and aggregations. * Service for job statistics and aggregations. Provides data for MCP tools and
* Provides data for MCP tools and reporting. * reporting.
*/ */
@Service @Service
@Slf4j @Slf4j
@@ -83,10 +83,8 @@ public class JobStatisticsService {
*/ */
public BigDecimal getTotalRevenue() { public BigDecimal getTotalRevenue() {
List<Job> allJobs = jobRepository.findAll(); List<Job> allJobs = jobRepository.findAll();
return allJobs.stream() return allJobs.stream().map(Job::getPrice).filter(price -> price != null).reduce(BigDecimal.ZERO,
.map(Job::getPrice) BigDecimal::add);
.filter(price -> price != null)
.reduce(BigDecimal.ZERO, BigDecimal::add);
} }
/** /**
@@ -110,10 +108,8 @@ public class JobStatisticsService {
* Get top customers by revenue. * Get top customers by revenue.
*/ */
public List<Map.Entry<String, BigDecimal>> getTopCustomersByRevenue(int limit) { public List<Map.Entry<String, BigDecimal>> getTopCustomersByRevenue(int limit) {
return getRevenueByCustomer().entrySet().stream() return getRevenueByCustomer().entrySet().stream().sorted((a, b) -> b.getValue().compareTo(a.getValue()))
.sorted((a, b) -> b.getValue().compareTo(a.getValue())) .limit(limit).toList();
.limit(limit)
.toList();
} }
/** /**
@@ -162,7 +158,8 @@ public class JobStatisticsService {
if (jobs.isEmpty()) { if (jobs.isEmpty()) {
String containsRegex = ".*" + escapedCustomer + ".*"; String containsRegex = ".*" + escapedCustomer + ".*";
jobs = jobRepository.findByCustomerSelectionIgnoreCase(containsRegex); 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; return jobs;
@@ -227,12 +224,8 @@ public class JobStatisticsService {
* Get all available customer names for autocomplete/filtering. * Get all available customer names for autocomplete/filtering.
*/ */
public List<String> getAllCustomerNames() { public List<String> getAllCustomerNames() {
return jobRepository.findAll().stream() return jobRepository.findAll().stream().map(Job::getCustomerSelection).filter(c -> c != null && !c.isBlank())
.map(Job::getCustomerSelection) .distinct().sorted().toList();
.filter(c -> c != null && !c.isBlank())
.distinct()
.sorted()
.toList();
} }
/** /**
@@ -261,9 +254,7 @@ public class JobStatisticsService {
* Get total revenue for a customer. * Get total revenue for a customer.
*/ */
public BigDecimal getTotalRevenueForCustomer(String customer) { public BigDecimal getTotalRevenueForCustomer(String customer) {
return getJobsByCustomer(customer).stream() return getJobsByCustomer(customer).stream().map(Job::getPrice).filter(price -> price != null)
.map(Job::getPrice)
.filter(price -> price != null)
.reduce(BigDecimal.ZERO, BigDecimal::add); .reduce(BigDecimal.ZERO, BigDecimal::add);
} }
@@ -275,9 +266,7 @@ public class JobStatisticsService {
if (customerJobs.isEmpty()) { if (customerJobs.isEmpty()) {
return 0.0; return 0.0;
} }
long completed = customerJobs.stream() long completed = customerJobs.stream().filter(j -> j.getStatus() == JobStatus.COMPLETED).count();
.filter(j -> j.getStatus() == JobStatus.COMPLETED)
.count();
return (double) completed / customerJobs.size() * 100.0; return (double) completed / customerJobs.size() * 100.0;
} }
@@ -289,11 +278,8 @@ public class JobStatisticsService {
LocalDateTime yearStart = LocalDateTime.of(year, 1, 1, 0, 0); LocalDateTime yearStart = LocalDateTime.of(year, 1, 1, 0, 0);
LocalDateTime yearEnd = LocalDateTime.of(year, 12, 31, 23, 59, 59); LocalDateTime yearEnd = LocalDateTime.of(year, 12, 31, 23, 59, 59);
List<Job> customerJobs = getJobsByCustomer(customer).stream() List<Job> customerJobs = getJobsByCustomer(customer).stream().filter(j -> j.getCreatedAt() != null
.filter(j -> j.getCreatedAt() != null && !j.getCreatedAt().isBefore(yearStart) && !j.getCreatedAt().isAfter(yearEnd)).toList();
&& !j.getCreatedAt().isBefore(yearStart)
&& !j.getCreatedAt().isAfter(yearEnd))
.toList();
// Initialize all months with 0 // Initialize all months with 0
for (Month month : Month.values()) { for (Month month : Month.values()) {
@@ -317,9 +303,7 @@ public class JobStatisticsService {
if (status == null) { if (status == null) {
return customerJobs; return customerJobs;
} }
return customerJobs.stream() return customerJobs.stream().filter(j -> j.getStatus() == status).toList();
.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" // This handles cases like "cAPPacity GmbH" matching "cAPPacity GmbH & Co. KG"
String extractedName = extractCustomerNameFromQuery(query); String extractedName = extractCustomerNameFromQuery(query);
if (extractedName != null) { if (extractedName != null) {
@@ -360,13 +345,15 @@ public class JobStatisticsService {
String lowerCustomer = customer.toLowerCase(); String lowerCustomer = customer.toLowerCase();
// Check if customer name starts with the extracted name, or contains it // Check if customer name starts with the extracted name, or contains it
if (lowerCustomer.startsWith(lowerExtracted) || lowerCustomer.contains(lowerExtracted)) { 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; 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+"); String[] queryWords = lowerQuery.split("\\s+");
for (String customer : allCustomers) { for (String customer : allCustomers) {
String lowerCustomer = customer.toLowerCase(); String lowerCustomer = customer.toLowerCase();
@@ -384,18 +371,15 @@ public class JobStatisticsService {
} }
/** /**
* Extract potential customer name from a query string. * Extract potential customer name from a query string. Looks for patterns like
* Looks for patterns like "firma X", "kunde X", "für X", etc. * "firma X", "kunde X", "für X", etc.
*/ */
private String extractCustomerNameFromQuery(String query) { private String extractCustomerNameFromQuery(String query) {
String lowerQuery = query.toLowerCase(); String lowerQuery = query.toLowerCase();
// Patterns that typically precede a customer name // Patterns that typically precede a customer name
String[] patterns = { String[] patterns = { "firma ", "kunde ", "kunden ", "unternehmen ", "für die firma ", "für den kunden ",
"firma ", "kunde ", "kunden ", "unternehmen ", "von der firma ", "vom kunden ", "der firma ", "des kunden ", "bei " };
"für die firma ", "für den kunden ", "von der firma ", "vom kunden ",
"der firma ", "des kunden ", "bei "
};
for (String pattern : patterns) { for (String pattern : patterns) {
int idx = lowerQuery.indexOf(pattern); int idx = lowerQuery.indexOf(pattern);
@@ -421,10 +405,8 @@ public class JobStatisticsService {
* Remove common trailing words from extracted customer name. * Remove common trailing words from extracted customer name.
*/ */
private String removeTrailingCommonWords(String text) { private String removeTrailingCommonWords(String text) {
String[] trailingPatterns = { String[] trailingPatterns = { " an$", " anzeigen$", " zeigen$", " auflisten$", " liste$", " status$",
" an$", " anzeigen$", " zeigen$", " auflisten$", " liste$", " mit status$", " die$", " der$", " das$" };
" status$", " mit status$", " die$", " der$", " das$"
};
String result = text; String result = text;
for (String pattern : trailingPatterns) { 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) { private boolean isCommonWord(String word) {
return Set.of( return Set.of("zeige", "alle", "jobs", "der", "die", "das", "für", "von", "mit", "und", "firma", "kunde",
"zeige", "alle", "jobs", "der", "die", "das", "für", "von", "mit", "und", "status", "welche", "sind", "gmbh", "show", "all", "the").contains(word.toLowerCase());
"firma", "kunde", "status", "welche", "sind", "gmbh", "show", "all", "the"
).contains(word.toLowerCase());
} }
} }

View File

@@ -12,8 +12,8 @@ import java.util.concurrent.Executor;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
/** /**
* Service that listens for message-related events and notifies registered UI components * Service that listens for message-related events and notifies registered UI
* to update their message badges (e.g., in the sidebar navigation) * components to update their message badges (e.g., in the sidebar navigation)
*/ */
@Service @Service
@Slf4j @Slf4j
@@ -25,7 +25,8 @@ public class MessageBadgeUpdateService {
/** /**
* Register a listener that will be called when message badge should be updated * 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 * @return Registration object that can be used to unregister the listener
*/ */
public synchronized Registration register(Runnable listener) { public synchronized Registration register(Runnable listener) {
@@ -74,4 +75,3 @@ public class MessageBadgeUpdateService {
notifyListeners(); notifyListeners();
} }
} }

View File

@@ -13,8 +13,8 @@ import java.util.concurrent.Executors;
import java.util.function.Consumer; import java.util.function.Consumer;
/** /**
* Broadcaster service that manages listeners for incoming messages * Broadcaster service that manages listeners for incoming messages and notifies
* and notifies UI components in a thread-safe manner * UI components in a thread-safe manner
*/ */
@Service @Service
@Slf4j @Slf4j
@@ -26,7 +26,8 @@ public class MessageBroadcaster {
/** /**
* Register a listener for incoming messages * 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 * @return Registration object that can be used to unregister the listener
*/ */
public synchronized Registration register(Consumer<Message> listener) { public synchronized Registration register(Consumer<Message> listener) {
@@ -42,8 +43,8 @@ public class MessageBroadcaster {
} }
/** /**
* Broadcast a message to all registered listeners * Broadcast a message to all registered listeners This is called asynchronously
* This is called asynchronously to avoid blocking the message reception * to avoid blocking the message reception
*/ */
private synchronized void broadcast(Message message) { private synchronized void broadcast(Message message) {
log.debug("Broadcasting message to {} listeners", listeners.size()); log.debug("Broadcasting message to {} listeners", listeners.size());
@@ -59,13 +60,14 @@ 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 @EventListener
public void onMessageReceived(MessageReceivedEvent event) { public void onMessageReceived(MessageReceivedEvent event) {
Message message = event.getMessage(); Message message = event.getMessage();
log.info("MessageBroadcaster received event for message with origin {} for receiver {}", log.info("MessageBroadcaster received event for message with origin {} for receiver {}", message.getOrigin(),
message.getOrigin(), message.getReceiver()); message.getReceiver());
broadcast(message); broadcast(message);
} }
} }

View File

@@ -29,8 +29,8 @@ public class MessageService {
private final MqttPublisher mqttPublisher; private final MqttPublisher mqttPublisher;
private final ApplicationEventPublisher eventPublisher; private final ApplicationEventPublisher eventPublisher;
public MessageService(MessageRepository messageRepository, JobRepository jobRepository, public MessageService(MessageRepository messageRepository, JobRepository jobRepository, MqttPublisher mqttPublisher,
MqttPublisher mqttPublisher, ApplicationEventPublisher eventPublisher) { ApplicationEventPublisher eventPublisher) {
this.messageRepository = messageRepository; this.messageRepository = messageRepository;
this.jobRepository = jobRepository; this.jobRepository = jobRepository;
this.mqttPublisher = mqttPublisher; this.mqttPublisher = mqttPublisher;
@@ -47,15 +47,17 @@ public class MessageService {
/** /**
* Send a general message to a client via MQTT * 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) { public Message sendGeneralMessageToClient(String content, String receiver) {
return sendGeneralMessageToClient(content, receiver, MessageContentType.TEXT); return sendGeneralMessageToClient(content, receiver, MessageContentType.TEXT);
} }
public Message sendGeneralMessageToClient(String content, String receiver, public Message sendGeneralMessageToClient(String content, String receiver, MessageContentType contentType) {
MessageContentType contentType) {
Message message = new Message(content, receiver, MessageOrigin.SERVER, contentType); Message message = new Message(content, receiver, MessageOrigin.SERVER, contentType);
message = saveMessage(message); message = saveMessage(message);
publishMessageToMqtt(message, receiver); publishMessageToMqtt(message, receiver);
@@ -64,21 +66,25 @@ public class MessageService {
/** /**
* Send a job-related message to a client via MQTT * Send a job-related message to a client via MQTT
* @param content Message content *
* @param receiver AppUser ID (clientId) * @param content
* @param jobId Job ObjectId * Message content
* @param jobNumber Job number * @param receiver
* AppUser ID (clientId)
* @param jobId
* Job ObjectId
* @param jobNumber
* Job number
*/ */
public Message sendJobMessageToClient(String content, String receiver, public Message sendJobMessageToClient(String content, String receiver, ObjectId jobId, String jobNumber) {
ObjectId jobId, String jobNumber) {
return sendJobMessageToClient(content, receiver, MessageContentType.TEXT, jobId, jobNumber); return sendJobMessageToClient(content, receiver, MessageContentType.TEXT, jobId, jobNumber);
} }
public Message sendJobMessageToClient(String content, String receiver, public Message sendJobMessageToClient(String content, String receiver, MessageContentType contentType,
MessageContentType contentType, ObjectId jobId, String jobNumber) { ObjectId jobId, String jobNumber) {
JobContext context = resolveJobContext(jobId, jobNumber); JobContext context = resolveJobContext(jobId, jobNumber);
Message message = new Message(content, receiver, MessageOrigin.SERVER, contentType, Message message = new Message(content, receiver, MessageOrigin.SERVER, contentType, context.jobId(),
context.jobId(), context.jobNumber()); context.jobNumber());
message = saveMessage(message); message = saveMessage(message);
publishMessageToMqtt(message, receiver); publishMessageToMqtt(message, receiver);
return message; return message;
@@ -86,7 +92,9 @@ public class MessageService {
/** /**
* Handle incoming message from a client * 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) { public Message receiveMessageFromClient(ChatMessageInboundPayload payload) {
Message message; Message message;
@@ -94,18 +102,17 @@ public class MessageService {
if (payload.hasJobContext()) { if (payload.hasJobContext()) {
JobContext context = resolveJobContext(payload.jobId(), payload.jobNumber()); JobContext context = resolveJobContext(payload.jobId(), payload.jobNumber());
// receiver = AppUser ID (clientId) // receiver = AppUser ID (clientId)
message = new Message(payload.content(), payload.receiver(), message = new Message(payload.content(), payload.receiver(), MessageOrigin.CLIENT, contentType,
MessageOrigin.CLIENT, contentType, context.jobId(), context.jobNumber()); context.jobId(), context.jobNumber());
} else { } else {
// receiver = AppUser ID (clientId) // receiver = AppUser ID (clientId)
message = new Message(payload.content(), payload.receiver(), message = new Message(payload.content(), payload.receiver(), MessageOrigin.CLIENT, contentType);
MessageOrigin.CLIENT, contentType);
} }
message = saveMessage(message); message = saveMessage(message);
// Publish event to notify UI components about the new message // Publish event to notify UI components about the new message
log.info("Publishing MessageReceivedEvent for message with origin {} for receiver {}", log.info("Publishing MessageReceivedEvent for message with origin {} for receiver {}", message.getOrigin(),
message.getOrigin(), message.getReceiver()); message.getReceiver());
eventPublisher.publishEvent(new MessageReceivedEvent(this, message)); eventPublisher.publishEvent(new MessageReceivedEvent(this, message));
return 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) * Get all messages for a specific AppUser (by receiver field), ordered by
* @param appUserId AppUser ID (clientId) * creation time ascending (oldest first)
*
* @param appUserId
* AppUser ID (clientId)
*/ */
public List<Message> getMessagesForAppUserAscending(String appUserId) { public List<Message> getMessagesForAppUserAscending(String appUserId) {
if (appUserId == null || appUserId.isBlank()) { 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 * Get all messages for a specific AppUser (by receiver field), ordered by
* @param appUserId AppUser ID (clientId) * creation time descending
*
* @param appUserId
* AppUser ID (clientId)
*/ */
public List<Message> getMessagesForAppUserDescending(String appUserId) { public List<Message> getMessagesForAppUserDescending(String appUserId) {
if (appUserId == null || appUserId.isBlank()) { if (appUserId == null || appUserId.isBlank()) {
@@ -225,6 +238,13 @@ public class MessageService {
return messageRepository.countByReceiverAndIsReadFalse(receiver); return messageRepository.countByReceiverAndIsReadFalse(receiver);
} }
/**
* Count all messages in the system
*/
public int countAllMessages() {
return (int) messageRepository.count();
}
/** /**
* Get a message by ID * Get a message by ID
*/ */
@@ -326,8 +346,7 @@ public class MessageService {
} }
return fuzzyMatches.stream() return fuzzyMatches.stream()
.filter(job -> normalizeJobToken(job.getJobNumber()).equalsIgnoreCase(normalizedCandidate)) .filter(job -> normalizeJobToken(job.getJobNumber()).equalsIgnoreCase(normalizedCandidate)).findFirst();
.findFirst();
} }
private String normalizeJobToken(String value) { private String normalizeJobToken(String value) {

View File

@@ -9,7 +9,8 @@ import java.time.LocalDate;
import java.time.YearMonth; 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 @Service
public class MonthlySchedulerService { public class MonthlySchedulerService {
@@ -17,8 +18,8 @@ public class MonthlySchedulerService {
private static final Logger logger = LoggerFactory.getLogger(MonthlySchedulerService.class); 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. * Scheduler, der täglich um 23:00 Uhr läuft und prüft, ob heute der letzte Tag
* Wenn ja, wird die monatliche Aufgabe ausgeführt. * des Monats ist. Wenn ja, wird die monatliche Aufgabe ausgeführt.
*/ */
@Scheduled(cron = "0 0 23 * * *") // Täglich um 23:00 Uhr @Scheduled(cron = "0 0 23 * * *") // Täglich um 23:00 Uhr
public void checkAndRunMonthlyTask() { 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); logger.info("Heute ist der letzte Tag des Monats ({}). Führe monatliche Aufgabe aus.", today);
runMonthlyUltimoTask(); runMonthlyUltimoTask();
} else { } else {
logger.debug("Heute ({}) ist nicht der letzte Tag des Monats. Nächster Ultimo: {}", logger.debug("Heute ({}) ist nicht der letzte Tag des Monats. Nächster Ultimo: {}", today, lastDayOfMonth);
today, lastDayOfMonth);
} }
} }
/** /**
* Alternative Implementierung: Direkter Cron-Ausdruck für den letzten Tag des Monats. * Alternative Implementierung: Direkter Cron-Ausdruck für den letzten Tag des
* Dieser Scheduler läuft am letzten Tag jedes Monats um 23:00 Uhr. * 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. * Hinweis: Dieser Ansatz ist weniger flexibel, da er nicht alle Monate korrekt
* Der obige Ansatz mit täglicher Prüfung ist robuster. * 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 // @Scheduled(cron = "0 0 23 L * *") // Am letzten Tag des Monats um 23:00 Uhr
public void runMonthlyUltimoTaskDirect() { public void runMonthlyUltimoTaskDirect() {
@@ -48,8 +48,8 @@ public class MonthlySchedulerService {
} }
/** /**
* Die eigentliche monatliche Aufgabe, die am Ultimo ausgeführt wird. * Die eigentliche monatliche Aufgabe, die am Ultimo ausgeführt wird. Hier
* Hier können Sie Ihre spezifische Geschäftslogik implementieren. * können Sie Ihre spezifische Geschäftslogik implementieren.
*/ */
private void runMonthlyUltimoTask() { private void runMonthlyUltimoTask() {
try { try {

View File

@@ -40,7 +40,8 @@ public class SystemInvoiceService {
// Set sample data // Set sample data
data.setInvoiceNumber("HHA-2021-007"); data.setInvoiceNumber("HHA-2021-007");
data.setInvoiceDate("19.07.2021"); 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.setRecipientName("Hamburger Hochbahn AG");
data.setRecipientDepartment("Kreditorenbuchhaltung"); data.setRecipientDepartment("Kreditorenbuchhaltung");
@@ -71,19 +72,25 @@ public class SystemInvoiceService {
// Replace invoice data placeholders // Replace invoice data placeholders
filledHtml = filledHtml.replace("HHA-2021-007", data.getInvoiceNumber() != null ? data.getInvoiceNumber() : ""); 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("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:", filledHtml = filledHtml.replace(
data.getInvoiceText() != null ? data.getInvoiceText() : ""); "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 // Replace recipient address
filledHtml = filledHtml.replace("Hamburger Hochbahn AG", data.getRecipientName() != null ? data.getRecipientName() : ""); filledHtml = filledHtml.replace("Hamburger Hochbahn AG",
filledHtml = filledHtml.replace("Kreditorenbuchhaltung", data.getRecipientDepartment() != null ? data.getRecipientDepartment() : ""); data.getRecipientName() != null ? data.getRecipientName() : "");
filledHtml = filledHtml.replace("Steinstraße 20", data.getRecipientStreet() != null ? data.getRecipientStreet() : ""); filledHtml = filledHtml.replace("Kreditorenbuchhaltung",
filledHtml = filledHtml.replace("20095 Hamburg", data.getRecipientCity() != null ? data.getRecipientCity() : ""); 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 // Replace invoice items
if (data.getInvoiceItems() != null && !data.getInvoiceItems().isEmpty()) { if (data.getInvoiceItems() != null && !data.getInvoiceItems().isEmpty()) {
SystemInvoiceItem item = data.getInvoiceItems().getFirst(); 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 // Replace amounts

View File

@@ -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;
}
}