Erweiterungen
This commit is contained in:
@@ -10,8 +10,8 @@ import java.net.URI;
|
||||
import java.net.URL;
|
||||
|
||||
/**
|
||||
* Configuration for LLM integration via LM Studio.
|
||||
* LM Studio provides an OpenAI-compatible API.
|
||||
* Configuration for LLM integration via LM Studio. LM Studio provides an
|
||||
* OpenAI-compatible API.
|
||||
*/
|
||||
@Configuration
|
||||
@Slf4j
|
||||
@@ -37,13 +37,16 @@ public class LlmConfig {
|
||||
// Test 1: Basic connectivity
|
||||
testEndpoint(baseUrl + "/v1/models", "GET", null);
|
||||
|
||||
// Test 2: Chat completions endpoint WITHOUT streaming (POST with minimal payload)
|
||||
String testPayload = "{\"model\":\"" + model + "\",\"messages\":[{\"role\":\"user\",\"content\":\"ping\"}],\"max_tokens\":1,\"stream\":false}";
|
||||
// Test 2: Chat completions endpoint WITHOUT streaming (POST with minimal
|
||||
// payload)
|
||||
String testPayload = "{\"model\":\"" + model
|
||||
+ "\",\"messages\":[{\"role\":\"user\",\"content\":\"ping\"}],\"max_tokens\":1,\"stream\":false}";
|
||||
log.info("Test payload (stream=false): {}", testPayload);
|
||||
testEndpoint(baseUrl + "/v1/chat/completions", "POST", testPayload);
|
||||
|
||||
// Test 3: Chat completions WITH streaming to compare behavior
|
||||
String streamPayload = "{\"model\":\"" + model + "\",\"messages\":[{\"role\":\"user\",\"content\":\"ping\"}],\"max_tokens\":1,\"stream\":true}";
|
||||
String streamPayload = "{\"model\":\"" + model
|
||||
+ "\",\"messages\":[{\"role\":\"user\",\"content\":\"ping\"}],\"max_tokens\":1,\"stream\":true}";
|
||||
log.info("Test payload (stream=true): {}", streamPayload);
|
||||
testEndpoint(baseUrl + "/v1/chat/completions", "POST", streamPayload);
|
||||
}
|
||||
|
||||
@@ -15,8 +15,8 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Service for AI-assisted statistics analysis with chart visualization.
|
||||
* Uses LM Studio via direct REST client (like aimailassistant) instead of Spring AI.
|
||||
* Service for AI-assisted statistics analysis with chart visualization. Uses LM
|
||||
* Studio via direct REST client (like aimailassistant) instead of Spring AI.
|
||||
*/
|
||||
@Service
|
||||
@Slf4j
|
||||
@@ -36,11 +36,8 @@ public class AiStatisticsService {
|
||||
/**
|
||||
* Response record containing text and optional chart data.
|
||||
*/
|
||||
public record StatisticsResponse(
|
||||
String textResponse,
|
||||
String chartType,
|
||||
String chartData
|
||||
) {}
|
||||
public record StatisticsResponse(String textResponse, String chartType, String chartData) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze a statistics query and return a response with optional visualization.
|
||||
@@ -48,11 +45,11 @@ public class AiStatisticsService {
|
||||
public StatisticsResponse analyzeStatisticsQuery(String userQuery) {
|
||||
log.info("Processing statistics query: {}", userQuery);
|
||||
|
||||
// Determine query type and prepare chart data (includes customer filter detection)
|
||||
// Determine query type and prepare chart data (includes customer filter
|
||||
// detection)
|
||||
QueryAnalysis analysis = analyzeQueryType(userQuery);
|
||||
log.debug("Query analysis - Type: {}, Chart: {}, Customer: {}, Status: {}",
|
||||
analysis.queryType, analysis.chartType,
|
||||
analysis.customerFilter != null ? analysis.customerFilter : "none",
|
||||
log.debug("Query analysis - Type: {}, Chart: {}, Customer: {}, Status: {}", analysis.queryType,
|
||||
analysis.chartType, analysis.customerFilter != null ? analysis.customerFilter : "none",
|
||||
analysis.statusFilter != null ? analysis.statusFilter : "none");
|
||||
|
||||
// Gather context (statistics or job list depending on query type)
|
||||
@@ -62,8 +59,7 @@ public class AiStatisticsService {
|
||||
String prompt = buildPrompt(userQuery, statisticsContext, analysis);
|
||||
|
||||
// System prompt - different for list vs statistics queries
|
||||
String systemPrompt = analysis.queryType.equals("list")
|
||||
? buildListSystemPrompt()
|
||||
String systemPrompt = analysis.queryType.equals("list") ? buildListSystemPrompt()
|
||||
: buildStatisticsSystemPrompt();
|
||||
|
||||
// Call LLM via direct REST client (like aimailassistant)
|
||||
@@ -74,21 +70,19 @@ public class AiStatisticsService {
|
||||
return new StatisticsResponse(llmResponse, analysis.chartType, analysis.chartData);
|
||||
} else {
|
||||
log.warn("LLM returned null or blank response, using fallback");
|
||||
return new StatisticsResponse(
|
||||
buildFallbackResponse(analysis),
|
||||
analysis.chartType,
|
||||
analysis.chartData
|
||||
);
|
||||
return new StatisticsResponse(buildFallbackResponse(analysis), analysis.chartType, analysis.chartData);
|
||||
}
|
||||
}
|
||||
|
||||
private record QueryAnalysis(
|
||||
String queryType,
|
||||
String chartType,
|
||||
String chartData,
|
||||
String customerFilter, // null = no filter, show all data
|
||||
JobStatus statusFilter // null = no status filter
|
||||
) {}
|
||||
private record QueryAnalysis(String queryType, String chartType, String chartData, String customerFilter, // null =
|
||||
// no
|
||||
// filter,
|
||||
// show
|
||||
// all
|
||||
// data
|
||||
JobStatus statusFilter // null = no status filter
|
||||
) {
|
||||
}
|
||||
|
||||
private String buildStatisticsSystemPrompt() {
|
||||
return """
|
||||
@@ -134,47 +128,42 @@ public class AiStatisticsService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect user-specified chart type from the query.
|
||||
* Returns null if no specific chart type was requested.
|
||||
* Detect user-specified chart type from the query. Returns null if no specific
|
||||
* chart type was requested.
|
||||
*/
|
||||
private String detectUserChartTypePreference(String query) {
|
||||
String lowerQuery = query.toLowerCase();
|
||||
|
||||
// Balkendiagramm / Bar Chart
|
||||
if (lowerQuery.contains("balken") || lowerQuery.contains("bar chart") ||
|
||||
lowerQuery.contains("säulen") || lowerQuery.contains("balkendiagramm")) {
|
||||
if (lowerQuery.contains("balken") || lowerQuery.contains("bar chart") || lowerQuery.contains("säulen")
|
||||
|| lowerQuery.contains("balkendiagramm")) {
|
||||
return "bar";
|
||||
}
|
||||
|
||||
// Tortendiagramm / Pie Chart
|
||||
if (lowerQuery.contains("torten") || lowerQuery.contains("pie") ||
|
||||
lowerQuery.contains("kreis") || lowerQuery.contains("tortendiagramm") ||
|
||||
lowerQuery.contains("kreisdiagramm")) {
|
||||
if (lowerQuery.contains("torten") || lowerQuery.contains("pie") || lowerQuery.contains("kreis")
|
||||
|| lowerQuery.contains("tortendiagramm") || lowerQuery.contains("kreisdiagramm")) {
|
||||
return "pie";
|
||||
}
|
||||
|
||||
// Donut / Ring Chart
|
||||
if (lowerQuery.contains("donut") || lowerQuery.contains("ring") ||
|
||||
lowerQuery.contains("doughnut")) {
|
||||
if (lowerQuery.contains("donut") || lowerQuery.contains("ring") || lowerQuery.contains("doughnut")) {
|
||||
return "doughnut";
|
||||
}
|
||||
|
||||
// Liniendiagramm / Line Chart
|
||||
if (lowerQuery.contains("linie") || lowerQuery.contains("line") ||
|
||||
lowerQuery.contains("liniendiagramm") || lowerQuery.contains("kurve") ||
|
||||
lowerQuery.contains("graph")) {
|
||||
if (lowerQuery.contains("linie") || lowerQuery.contains("line") || lowerQuery.contains("liniendiagramm")
|
||||
|| lowerQuery.contains("kurve") || lowerQuery.contains("graph")) {
|
||||
return "line";
|
||||
}
|
||||
|
||||
// Flächendiagramm / Area Chart
|
||||
if (lowerQuery.contains("fläche") || lowerQuery.contains("area") ||
|
||||
lowerQuery.contains("flächendiagramm")) {
|
||||
if (lowerQuery.contains("fläche") || lowerQuery.contains("area") || lowerQuery.contains("flächendiagramm")) {
|
||||
return "line"; // Line with fill=true
|
||||
}
|
||||
|
||||
// Radar Chart
|
||||
if (lowerQuery.contains("radar") || lowerQuery.contains("netz") ||
|
||||
lowerQuery.contains("spinne")) {
|
||||
if (lowerQuery.contains("radar") || lowerQuery.contains("netz") || lowerQuery.contains("spinne")) {
|
||||
return "radar";
|
||||
}
|
||||
|
||||
@@ -216,31 +205,29 @@ public class AiStatisticsService {
|
||||
String chartData;
|
||||
|
||||
// Status-bezogene Anfragen (Statistik, nicht Liste)
|
||||
if ((lowerQuery.contains("status") && (lowerQuery.contains("statistik") || lowerQuery.contains("verteilung") ||
|
||||
lowerQuery.contains("übersicht") || lowerQuery.contains("wie viele"))) ||
|
||||
lowerQuery.contains("zählen") || lowerQuery.contains("anzahl")) {
|
||||
if ((lowerQuery.contains("status") && (lowerQuery.contains("statistik") || lowerQuery.contains("verteilung")
|
||||
|| lowerQuery.contains("übersicht") || lowerQuery.contains("wie viele")))
|
||||
|| lowerQuery.contains("zählen") || lowerQuery.contains("anzahl")) {
|
||||
queryType = "status";
|
||||
defaultChartType = "doughnut";
|
||||
chartData = buildStatusChartData(customerFilter);
|
||||
}
|
||||
// Umsatz-bezogene Anfragen
|
||||
else if (lowerQuery.contains("umsatz") || lowerQuery.contains("revenue") ||
|
||||
lowerQuery.contains("einnahmen")) {
|
||||
else if (lowerQuery.contains("umsatz") || lowerQuery.contains("revenue") || lowerQuery.contains("einnahmen")) {
|
||||
queryType = "revenue";
|
||||
defaultChartType = "bar";
|
||||
chartData = customerFilter != null ? buildCustomerRevenueChartData(customerFilter) : buildRevenueChartData();
|
||||
chartData = customerFilter != null ? buildCustomerRevenueChartData(customerFilter)
|
||||
: buildRevenueChartData();
|
||||
}
|
||||
// Trend-bezogene Anfragen
|
||||
else if (lowerQuery.contains("trend") || lowerQuery.contains("monat") ||
|
||||
lowerQuery.contains("entwicklung") || lowerQuery.contains("jahr") ||
|
||||
lowerQuery.contains("verlauf")) {
|
||||
else if (lowerQuery.contains("trend") || lowerQuery.contains("monat") || lowerQuery.contains("entwicklung")
|
||||
|| lowerQuery.contains("jahr") || lowerQuery.contains("verlauf")) {
|
||||
queryType = "trend";
|
||||
defaultChartType = "line";
|
||||
chartData = buildTrendChartData(customerFilter);
|
||||
}
|
||||
// Task-bezogene Anfragen
|
||||
else if (lowerQuery.contains("task") || lowerQuery.contains("aufgabe") ||
|
||||
lowerQuery.contains("erledigt")) {
|
||||
else if (lowerQuery.contains("task") || lowerQuery.contains("aufgabe") || lowerQuery.contains("erledigt")) {
|
||||
queryType = "tasks";
|
||||
defaultChartType = "doughnut";
|
||||
chartData = buildTaskChartData();
|
||||
@@ -274,7 +261,8 @@ public class AiStatisticsService {
|
||||
if (lowerQuery.contains("abgeholt") || lowerQuery.contains("picked up")) {
|
||||
return JobStatus.PICKED_UP;
|
||||
}
|
||||
if (lowerQuery.contains("in transport") || lowerQuery.contains("in transit") || lowerQuery.contains("unterwegs")) {
|
||||
if (lowerQuery.contains("in transport") || lowerQuery.contains("in transit")
|
||||
|| lowerQuery.contains("unterwegs")) {
|
||||
return JobStatus.IN_TRANSIT;
|
||||
}
|
||||
if (lowerQuery.contains("zugestellt") || lowerQuery.contains("delivered") || lowerQuery.contains("geliefert")) {
|
||||
@@ -283,7 +271,8 @@ public class AiStatisticsService {
|
||||
if (lowerQuery.contains("abgeschlossen") || lowerQuery.contains("completed") || lowerQuery.contains("fertig")) {
|
||||
return JobStatus.COMPLETED;
|
||||
}
|
||||
if (lowerQuery.contains("storniert") || lowerQuery.contains("cancelled") || lowerQuery.contains("abgebrochen")) {
|
||||
if (lowerQuery.contains("storniert") || lowerQuery.contains("cancelled")
|
||||
|| lowerQuery.contains("abgebrochen")) {
|
||||
return JobStatus.CANCELLED;
|
||||
}
|
||||
return null;
|
||||
@@ -294,45 +283,31 @@ public class AiStatisticsService {
|
||||
*/
|
||||
private boolean isListQuery(String lowerQuery) {
|
||||
// Keywords that indicate a list/detail query (not statistics)
|
||||
boolean hasListKeywords = lowerQuery.contains("zeige alle") ||
|
||||
lowerQuery.contains("liste") ||
|
||||
lowerQuery.contains("welche jobs") ||
|
||||
lowerQuery.contains("alle jobs") ||
|
||||
lowerQuery.contains("alle aufträge") ||
|
||||
lowerQuery.contains("zeige die jobs") ||
|
||||
lowerQuery.contains("zeige die aufträge") ||
|
||||
lowerQuery.contains("zeig mir die") ||
|
||||
lowerQuery.contains("gib mir die");
|
||||
boolean hasListKeywords = lowerQuery.contains("zeige alle") || lowerQuery.contains("liste")
|
||||
|| lowerQuery.contains("welche jobs") || lowerQuery.contains("alle jobs")
|
||||
|| lowerQuery.contains("alle aufträge") || lowerQuery.contains("zeige die jobs")
|
||||
|| lowerQuery.contains("zeige die aufträge") || lowerQuery.contains("zeig mir die")
|
||||
|| lowerQuery.contains("gib mir die");
|
||||
|
||||
// Keywords that indicate statistics (override list detection)
|
||||
boolean hasStatsKeywords = lowerQuery.contains("statistik") ||
|
||||
lowerQuery.contains("diagramm") ||
|
||||
lowerQuery.contains("chart") ||
|
||||
lowerQuery.contains("verteilung") ||
|
||||
lowerQuery.contains("wie viele") ||
|
||||
lowerQuery.contains("anzahl") ||
|
||||
lowerQuery.contains("zähle") ||
|
||||
lowerQuery.contains("umsatz") ||
|
||||
lowerQuery.contains("trend") ||
|
||||
lowerQuery.contains("torte") ||
|
||||
lowerQuery.contains("balken");
|
||||
boolean hasStatsKeywords = lowerQuery.contains("statistik") || lowerQuery.contains("diagramm")
|
||||
|| lowerQuery.contains("chart") || lowerQuery.contains("verteilung") || lowerQuery.contains("wie viele")
|
||||
|| lowerQuery.contains("anzahl") || lowerQuery.contains("zähle") || lowerQuery.contains("umsatz")
|
||||
|| lowerQuery.contains("trend") || lowerQuery.contains("torte") || lowerQuery.contains("balken");
|
||||
|
||||
return hasListKeywords && !hasStatsKeywords;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect customer filter from the query.
|
||||
* Returns the matching customer name or null if no filter detected.
|
||||
* Detect customer filter from the query. Returns the matching customer name or
|
||||
* null if no filter detected.
|
||||
*/
|
||||
private String detectCustomerFilter(String query) {
|
||||
String lowerQuery = query.toLowerCase();
|
||||
|
||||
// Keywords that indicate a customer filter
|
||||
String[] filterIndicators = {
|
||||
"für ", "von ", "bei ", "kunde ", "firma ", "unternehmen ",
|
||||
"für die firma ", "für den kunden ", "von der firma ", "vom kunden ",
|
||||
"nur ", "ausschließlich ", "speziell "
|
||||
};
|
||||
String[] filterIndicators = { "für ", "von ", "bei ", "kunde ", "firma ", "unternehmen ", "für die firma ",
|
||||
"für den kunden ", "von der firma ", "vom kunden ", "nur ", "ausschließlich ", "speziell " };
|
||||
|
||||
// Check if any indicator is present
|
||||
boolean hasIndicator = false;
|
||||
@@ -367,15 +342,14 @@ public class AiStatisticsService {
|
||||
List<String> labels = new ArrayList<>();
|
||||
List<Long> data = new ArrayList<>();
|
||||
// Moderne Farbpalette mit satteren Farben
|
||||
List<String> colors = List.of(
|
||||
"#06b6d4", // CREATED - cyan
|
||||
"#f59e0b", // IN_PROGRESS - amber
|
||||
"#3b82f6", // PICKUP_SCHEDULED - blau
|
||||
"#8b5cf6", // PICKED_UP - violett
|
||||
"#f97316", // IN_TRANSIT - orange
|
||||
"#22c55e", // DELIVERED - grün
|
||||
"#6366f1", // COMPLETED - indigo
|
||||
"#ef4444" // CANCELLED - rot
|
||||
List<String> colors = List.of("#06b6d4", // CREATED - cyan
|
||||
"#f59e0b", // IN_PROGRESS - amber
|
||||
"#3b82f6", // PICKUP_SCHEDULED - blau
|
||||
"#8b5cf6", // PICKED_UP - violett
|
||||
"#f97316", // IN_TRANSIT - orange
|
||||
"#22c55e", // DELIVERED - grün
|
||||
"#6366f1", // COMPLETED - indigo
|
||||
"#ef4444" // CANCELLED - rot
|
||||
);
|
||||
|
||||
for (JobStatus status : JobStatus.values()) {
|
||||
@@ -402,12 +376,10 @@ public class AiStatisticsService {
|
||||
}
|
||||
|
||||
// Gradient-ähnliche Farbpalette für Balken
|
||||
List<String> colors = List.of(
|
||||
"#6366f1", "#8b5cf6", "#a855f7", "#c084fc",
|
||||
"#d8b4fe", "#e9d5ff", "#f3e8ff", "#faf5ff",
|
||||
"#ede9fe", "#ddd6fe"
|
||||
);
|
||||
return buildChartJsonDouble(labels, data, colors.subList(0, Math.min(labels.size(), colors.size())), "Umsatz (EUR)");
|
||||
List<String> colors = List.of("#6366f1", "#8b5cf6", "#a855f7", "#c084fc", "#d8b4fe", "#e9d5ff", "#f3e8ff",
|
||||
"#faf5ff", "#ede9fe", "#ddd6fe");
|
||||
return buildChartJsonDouble(labels, data, colors.subList(0, Math.min(labels.size(), colors.size())),
|
||||
"Umsatz (EUR)");
|
||||
}
|
||||
|
||||
private String buildCustomerRevenueChartData(String customer) {
|
||||
@@ -418,11 +390,8 @@ public class AiStatisticsService {
|
||||
long inProgress = statusCounts.getOrDefault(JobStatus.IN_PROGRESS, 0L);
|
||||
|
||||
List<String> labels = List.of("Aufträge gesamt", "Abgeschlossen", "In Bearbeitung", "Umsatz (€/100)");
|
||||
List<Double> data = List.of(
|
||||
(double) totalJobs,
|
||||
(double) completed,
|
||||
(double) inProgress,
|
||||
totalRevenue.doubleValue() / 100 // Scale down for better visualization
|
||||
List<Double> data = List.of((double) totalJobs, (double) completed, (double) inProgress,
|
||||
totalRevenue.doubleValue() / 100 // Scale down for better visualization
|
||||
);
|
||||
List<String> colors = List.of("#3b82f6", "#22c55e", "#f59e0b", "#6366f1");
|
||||
|
||||
@@ -435,16 +404,15 @@ public class AiStatisticsService {
|
||||
? statisticsService.getMonthlyJobCountsForCustomer(currentYear, customerFilter)
|
||||
: statisticsService.getMonthlyJobCounts(currentYear);
|
||||
|
||||
List<String> labels = List.of("Jan", "Feb", "Mär", "Apr", "Mai", "Jun",
|
||||
"Jul", "Aug", "Sep", "Okt", "Nov", "Dez");
|
||||
List<String> labels = List.of("Jan", "Feb", "Mär", "Apr", "Mai", "Jun", "Jul", "Aug", "Sep", "Okt", "Nov",
|
||||
"Dez");
|
||||
List<Long> data = new ArrayList<>();
|
||||
|
||||
for (Month month : Month.values()) {
|
||||
data.add(monthlyData.getOrDefault(month, 0L));
|
||||
}
|
||||
|
||||
String datasetLabel = customerFilter != null
|
||||
? String.format("%s - %d", customerFilter, currentYear)
|
||||
String datasetLabel = customerFilter != null ? String.format("%s - %d", customerFilter, currentYear)
|
||||
: String.format("Aufträge %d", currentYear);
|
||||
|
||||
return String.format("""
|
||||
@@ -471,10 +439,7 @@ public class AiStatisticsService {
|
||||
Map<String, Long> taskStats = statisticsService.getTaskCompletionStats();
|
||||
|
||||
List<String> labels = List.of("Erledigt", "Ausstehend");
|
||||
List<Long> data = List.of(
|
||||
taskStats.getOrDefault("completed", 0L),
|
||||
taskStats.getOrDefault("pending", 0L)
|
||||
);
|
||||
List<Long> data = List.of(taskStats.getOrDefault("completed", 0L), taskStats.getOrDefault("pending", 0L));
|
||||
List<String> colors = List.of("#22c55e", "#f59e0b");
|
||||
|
||||
return buildChartJson(labels, data, colors, "Aufgaben");
|
||||
@@ -485,8 +450,7 @@ public class AiStatisticsService {
|
||||
? statisticsService.getJobCountsByStatusForCustomer(customerFilter)
|
||||
: statisticsService.getJobCountsByStatus();
|
||||
|
||||
long total = customerFilter != null
|
||||
? statisticsService.getTotalJobCountForCustomer(customerFilter)
|
||||
long total = customerFilter != null ? statisticsService.getTotalJobCountForCustomer(customerFilter)
|
||||
: statisticsService.getTotalJobCount();
|
||||
long completed = statusCounts.getOrDefault(JobStatus.COMPLETED, 0L);
|
||||
long inProgress = statusCounts.getOrDefault(JobStatus.IN_PROGRESS, 0L);
|
||||
@@ -570,8 +534,8 @@ public class AiStatisticsService {
|
||||
// General statistics (all data)
|
||||
var statusCounts = statisticsService.getJobCountsByStatus();
|
||||
context.append("**Aktuelle Auftragsstatistiken:**\n");
|
||||
statusCounts.forEach((status, count) ->
|
||||
context.append(String.format("- %s: %d Aufträge\n", status.getDisplayName(), count)));
|
||||
statusCounts.forEach((status, count) -> context
|
||||
.append(String.format("- %s: %d Aufträge\n", status.getDisplayName(), count)));
|
||||
|
||||
context.append(String.format("\n**Gesamtübersicht:**\n"));
|
||||
context.append(String.format("- Gesamtanzahl Aufträge: %d\n", statisticsService.getTotalJobCount()));
|
||||
@@ -591,8 +555,7 @@ public class AiStatisticsService {
|
||||
context.append("\n**Top 5 Kunden nach Umsatz:**\n");
|
||||
for (var entry : topCustomers) {
|
||||
context.append(String.format("- %s: %.2f EUR\n",
|
||||
entry.getKey() != null ? entry.getKey() : "Unbekannt",
|
||||
entry.getValue()));
|
||||
entry.getKey() != null ? entry.getKey() : "Unbekannt", entry.getValue()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -606,8 +569,8 @@ public class AiStatisticsService {
|
||||
List<Job> jobs;
|
||||
if (customerFilter != null && statusFilter != null) {
|
||||
jobs = statisticsService.getJobsByCustomerAndStatus(customerFilter, statusFilter);
|
||||
context.append(String.format("**Jobs für %s mit Status %s:**\n\n",
|
||||
customerFilter, statusFilter.getDisplayName()));
|
||||
context.append(
|
||||
String.format("**Jobs für %s mit Status %s:**\n\n", customerFilter, statusFilter.getDisplayName()));
|
||||
} else if (customerFilter != null) {
|
||||
jobs = statisticsService.getJobsByCustomer(customerFilter);
|
||||
context.append(String.format("**Jobs für %s:**\n\n", customerFilter));
|
||||
@@ -628,10 +591,10 @@ public class AiStatisticsService {
|
||||
context.append(String.format("... und %d weitere Jobs\n", jobs.size() - 10));
|
||||
break;
|
||||
}
|
||||
context.append(String.format("- %s: %s (%s)\n",
|
||||
job.getJobNumber() != null ? job.getJobNumber() : "Ohne Nr.",
|
||||
job.getCustomerSelection() != null ? job.getCustomerSelection() : "Unbekannt",
|
||||
job.getStatus().getDisplayName()));
|
||||
context.append(
|
||||
String.format("- %s: %s (%s)\n", job.getJobNumber() != null ? job.getJobNumber() : "Ohne Nr.",
|
||||
job.getCustomerSelection() != null ? job.getCustomerSelection() : "Unbekannt",
|
||||
job.getStatus().getDisplayName()));
|
||||
shown++;
|
||||
}
|
||||
|
||||
@@ -639,7 +602,8 @@ public class AiStatisticsService {
|
||||
}
|
||||
|
||||
private String buildPrompt(String userQuery, String statisticsContext, QueryAnalysis analysis) {
|
||||
// User prompt contains only the context and question (system prompt is passed separately)
|
||||
// User prompt contains only the context and question (system prompt is passed
|
||||
// separately)
|
||||
return String.format("""
|
||||
%s
|
||||
|
||||
@@ -652,107 +616,93 @@ public class AiStatisticsService {
|
||||
JobStatus statusFilter = analysis.statusFilter;
|
||||
|
||||
return switch (analysis.queryType) {
|
||||
case "list" -> {
|
||||
List<Job> jobs;
|
||||
if (customer != null && statusFilter != null) {
|
||||
jobs = statisticsService.getJobsByCustomerAndStatus(customer, statusFilter);
|
||||
yield String.format("Es wurden %d Jobs für %s mit Status \"%s\" gefunden.",
|
||||
jobs.size(), customer, statusFilter.getDisplayName());
|
||||
} else if (customer != null) {
|
||||
jobs = statisticsService.getJobsByCustomer(customer);
|
||||
yield String.format("Es wurden %d Jobs für %s gefunden.", jobs.size(), customer);
|
||||
} else if (statusFilter != null) {
|
||||
jobs = statisticsService.getJobsByStatus(statusFilter);
|
||||
yield String.format("Es wurden %d Jobs mit Status \"%s\" gefunden.",
|
||||
jobs.size(), statusFilter.getDisplayName());
|
||||
} else {
|
||||
yield "Hier sind die aktuellen Jobs.";
|
||||
}
|
||||
case "list" -> {
|
||||
List<Job> jobs;
|
||||
if (customer != null && statusFilter != null) {
|
||||
jobs = statisticsService.getJobsByCustomerAndStatus(customer, statusFilter);
|
||||
yield String.format("Es wurden %d Jobs für %s mit Status \"%s\" gefunden.", jobs.size(), customer,
|
||||
statusFilter.getDisplayName());
|
||||
} else if (customer != null) {
|
||||
jobs = statisticsService.getJobsByCustomer(customer);
|
||||
yield String.format("Es wurden %d Jobs für %s gefunden.", jobs.size(), customer);
|
||||
} else if (statusFilter != null) {
|
||||
jobs = statisticsService.getJobsByStatus(statusFilter);
|
||||
yield String.format("Es wurden %d Jobs mit Status \"%s\" gefunden.", jobs.size(),
|
||||
statusFilter.getDisplayName());
|
||||
} else {
|
||||
yield "Hier sind die aktuellen Jobs.";
|
||||
}
|
||||
case "status" -> {
|
||||
var counts = customer != null
|
||||
? statisticsService.getJobCountsByStatusForCustomer(customer)
|
||||
: statisticsService.getJobCountsByStatus();
|
||||
String title = customer != null
|
||||
? String.format("**Auftragsübersicht für %s:**\n\n", customer)
|
||||
: "**Auftragsübersicht nach Status:**\n\n";
|
||||
StringBuilder sb = new StringBuilder(title);
|
||||
counts.forEach((status, count) -> {
|
||||
if (count > 0) {
|
||||
sb.append(String.format("- **%s:** %d Aufträge\n", status.getDisplayName(), count));
|
||||
}
|
||||
});
|
||||
long total = customer != null
|
||||
? statisticsService.getTotalJobCountForCustomer(customer)
|
||||
: statisticsService.getTotalJobCount();
|
||||
sb.append(String.format("\n**Gesamt:** %d Aufträge", total));
|
||||
}
|
||||
case "status" -> {
|
||||
var counts = customer != null ? statisticsService.getJobCountsByStatusForCustomer(customer)
|
||||
: statisticsService.getJobCountsByStatus();
|
||||
String title = customer != null ? String.format("**Auftragsübersicht für %s:**\n\n", customer)
|
||||
: "**Auftragsübersicht nach Status:**\n\n";
|
||||
StringBuilder sb = new StringBuilder(title);
|
||||
counts.forEach((status, count) -> {
|
||||
if (count > 0) {
|
||||
sb.append(String.format("- **%s:** %d Aufträge\n", status.getDisplayName(), count));
|
||||
}
|
||||
});
|
||||
long total = customer != null ? statisticsService.getTotalJobCountForCustomer(customer)
|
||||
: statisticsService.getTotalJobCount();
|
||||
sb.append(String.format("\n**Gesamt:** %d Aufträge", total));
|
||||
yield sb.toString();
|
||||
}
|
||||
case "revenue" -> {
|
||||
if (customer != null) {
|
||||
var revenue = statisticsService.getTotalRevenueForCustomer(customer);
|
||||
var jobCount = statisticsService.getTotalJobCountForCustomer(customer);
|
||||
yield String.format("**Umsatz für %s:**\n\n" + "- **Gesamtumsatz:** %.2f EUR\n" + "- **Aufträge:** %d",
|
||||
customer, revenue, jobCount);
|
||||
} else {
|
||||
var topCustomers = statisticsService.getTopCustomersByRevenue(5);
|
||||
StringBuilder sb = new StringBuilder("**Top Kunden nach Umsatz:**\n\n");
|
||||
int rank = 1;
|
||||
for (var entry : topCustomers) {
|
||||
sb.append(String.format("%d. **%s:** %.2f EUR\n", rank++,
|
||||
entry.getKey() != null ? entry.getKey() : "Unbekannt", entry.getValue()));
|
||||
}
|
||||
sb.append(String.format("\n**Gesamtumsatz:** %.2f EUR", statisticsService.getTotalRevenue()));
|
||||
yield sb.toString();
|
||||
}
|
||||
case "revenue" -> {
|
||||
if (customer != null) {
|
||||
var revenue = statisticsService.getTotalRevenueForCustomer(customer);
|
||||
var jobCount = statisticsService.getTotalJobCountForCustomer(customer);
|
||||
yield String.format("**Umsatz für %s:**\n\n" +
|
||||
"- **Gesamtumsatz:** %.2f EUR\n" +
|
||||
"- **Aufträge:** %d",
|
||||
customer, revenue, jobCount);
|
||||
} else {
|
||||
var topCustomers = statisticsService.getTopCustomersByRevenue(5);
|
||||
StringBuilder sb = new StringBuilder("**Top Kunden nach Umsatz:**\n\n");
|
||||
int rank = 1;
|
||||
for (var entry : topCustomers) {
|
||||
sb.append(String.format("%d. **%s:** %.2f EUR\n",
|
||||
rank++,
|
||||
entry.getKey() != null ? entry.getKey() : "Unbekannt",
|
||||
entry.getValue()));
|
||||
}
|
||||
sb.append(String.format("\n**Gesamtumsatz:** %.2f EUR", statisticsService.getTotalRevenue()));
|
||||
yield sb.toString();
|
||||
}
|
||||
}
|
||||
case "trend" -> {
|
||||
int year = Year.now().getValue();
|
||||
var monthly = customer != null
|
||||
? statisticsService.getMonthlyJobCountsForCustomer(year, customer)
|
||||
: statisticsService.getMonthlyJobCounts(year);
|
||||
long total = monthly.values().stream().mapToLong(Long::longValue).sum();
|
||||
String title = customer != null
|
||||
? String.format("**Monatstrend %d für %s:**", year, customer)
|
||||
: String.format("**Monatstrend %d:**", year);
|
||||
yield String.format("%s\n\nInsgesamt wurden %d Aufträge erstellt. " +
|
||||
"Die Verteilung ist im Diagramm ersichtlich.", title, total);
|
||||
}
|
||||
case "tasks" -> {
|
||||
var taskStats = statisticsService.getTaskCompletionStats();
|
||||
long total = taskStats.getOrDefault("total", 0L);
|
||||
long completed = taskStats.getOrDefault("completed", 0L);
|
||||
double rate = total > 0 ? (double) completed / total * 100 : 0;
|
||||
yield String.format("**Aufgabenstatistik:**\n\n" +
|
||||
"- **Gesamt:** %d Aufgaben\n" +
|
||||
"- **Erledigt:** %d (%.1f%%)\n" +
|
||||
"- **Ausstehend:** %d",
|
||||
total, completed, rate, taskStats.getOrDefault("pending", 0L));
|
||||
}
|
||||
default -> {
|
||||
if (customer != null) {
|
||||
yield String.format("**Übersicht für %s:**\n\n" +
|
||||
"- **Aufträge gesamt:** %d\n" +
|
||||
"- **Abschlussrate:** %.1f%%\n" +
|
||||
"- **Umsatz:** %.2f EUR",
|
||||
customer,
|
||||
statisticsService.getTotalJobCountForCustomer(customer),
|
||||
statisticsService.getCompletionRateForCustomer(customer),
|
||||
statisticsService.getTotalRevenueForCustomer(customer));
|
||||
} else {
|
||||
yield String.format("**Übersicht:**\n\n" +
|
||||
"- **Aufträge gesamt:** %d\n" +
|
||||
"- **Abschlussrate:** %.1f%%\n" +
|
||||
"- **Gesamtumsatz:** %.2f EUR",
|
||||
statisticsService.getTotalJobCount(),
|
||||
statisticsService.getCompletionRate(),
|
||||
statisticsService.getTotalRevenue());
|
||||
}
|
||||
}
|
||||
case "trend" -> {
|
||||
int year = Year.now().getValue();
|
||||
var monthly = customer != null ? statisticsService.getMonthlyJobCountsForCustomer(year, customer)
|
||||
: statisticsService.getMonthlyJobCounts(year);
|
||||
long total = monthly.values().stream().mapToLong(Long::longValue).sum();
|
||||
String title = customer != null ? String.format("**Monatstrend %d für %s:**", year, customer)
|
||||
: String.format("**Monatstrend %d:**", year);
|
||||
yield String.format(
|
||||
"%s\n\nInsgesamt wurden %d Aufträge erstellt. " + "Die Verteilung ist im Diagramm ersichtlich.",
|
||||
title, total);
|
||||
}
|
||||
case "tasks" -> {
|
||||
var taskStats = statisticsService.getTaskCompletionStats();
|
||||
long total = taskStats.getOrDefault("total", 0L);
|
||||
long completed = taskStats.getOrDefault("completed", 0L);
|
||||
double rate = total > 0 ? (double) completed / total * 100 : 0;
|
||||
yield String.format("**Aufgabenstatistik:**\n\n" + "- **Gesamt:** %d Aufgaben\n"
|
||||
+ "- **Erledigt:** %d (%.1f%%)\n" + "- **Ausstehend:** %d", total, completed, rate,
|
||||
taskStats.getOrDefault("pending", 0L));
|
||||
}
|
||||
default -> {
|
||||
if (customer != null) {
|
||||
yield String.format(
|
||||
"**Übersicht für %s:**\n\n" + "- **Aufträge gesamt:** %d\n" + "- **Abschlussrate:** %.1f%%\n"
|
||||
+ "- **Umsatz:** %.2f EUR",
|
||||
customer, statisticsService.getTotalJobCountForCustomer(customer),
|
||||
statisticsService.getCompletionRateForCustomer(customer),
|
||||
statisticsService.getTotalRevenueForCustomer(customer));
|
||||
} else {
|
||||
yield String.format(
|
||||
"**Übersicht:**\n\n" + "- **Aufträge gesamt:** %d\n" + "- **Abschlussrate:** %.1f%%\n"
|
||||
+ "- **Gesamtumsatz:** %.2f EUR",
|
||||
statisticsService.getTotalJobCount(), statisticsService.getCompletionRate(),
|
||||
statisticsService.getTotalRevenue());
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,8 +13,8 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Direct REST client for LM Studio API.
|
||||
* Uses Spring WebClient like aimailassistant - bypasses Spring AI.
|
||||
* Direct REST client for LM Studio API. Uses Spring WebClient like
|
||||
* aimailassistant - bypasses Spring AI.
|
||||
*/
|
||||
@Component
|
||||
@Slf4j
|
||||
@@ -24,14 +24,10 @@ public class LlmRestClient {
|
||||
private final ObjectMapper objectMapper;
|
||||
private final String model;
|
||||
|
||||
public LlmRestClient(
|
||||
@Value("${spring.ai.openai.base-url:http://192.168.180.10:1234}") String baseUrl,
|
||||
@Value("${spring.ai.openai.chat.options.model:local-model}") String model,
|
||||
ObjectMapper objectMapper) {
|
||||
public LlmRestClient(@Value("${spring.ai.openai.base-url:http://192.168.180.10:1234}") String baseUrl,
|
||||
@Value("${spring.ai.openai.chat.options.model:local-model}") String model, ObjectMapper objectMapper) {
|
||||
|
||||
this.webClient = WebClient.builder()
|
||||
.baseUrl(baseUrl + "/v1/chat/completions")
|
||||
.build();
|
||||
this.webClient = WebClient.builder().baseUrl(baseUrl + "/v1/chat/completions").build();
|
||||
this.model = model;
|
||||
this.objectMapper = objectMapper;
|
||||
|
||||
@@ -41,8 +37,10 @@ public class LlmRestClient {
|
||||
/**
|
||||
* Send a chat completion request to LM Studio.
|
||||
*
|
||||
* @param systemPrompt System prompt for context
|
||||
* @param userMessage User message/question
|
||||
* @param systemPrompt
|
||||
* System prompt for context
|
||||
* @param userMessage
|
||||
* User message/question
|
||||
* @return LLM response text, or null on error
|
||||
*/
|
||||
public String chat(String systemPrompt, String userMessage) {
|
||||
@@ -52,36 +50,29 @@ public class LlmRestClient {
|
||||
/**
|
||||
* Send a chat completion request to LM Studio with custom parameters.
|
||||
*
|
||||
* @param systemPrompt System prompt for context
|
||||
* @param userMessage User message/question
|
||||
* @param temperature Temperature for response randomness (0.0-1.0)
|
||||
* @param maxTokens Maximum tokens in response
|
||||
* @param systemPrompt
|
||||
* System prompt for context
|
||||
* @param userMessage
|
||||
* User message/question
|
||||
* @param temperature
|
||||
* Temperature for response randomness (0.0-1.0)
|
||||
* @param maxTokens
|
||||
* Maximum tokens in response
|
||||
* @return LLM response text, or null on error
|
||||
*/
|
||||
public String chat(String systemPrompt, String userMessage, double temperature, int maxTokens) {
|
||||
try {
|
||||
Map<String, Object> request = Map.of(
|
||||
"model", model,
|
||||
"messages", List.of(
|
||||
Map.of("role", "system", "content", systemPrompt != null ? systemPrompt : ""),
|
||||
Map.of("role", "user", "content", userMessage)
|
||||
),
|
||||
"temperature", temperature,
|
||||
"max_tokens", maxTokens,
|
||||
"stream", false // WICHTIG: Kein Streaming!
|
||||
Map<String, Object> request = Map.of("model", model, "messages",
|
||||
List.of(Map.of("role", "system", "content", systemPrompt != null ? systemPrompt : ""),
|
||||
Map.of("role", "user", "content", userMessage)),
|
||||
"temperature", temperature, "max_tokens", maxTokens, "stream", false // WICHTIG: Kein Streaming!
|
||||
);
|
||||
|
||||
log.info("Sending request to LLM (model: {}, prompt length: {} chars)...",
|
||||
model, userMessage.length());
|
||||
log.info("Sending request to LLM (model: {}, prompt length: {} chars)...", model, userMessage.length());
|
||||
long startTime = System.currentTimeMillis();
|
||||
|
||||
String response = webClient.post()
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.bodyValue(request)
|
||||
.retrieve()
|
||||
.bodyToMono(String.class)
|
||||
.timeout(Duration.ofSeconds(120))
|
||||
.block();
|
||||
String response = webClient.post().contentType(MediaType.APPLICATION_JSON).bodyValue(request).retrieve()
|
||||
.bodyToMono(String.class).timeout(Duration.ofSeconds(120)).block();
|
||||
|
||||
long duration = System.currentTimeMillis() - startTime;
|
||||
log.info("LLM response received in {}ms", duration);
|
||||
|
||||
@@ -8,15 +8,15 @@ import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Primary;
|
||||
|
||||
/**
|
||||
* Jackson configuration for consistent JSON serialization across the application.
|
||||
* Ensures all date/time fields are serialized as ISO 8601 strings.
|
||||
* Jackson configuration for consistent JSON serialization across the
|
||||
* application. Ensures all date/time fields are serialized as ISO 8601 strings.
|
||||
*/
|
||||
@Configuration
|
||||
public class JacksonConfig {
|
||||
|
||||
/**
|
||||
* Creates a configured ObjectMapper bean that serializes dates as ISO 8601 strings.
|
||||
* This bean is used throughout the application for JSON serialization.
|
||||
* Creates a configured ObjectMapper bean that serializes dates as ISO 8601
|
||||
* strings. This bean is used throughout the application for JSON serialization.
|
||||
*/
|
||||
@Bean
|
||||
@Primary
|
||||
@@ -28,4 +28,3 @@ public class JacksonConfig {
|
||||
return objectMapper;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -124,6 +124,9 @@ public class MongoConfig {
|
||||
if (source.containsKey("task_order")) {
|
||||
task.setTaskOrder(source.getInteger("task_order", 0));
|
||||
}
|
||||
if (source.containsKey("description")) {
|
||||
task.setDescription(source.getString("description"));
|
||||
}
|
||||
if (source.containsKey("completed")) {
|
||||
task.setCompleted(source.getBoolean("completed", false));
|
||||
}
|
||||
|
||||
@@ -17,4 +17,3 @@ public class PasswordEncoderConfig {
|
||||
return new BCryptPasswordEncoder();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,8 +14,8 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* REST API controller for message operations.
|
||||
* Provides endpoints for sending messages, retrieving messages, and marking messages as read.
|
||||
* REST API controller for message operations. Provides endpoints for sending
|
||||
* messages, retrieving messages, and marking messages as read.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/messages")
|
||||
@@ -29,9 +29,8 @@ public class MessageApiController {
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a general message to a client
|
||||
* POST /api/messages/send
|
||||
* Body: { "content": "message text", "receiver": "appUserId", "contentType": "TEXT|IMAGE" }
|
||||
* Send a general message to a client POST /api/messages/send Body: { "content":
|
||||
* "message text", "receiver": "appUserId", "contentType": "TEXT|IMAGE" }
|
||||
*/
|
||||
@PostMapping("/send")
|
||||
public ResponseEntity<Message> sendGeneralMessage(@RequestBody Map<String, String> request) {
|
||||
@@ -40,8 +39,7 @@ public class MessageApiController {
|
||||
String receiver = request.get("receiver");
|
||||
MessageContentType contentType = resolveContentType(request.get("contentType"));
|
||||
|
||||
if (content == null || content.isBlank() ||
|
||||
receiver == null || receiver.isBlank()) {
|
||||
if (content == null || content.isBlank() || receiver == null || receiver.isBlank()) {
|
||||
log.warn("Invalid message request: missing required fields");
|
||||
return ResponseEntity.badRequest().build();
|
||||
}
|
||||
@@ -60,10 +58,9 @@ public class MessageApiController {
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a job-related message to a client
|
||||
* POST /api/messages/send-job-message
|
||||
* Body: { "content": "message text", "receiver": "appUserId",
|
||||
* "jobId": "job id", "jobNumber": "job number", "contentType": "TEXT|IMAGE" }
|
||||
* Send a job-related message to a client POST /api/messages/send-job-message
|
||||
* Body: { "content": "message text", "receiver": "appUserId", "jobId": "job
|
||||
* id", "jobNumber": "job number", "contentType": "TEXT|IMAGE" }
|
||||
*/
|
||||
@PostMapping("/send-job-message")
|
||||
public ResponseEntity<Message> sendJobMessage(@RequestBody Map<String, String> request) {
|
||||
@@ -74,9 +71,8 @@ public class MessageApiController {
|
||||
String jobNumber = request.get("jobNumber");
|
||||
MessageContentType contentType = resolveContentType(request.get("contentType"));
|
||||
|
||||
if (content == null || content.isBlank() ||
|
||||
receiver == null || receiver.isBlank() ||
|
||||
jobIdStr == null || jobIdStr.isBlank()) {
|
||||
if (content == null || content.isBlank() || receiver == null || receiver.isBlank() || jobIdStr == null
|
||||
|| jobIdStr.isBlank()) {
|
||||
log.warn("Invalid job message request: missing required fields");
|
||||
return ResponseEntity.badRequest().build();
|
||||
}
|
||||
@@ -96,8 +92,8 @@ public class MessageApiController {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all messages for a specific receiver
|
||||
* GET /api/messages/receiver/{username}
|
||||
* Get all messages for a specific receiver GET
|
||||
* /api/messages/receiver/{username}
|
||||
*/
|
||||
@GetMapping("/receiver/{username}")
|
||||
public ResponseEntity<List<Message>> getMessagesForReceiver(@PathVariable String username) {
|
||||
@@ -111,8 +107,8 @@ public class MessageApiController {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all unread messages for a specific receiver
|
||||
* GET /api/messages/receiver/{username}/unread
|
||||
* Get all unread messages for a specific receiver GET
|
||||
* /api/messages/receiver/{username}/unread
|
||||
*/
|
||||
@GetMapping("/receiver/{username}/unread")
|
||||
public ResponseEntity<List<Message>> getUnreadMessagesForReceiver(@PathVariable String username) {
|
||||
@@ -126,8 +122,8 @@ public class MessageApiController {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get unread message count for a specific receiver
|
||||
* GET /api/messages/receiver/{username}/unread-count
|
||||
* Get unread message count for a specific receiver GET
|
||||
* /api/messages/receiver/{username}/unread-count
|
||||
*/
|
||||
@GetMapping("/receiver/{username}/unread-count")
|
||||
public ResponseEntity<Map<String, Long>> getUnreadMessageCount(@PathVariable String username) {
|
||||
@@ -141,8 +137,7 @@ public class MessageApiController {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all messages related to a specific job
|
||||
* GET /api/messages/job/{jobId}
|
||||
* Get all messages related to a specific job GET /api/messages/job/{jobId}
|
||||
*/
|
||||
@GetMapping("/job/{jobId}")
|
||||
public ResponseEntity<List<Message>> getMessagesForJob(@PathVariable String jobId) {
|
||||
@@ -160,8 +155,7 @@ public class MessageApiController {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all messages (for admin/overview)
|
||||
* GET /api/messages/all
|
||||
* Get all messages (for admin/overview) GET /api/messages/all
|
||||
*/
|
||||
@GetMapping("/all")
|
||||
public ResponseEntity<List<Message>> getAllMessages() {
|
||||
@@ -175,8 +169,8 @@ public class MessageApiController {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get messages by origin (incoming/outgoing/server)
|
||||
* GET /api/messages/origin/{origin}
|
||||
* Get messages by origin (incoming/outgoing/server) GET
|
||||
* /api/messages/origin/{origin}
|
||||
*/
|
||||
@GetMapping("/origin/{origin}")
|
||||
public ResponseEntity<List<Message>> getMessagesByOrigin(@PathVariable String origin) {
|
||||
@@ -194,8 +188,7 @@ public class MessageApiController {
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a message as read
|
||||
* PUT /api/messages/{messageId}/mark-read
|
||||
* Mark a message as read PUT /api/messages/{messageId}/mark-read
|
||||
*/
|
||||
@PutMapping("/{messageId}/mark-read")
|
||||
public ResponseEntity<Void> markMessageAsRead(@PathVariable String messageId) {
|
||||
@@ -213,8 +206,7 @@ public class MessageApiController {
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a message
|
||||
* DELETE /api/messages/{messageId}
|
||||
* Delete a message DELETE /api/messages/{messageId}
|
||||
*/
|
||||
@DeleteMapping("/{messageId}")
|
||||
public ResponseEntity<Void> deleteMessage(@PathVariable String messageId) {
|
||||
|
||||
@@ -76,9 +76,9 @@ public class MessageController {
|
||||
public MessageController(MqttPublisher mqttPublisher, AppUserRepository appUserRepository,
|
||||
AppUserService appUserService, JobRepository jobRepository, CargoItemRepository cargoItemRepository,
|
||||
TaskRepository taskRepository, PhotoRepository photoRepository, BarcodeRepository barcodeRepository,
|
||||
SignatureRepository signatureRepository, CommentRepository commentRepository, JobHistoryService jobHistoryService,
|
||||
EmailService emailService, MessageService messageService, ObjectMapper objectMapper,
|
||||
ClientConnectionService clientConnectionService) {
|
||||
SignatureRepository signatureRepository, CommentRepository commentRepository,
|
||||
JobHistoryService jobHistoryService, EmailService emailService, MessageService messageService,
|
||||
ObjectMapper objectMapper, ClientConnectionService clientConnectionService) {
|
||||
this.mqttPublisher = mqttPublisher;
|
||||
this.appUserRepository = appUserRepository;
|
||||
this.appUserService = appUserService;
|
||||
@@ -181,8 +181,8 @@ public class MessageController {
|
||||
List<Job> allJobs = jobRepository.findAll();
|
||||
log.info("DEBUG: Total jobs in database: {}", allJobs.size());
|
||||
for (Job job : allJobs) {
|
||||
log.info("DEBUG: Job {} (number: {}) has app_user='{}', digitalProcessing={}",
|
||||
job.getIdAsString(), job.getJobNumber(), job.getAppUser(), job.isDigitalProcessing());
|
||||
log.info("DEBUG: Job {} (number: {}) has app_user='{}', digitalProcessing={}", job.getIdAsString(),
|
||||
job.getJobNumber(), job.getAppUser(), job.isDigitalProcessing());
|
||||
}
|
||||
|
||||
// For each job, fetch related cargo items and tasks (ordered by task order)
|
||||
@@ -304,7 +304,8 @@ public class MessageController {
|
||||
if (extra instanceof Map<?, ?> extraData) {
|
||||
Object barcodesObj = extraData.get("barcodes");
|
||||
if (barcodesObj instanceof List<?> barcodesList) {
|
||||
// Suppressing unchecked cast warning as extraData structure is validated from MQTT payload
|
||||
// Suppressing unchecked cast warning as extraData structure is validated from
|
||||
// MQTT payload
|
||||
@SuppressWarnings("unchecked")
|
||||
List<String> barcodes = (List<String>) barcodesList;
|
||||
|
||||
@@ -404,15 +405,15 @@ public class MessageController {
|
||||
if (extra instanceof Map<?, ?> extraData) {
|
||||
Object photosObj = extraData.get("photos");
|
||||
if (photosObj instanceof List<?> photosList) {
|
||||
// Suppressing unchecked cast warning as extraData structure is validated from MQTT payload
|
||||
// Suppressing unchecked cast warning as extraData structure is validated from
|
||||
// MQTT payload
|
||||
@SuppressWarnings("unchecked")
|
||||
List<String> photos = (List<String>) photosList;
|
||||
|
||||
if (!photos.isEmpty()) {
|
||||
for (String photoString : photos) {
|
||||
String completedBy = task.getCompletedBy() != null ? task.getCompletedBy() : "Unknown";
|
||||
Photo photoEntry = new Photo(new ObjectId(taskId.toString()), photoString,
|
||||
completedBy);
|
||||
Photo photoEntry = new Photo(new ObjectId(taskId.toString()), photoString, completedBy);
|
||||
|
||||
photoRepository.save(photoEntry);
|
||||
}
|
||||
@@ -609,9 +610,8 @@ public class MessageController {
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle pong response from a client.
|
||||
* Client sends to /server/{clientId}/pong with payload { timestamp }.
|
||||
* Used for connection monitoring.
|
||||
* Handle pong response from a client. Client sends to /server/{clientId}/pong
|
||||
* with payload { timestamp }. Used for connection monitoring.
|
||||
*/
|
||||
public void handlePong(Map<String, Object> payload) {
|
||||
String clientId = payload.get("clientId") != null ? payload.get("clientId").toString() : null;
|
||||
@@ -625,14 +625,10 @@ public class MessageController {
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming message from a client via MQTT.
|
||||
* Client sends to /server/{clientId}/message with payload:
|
||||
* {
|
||||
* "content": "message payload",
|
||||
* "contentType": "TEXT|IMAGE",
|
||||
* "jobId": "optional job id",
|
||||
* "jobNumber": "optional job number"
|
||||
* }
|
||||
* Handle incoming message from a client via MQTT. Client sends to
|
||||
* /server/{clientId}/message with payload: { "content": "message payload",
|
||||
* "contentType": "TEXT|IMAGE", "jobId": "optional job id", "jobNumber":
|
||||
* "optional job number" }
|
||||
*
|
||||
* The clientId is extracted from the MQTT topic and represents the AppUser ID.
|
||||
* This clientId is stored as the receiver field in the message.
|
||||
|
||||
@@ -8,8 +8,8 @@ import org.bson.types.ObjectId;
|
||||
* Normalized payload for chat messages sent by mobile clients via MQTT.
|
||||
* receiver = AppUser ID (clientId) extracted from MQTT topic
|
||||
*/
|
||||
public record ChatMessageInboundPayload(String receiver, String content,
|
||||
MessageContentType contentType, ObjectId jobId, String jobNumber) {
|
||||
public record ChatMessageInboundPayload(String receiver, String content, MessageContentType contentType, ObjectId jobId,
|
||||
String jobNumber) {
|
||||
|
||||
public ChatMessageInboundPayload {
|
||||
contentType = contentType != null ? contentType : MessageContentType.TEXT;
|
||||
@@ -58,8 +58,7 @@ public record ChatMessageInboundPayload(String receiver, String content,
|
||||
try {
|
||||
return new ObjectId(candidate);
|
||||
} catch (IllegalArgumentException ex) {
|
||||
throw new IllegalArgumentException(
|
||||
"Field '%s' must be a valid MongoDB ObjectId".formatted(fieldName), ex);
|
||||
throw new IllegalArgumentException("Field '%s' must be a valid MongoDB ObjectId".formatted(fieldName), ex);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,32 +7,16 @@ import de.assecutor.votianlt.model.MessageType;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* Outbound chat message payload published to MQTT subscribers.
|
||||
* The receiver is implicit from the MQTT topic (/client/{appUserId}/message)
|
||||
* Outbound chat message payload published to MQTT subscribers. The receiver is
|
||||
* implicit from the MQTT topic (/client/{appUserId}/message)
|
||||
*/
|
||||
public record ChatMessageOutboundPayload(
|
||||
String messageId,
|
||||
String content,
|
||||
MessageContentType contentType,
|
||||
MessageOrigin origin,
|
||||
MessageType messageType,
|
||||
LocalDateTime createdAt,
|
||||
String jobId,
|
||||
String jobNumber,
|
||||
boolean read
|
||||
) {
|
||||
public record ChatMessageOutboundPayload(String messageId, String content, MessageContentType contentType,
|
||||
MessageOrigin origin, MessageType messageType, LocalDateTime createdAt, String jobId, String jobNumber,
|
||||
boolean read) {
|
||||
|
||||
public static ChatMessageOutboundPayload fromMessage(Message message) {
|
||||
return new ChatMessageOutboundPayload(
|
||||
message.getIdAsString(),
|
||||
message.getContent(),
|
||||
message.getContentType(),
|
||||
message.getOrigin(),
|
||||
message.getMessageType(),
|
||||
message.getCreatedAt(),
|
||||
message.getJobIdAsString(),
|
||||
message.getJobNumber(),
|
||||
message.isRead()
|
||||
);
|
||||
return new ChatMessageOutboundPayload(message.getIdAsString(), message.getContent(), message.getContentType(),
|
||||
message.getOrigin(), message.getMessageType(), message.getCreatedAt(), message.getJobIdAsString(),
|
||||
message.getJobNumber(), message.isRead());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,8 @@ package de.assecutor.votianlt.event;
|
||||
import org.springframework.context.ApplicationEvent;
|
||||
|
||||
/**
|
||||
* Event published when message read status changes (e.g., messages marked as read)
|
||||
* This allows UI components like the sidebar badge to update accordingly
|
||||
* Event published when message read status changes (e.g., messages marked as
|
||||
* read) This allows UI components like the sidebar badge to update accordingly
|
||||
*/
|
||||
public class MessageReadStatusChangedEvent extends ApplicationEvent {
|
||||
|
||||
@@ -12,4 +12,3 @@ public class MessageReadStatusChangedEvent extends ApplicationEvent {
|
||||
super(source);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,8 +10,8 @@ import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/**
|
||||
* Configuration for the MCP (Model Context Protocol) server.
|
||||
* Registers all MCP tools for job statistics and queries.
|
||||
* Configuration for the MCP (Model Context Protocol) server. Registers all MCP
|
||||
* tools for job statistics and queries.
|
||||
*/
|
||||
@Configuration
|
||||
@Slf4j
|
||||
|
||||
@@ -27,8 +27,7 @@ public class JobQueryTool {
|
||||
|
||||
@Tool(description = "Query jobs with optional filters. Returns a list of jobs matching the criteria.")
|
||||
public List<JobQueryResult> queryJobs(
|
||||
@ToolParam(description = "Optional: Job status filter (CREATED, IN_PROGRESS, PICKUP_SCHEDULED, PICKED_UP, IN_TRANSIT, DELIVERED, COMPLETED, CANCELLED)")
|
||||
String status,
|
||||
@ToolParam(description = "Optional: Job status filter (CREATED, IN_PROGRESS, PICKUP_SCHEDULED, PICKED_UP, IN_TRANSIT, DELIVERED, COMPLETED, CANCELLED)") String status,
|
||||
@ToolParam(description = "Optional: Customer name filter") String customer,
|
||||
@ToolParam(description = "Optional: Pickup city filter") String pickupCity,
|
||||
@ToolParam(description = "Optional: Delivery city filter") String deliveryCity,
|
||||
@@ -52,10 +51,7 @@ public class JobQueryTool {
|
||||
jobs = statisticsService.getLatestJobs(actualLimit);
|
||||
}
|
||||
|
||||
return jobs.stream()
|
||||
.limit(actualLimit)
|
||||
.map(this::toQueryResult)
|
||||
.toList();
|
||||
return jobs.stream().limit(actualLimit).map(this::toQueryResult).toList();
|
||||
}
|
||||
|
||||
@Tool(description = "Get detailed information about a specific job by its job number")
|
||||
@@ -71,13 +67,10 @@ public class JobQueryTool {
|
||||
}
|
||||
|
||||
@Tool(description = "Get jobs assigned to a specific mobile app user")
|
||||
public List<JobQueryResult> getJobsByAppUser(
|
||||
@ToolParam(description = "App user identifier") String appUser) {
|
||||
public List<JobQueryResult> getJobsByAppUser(@ToolParam(description = "App user identifier") String appUser) {
|
||||
log.info("MCP Tool: Getting jobs for app user: {}", appUser);
|
||||
|
||||
return statisticsService.getJobsByAppUser(appUser).stream()
|
||||
.map(this::toQueryResult)
|
||||
.toList();
|
||||
return statisticsService.getJobsByAppUser(appUser).stream().map(this::toQueryResult).toList();
|
||||
}
|
||||
|
||||
@Tool(description = "Get the most recent jobs, sorted by creation date descending")
|
||||
@@ -86,9 +79,7 @@ public class JobQueryTool {
|
||||
log.info("MCP Tool: Getting latest jobs, limit: {}", limit);
|
||||
|
||||
int actualLimit = limit != null ? limit : 10;
|
||||
return statisticsService.getLatestJobs(actualLimit).stream()
|
||||
.map(this::toQueryResult)
|
||||
.toList();
|
||||
return statisticsService.getLatestJobs(actualLimit).stream().map(this::toQueryResult).toList();
|
||||
}
|
||||
|
||||
@Tool(description = "Get jobs created within a specific date range")
|
||||
@@ -102,27 +93,17 @@ public class JobQueryTool {
|
||||
LocalDateTime end = LocalDateTime.parse(endDate);
|
||||
int actualLimit = limit != null ? limit : 100;
|
||||
|
||||
return statisticsService.getJobsByDateRange(start, end).stream()
|
||||
.limit(actualLimit)
|
||||
.map(this::toQueryResult)
|
||||
return statisticsService.getJobsByDateRange(start, end).stream().limit(actualLimit).map(this::toQueryResult)
|
||||
.toList();
|
||||
}
|
||||
|
||||
private JobQueryResult toQueryResult(Job job) {
|
||||
return JobQueryResult.builder()
|
||||
.jobId(job.getIdAsString())
|
||||
.jobNumber(job.getJobNumber())
|
||||
return JobQueryResult.builder().jobId(job.getIdAsString()).jobNumber(job.getJobNumber())
|
||||
.status(job.getStatus() != null ? job.getStatus().name() : null)
|
||||
.statusDisplayName(job.getStatus() != null ? job.getStatus().getDisplayName() : null)
|
||||
.customer(job.getCustomerSelection())
|
||||
.pickupCity(job.getPickupCity())
|
||||
.deliveryCity(job.getDeliveryCity())
|
||||
.pickupDate(job.getPickupDate())
|
||||
.deliveryDate(job.getDeliveryDate())
|
||||
.price(job.getPrice())
|
||||
.createdAt(job.getCreatedAt())
|
||||
.assignedAppUser(job.getAppUser())
|
||||
.digitalProcessing(job.isDigitalProcessing())
|
||||
.build();
|
||||
.customer(job.getCustomerSelection()).pickupCity(job.getPickupCity())
|
||||
.deliveryCity(job.getDeliveryCity()).pickupDate(job.getPickupDate()).deliveryDate(job.getDeliveryDate())
|
||||
.price(job.getPrice()).createdAt(job.getCreatedAt()).assignedAppUser(job.getAppUser())
|
||||
.digitalProcessing(job.isDigitalProcessing()).build();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,8 +17,8 @@ import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* MCP Tool for job statistics queries.
|
||||
* Provides various statistics and aggregations about jobs.
|
||||
* MCP Tool for job statistics queries. Provides various statistics and
|
||||
* aggregations about jobs.
|
||||
*/
|
||||
@Component
|
||||
@Slf4j
|
||||
@@ -42,16 +42,10 @@ public class JobStatisticsTool {
|
||||
long cancelled = countsByStatus.getOrDefault(JobStatus.CANCELLED, 0L);
|
||||
long inProgress = countsByStatus.getOrDefault(JobStatus.IN_PROGRESS, 0L);
|
||||
|
||||
return JobStatisticsResult.builder()
|
||||
.countsByStatus(statusCounts)
|
||||
.totalJobs(statisticsService.getTotalJobCount())
|
||||
.completedJobs(completed)
|
||||
.cancelledJobs(cancelled)
|
||||
.inProgressJobs(inProgress)
|
||||
.completionRate(statisticsService.getCompletionRate())
|
||||
.totalRevenue(statisticsService.getTotalRevenue())
|
||||
.queryTimestamp(LocalDateTime.now())
|
||||
.build();
|
||||
return JobStatisticsResult.builder().countsByStatus(statusCounts)
|
||||
.totalJobs(statisticsService.getTotalJobCount()).completedJobs(completed).cancelledJobs(cancelled)
|
||||
.inProgressJobs(inProgress).completionRate(statisticsService.getCompletionRate())
|
||||
.totalRevenue(statisticsService.getTotalRevenue()).queryTimestamp(LocalDateTime.now()).build();
|
||||
}
|
||||
|
||||
@Tool(description = "Get job counts grouped by status (CREATED, IN_PROGRESS, PICKUP_SCHEDULED, PICKED_UP, IN_TRANSIT, DELIVERED, COMPLETED, CANCELLED)")
|
||||
@@ -59,10 +53,8 @@ public class JobStatisticsTool {
|
||||
log.info("MCP Tool: Getting job counts by status");
|
||||
|
||||
Map<JobStatus, Long> counts = statisticsService.getJobCountsByStatus();
|
||||
return counts.entrySet().stream()
|
||||
.collect(Collectors.toMap(
|
||||
e -> e.getKey().name() + " (" + e.getKey().getDisplayName() + ")",
|
||||
Map.Entry::getValue));
|
||||
return counts.entrySet().stream().collect(Collectors
|
||||
.toMap(e -> e.getKey().name() + " (" + e.getKey().getDisplayName() + ")", Map.Entry::getValue));
|
||||
}
|
||||
|
||||
@Tool(description = "Get the completion rate as a percentage (completed jobs / total jobs * 100)")
|
||||
@@ -79,17 +71,12 @@ public class JobStatisticsTool {
|
||||
log.info("MCP Tool: Getting revenue by customer, limit: {}", limit);
|
||||
|
||||
int actualLimit = limit != null ? limit : 10;
|
||||
return statisticsService.getTopCustomersByRevenue(actualLimit).stream()
|
||||
.map(entry -> {
|
||||
String customer = entry.getKey();
|
||||
long jobCount = statisticsService.getJobsByCustomer(customer).size();
|
||||
return CustomerRevenueResult.builder()
|
||||
.customer(customer)
|
||||
.revenue(entry.getValue())
|
||||
.jobCount(jobCount)
|
||||
.build();
|
||||
})
|
||||
.toList();
|
||||
return statisticsService.getTopCustomersByRevenue(actualLimit).stream().map(entry -> {
|
||||
String customer = entry.getKey();
|
||||
long jobCount = statisticsService.getJobsByCustomer(customer).size();
|
||||
return CustomerRevenueResult.builder().customer(customer).revenue(entry.getValue()).jobCount(jobCount)
|
||||
.build();
|
||||
}).toList();
|
||||
}
|
||||
|
||||
@Tool(description = "Get monthly job trend data for a specific year showing job counts per month")
|
||||
@@ -99,9 +86,7 @@ public class JobStatisticsTool {
|
||||
|
||||
Map<Month, Long> monthlyData = statisticsService.getMonthlyJobCounts(year);
|
||||
return monthlyData.entrySet().stream()
|
||||
.collect(Collectors.toMap(
|
||||
e -> e.getKey().toString(),
|
||||
Map.Entry::getValue));
|
||||
.collect(Collectors.toMap(e -> e.getKey().toString(), Map.Entry::getValue));
|
||||
}
|
||||
|
||||
@Tool(description = "Get total revenue from all jobs")
|
||||
|
||||
@@ -32,12 +32,8 @@ public class TaskCompletionTool {
|
||||
|
||||
double completionRate = total > 0 ? (double) completed / total * 100.0 : 0.0;
|
||||
|
||||
return TaskCompletionResult.builder()
|
||||
.totalTasks(total)
|
||||
.completedTasks(completed)
|
||||
.pendingTasks(pending)
|
||||
.completionRate(completionRate)
|
||||
.build();
|
||||
return TaskCompletionResult.builder().totalTasks(total).completedTasks(completed).pendingTasks(pending)
|
||||
.completionRate(completionRate).build();
|
||||
}
|
||||
|
||||
@Tool(description = "Get a summary of task completion as a formatted string")
|
||||
@@ -51,8 +47,7 @@ public class TaskCompletionTool {
|
||||
|
||||
double completionRate = total > 0 ? (double) completed / total * 100.0 : 0.0;
|
||||
|
||||
return String.format(
|
||||
"Task Statistics: %d total tasks, %d completed (%.1f%%), %d pending",
|
||||
total, completed, completionRate, pending);
|
||||
return String.format("Task Statistics: %d total tasks, %d completed (%.1f%%), %d pending", total, completed,
|
||||
completionRate, pending);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,8 +20,8 @@ import java.nio.charset.StandardCharsets;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Configuration for the plugin-based messaging system.
|
||||
* Initializes the selected plugin and sets up message routing.
|
||||
* Configuration for the plugin-based messaging system. Initializes the selected
|
||||
* plugin and sets up message routing.
|
||||
*/
|
||||
@Configuration
|
||||
@Slf4j
|
||||
@@ -54,8 +54,9 @@ public class PluginMessagingConfig {
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the messaging plugin after application startup.
|
||||
* This method is called after all beans are created, so we can safely access MessageDeliveryService.
|
||||
* Initialize the messaging plugin after application startup. This method is
|
||||
* called after all beans are created, so we can safely access
|
||||
* MessageDeliveryService.
|
||||
*/
|
||||
@EventListener(ApplicationReadyEvent.class)
|
||||
public void initializePlugin(ApplicationReadyEvent event) {
|
||||
@@ -66,9 +67,11 @@ public class PluginMessagingConfig {
|
||||
PluginConfig config = createPluginConfig(pluginType);
|
||||
|
||||
// Get beans from context (after all beans are created)
|
||||
MessageDeliveryService deliveryService = event.getApplicationContext().getBean(MessageDeliveryService.class);
|
||||
MessageDeliveryService deliveryService = event.getApplicationContext()
|
||||
.getBean(MessageDeliveryService.class);
|
||||
MessageController messageController = event.getApplicationContext().getBean(MessageController.class);
|
||||
ClientConnectionService clientConnectionService = event.getApplicationContext().getBean(ClientConnectionService.class);
|
||||
ClientConnectionService clientConnectionService = event.getApplicationContext()
|
||||
.getBean(ClientConnectionService.class);
|
||||
|
||||
// Set up a listener to subscribe when connected
|
||||
log.info("[PluginMessagingConfig] Adding state listener");
|
||||
@@ -89,10 +92,12 @@ public class PluginMessagingConfig {
|
||||
});
|
||||
log.info("[PluginMessagingConfig] State listener added");
|
||||
|
||||
// Activate plugin (this will trigger connection and eventually the listener above)
|
||||
// Activate plugin (this will trigger connection and eventually the listener
|
||||
// above)
|
||||
pluginManager.activatePlugin(plugin, config);
|
||||
|
||||
log.info("[PluginMessagingConfig] Plugin activation initiated, subscriptions will be set up when connected");
|
||||
log.info(
|
||||
"[PluginMessagingConfig] Plugin activation initiated, subscriptions will be set up when connected");
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("[PluginMessagingConfig] Failed to initialize plugin: {}", e.getMessage(), e);
|
||||
@@ -105,11 +110,11 @@ public class PluginMessagingConfig {
|
||||
*/
|
||||
private MessagingPlugin createPlugin(String type) {
|
||||
return switch (type.toLowerCase()) {
|
||||
case "mqtt" -> new MqttMessagingPlugin();
|
||||
// Add more plugin types here in the future
|
||||
// case "websocket" -> new WebSocketMessagingPlugin();
|
||||
// case "grpc" -> new GrpcMessagingPlugin();
|
||||
default -> throw new IllegalArgumentException("Unknown plugin type: " + type);
|
||||
case "mqtt" -> new MqttMessagingPlugin();
|
||||
// Add more plugin types here in the future
|
||||
// case "websocket" -> new WebSocketMessagingPlugin();
|
||||
// case "grpc" -> new GrpcMessagingPlugin();
|
||||
default -> throw new IllegalArgumentException("Unknown plugin type: " + type);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -120,17 +125,17 @@ public class PluginMessagingConfig {
|
||||
PluginConfig config = new PluginConfig();
|
||||
|
||||
switch (type.toLowerCase()) {
|
||||
case "mqtt" -> {
|
||||
config.setProperty("broker.host", mqttBrokerHost);
|
||||
config.setProperty("broker.port", mqttBrokerPort);
|
||||
config.setProperty("username", mqttUsername);
|
||||
config.setProperty("password", mqttPassword);
|
||||
config.setProperty("client.id", mqttClientId);
|
||||
config.setProperty("auto.reconnect", true);
|
||||
config.setProperty("clean.start", true);
|
||||
}
|
||||
// Add more plugin configurations here
|
||||
default -> throw new IllegalArgumentException("Unknown plugin type: " + type);
|
||||
case "mqtt" -> {
|
||||
config.setProperty("broker.host", mqttBrokerHost);
|
||||
config.setProperty("broker.port", mqttBrokerPort);
|
||||
config.setProperty("username", mqttUsername);
|
||||
config.setProperty("password", mqttPassword);
|
||||
config.setProperty("client.id", mqttClientId);
|
||||
config.setProperty("auto.reconnect", true);
|
||||
config.setProperty("clean.start", true);
|
||||
}
|
||||
// Add more plugin configurations here
|
||||
default -> throw new IllegalArgumentException("Unknown plugin type: " + type);
|
||||
}
|
||||
|
||||
return config;
|
||||
@@ -139,9 +144,8 @@ public class PluginMessagingConfig {
|
||||
/**
|
||||
* Setup message subscriptions using the new plugin API.
|
||||
*/
|
||||
private void setupSubscriptions(MessageDeliveryService deliveryService,
|
||||
MessageController messageController,
|
||||
ClientConnectionService clientConnectionService) {
|
||||
private void setupSubscriptions(MessageDeliveryService deliveryService, MessageController messageController,
|
||||
ClientConnectionService clientConnectionService) {
|
||||
log.info("[PluginMessagingConfig] Setting up message subscriptions");
|
||||
|
||||
try {
|
||||
@@ -153,7 +157,8 @@ public class PluginMessagingConfig {
|
||||
|
||||
// ACK messages are wrapped in MessageEnvelope
|
||||
MessageEnvelope envelope = objectMapper.readValue(json, MessageEnvelope.class);
|
||||
AcknowledgmentMessage ack = objectMapper.convertValue(envelope.getPayload(), AcknowledgmentMessage.class);
|
||||
AcknowledgmentMessage ack = objectMapper.convertValue(envelope.getPayload(),
|
||||
AcknowledgmentMessage.class);
|
||||
deliveryService.handleAcknowledgment(ack);
|
||||
} catch (Exception e) {
|
||||
log.error("[PluginMessagingConfig] Error handling ACK message: {}", e.getMessage(), e);
|
||||
@@ -161,17 +166,12 @@ public class PluginMessagingConfig {
|
||||
});
|
||||
|
||||
// Register message handlers for different message types
|
||||
String[] messageTypes = {
|
||||
"task_completed",
|
||||
"jobs/assigned",
|
||||
"message",
|
||||
"login",
|
||||
"pong"
|
||||
};
|
||||
String[] messageTypes = { "task_completed", "jobs/assigned", "message", "login", "pong" };
|
||||
|
||||
for (String messageType : messageTypes) {
|
||||
pluginManager.registerMessageHandler(messageType, (clientId, payload) ->
|
||||
handleEnvelopedMessage(clientId, payload, deliveryService, messageController, clientConnectionService));
|
||||
pluginManager.registerMessageHandler(messageType,
|
||||
(clientId, payload) -> handleEnvelopedMessage(clientId, payload, deliveryService,
|
||||
messageController, clientConnectionService));
|
||||
}
|
||||
|
||||
log.info("[PluginMessagingConfig] Message subscriptions initialized");
|
||||
@@ -183,11 +183,11 @@ public class PluginMessagingConfig {
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming enveloped message.
|
||||
* Supports both new envelope format and legacy format for backwards compatibility.
|
||||
* Handle incoming enveloped message. Supports both new envelope format and
|
||||
* legacy format for backwards compatibility.
|
||||
*/
|
||||
private void handleEnvelopedMessage(String clientId, byte[] payload, MessageDeliveryService deliveryService,
|
||||
MessageController messageController, ClientConnectionService clientConnectionService) {
|
||||
MessageController messageController, ClientConnectionService clientConnectionService) {
|
||||
try {
|
||||
String json = new String(payload, StandardCharsets.UTF_8);
|
||||
log.info("[PluginMessagingConfig] Received JSON from client {}: {}", clientId, json);
|
||||
@@ -214,12 +214,12 @@ public class PluginMessagingConfig {
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle legacy message format (without envelope wrapper).
|
||||
* This supports older clients that don't use the envelope format.
|
||||
* Handle legacy message format (without envelope wrapper). This supports older
|
||||
* clients that don't use the envelope format.
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
private void handleLegacyMessage(String clientId, String json,
|
||||
MessageController messageController, ClientConnectionService clientConnectionService) {
|
||||
private void handleLegacyMessage(String clientId, String json, MessageController messageController,
|
||||
ClientConnectionService clientConnectionService) {
|
||||
try {
|
||||
Map<String, Object> payload = objectMapper.readValue(json, Map.class);
|
||||
log.info("[PluginMessagingConfig] Processing legacy message from client {}: {}", clientId, payload);
|
||||
@@ -263,8 +263,8 @@ public class PluginMessagingConfig {
|
||||
log.warn("[PluginMessagingConfig] Unknown legacy message format from client {}: {}", clientId, json);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("[PluginMessagingConfig] Error handling legacy message from client {}: {}", clientId, e.getMessage(), e);
|
||||
log.error("[PluginMessagingConfig] Error handling legacy message from client {}: {}", clientId,
|
||||
e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,8 +28,8 @@ public class AcknowledgmentHandler {
|
||||
}
|
||||
|
||||
/**
|
||||
* Route incoming message envelope to appropriate application handler.
|
||||
* Unwraps the envelope and delegates to MessageController.
|
||||
* Route incoming message envelope to appropriate application handler. Unwraps
|
||||
* the envelope and delegates to MessageController.
|
||||
*/
|
||||
public void routeIncomingMessage(MessageEnvelope envelope) {
|
||||
try {
|
||||
@@ -40,7 +40,8 @@ public class AcknowledgmentHandler {
|
||||
|
||||
// Convert payload to Map for routing
|
||||
Map<String, Object> payloadMap = objectMapper.convertValue(payload,
|
||||
new TypeReference<Map<String, Object>>() {});
|
||||
new TypeReference<Map<String, Object>>() {
|
||||
});
|
||||
|
||||
// Route based on topic pattern
|
||||
if (topic.matches("/server/.+/task_completed")) {
|
||||
@@ -58,8 +59,7 @@ public class AcknowledgmentHandler {
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("[AckHandler] Error routing message {}: {}",
|
||||
envelope.getMessageId(), e.getMessage(), e);
|
||||
log.error("[AckHandler] Error routing message {}: {}", envelope.getMessageId(), e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,8 +96,7 @@ public class AcknowledgmentHandler {
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle login request
|
||||
* Topic: /server/login
|
||||
* Handle login request Topic: /server/login
|
||||
*/
|
||||
private void handleLogin(Map<String, Object> payload) {
|
||||
try {
|
||||
@@ -146,4 +145,3 @@ public class AcknowledgmentHandler {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -59,4 +59,3 @@ public class DeliveryConfig {
|
||||
*/
|
||||
private int acknowledgedRetentionDays = 7;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,28 +6,37 @@ import java.util.Optional;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
/**
|
||||
* Service for reliable message delivery with acknowledgment tracking.
|
||||
* Provides guaranteed delivery with retry mechanism and acknowledgment handling.
|
||||
* Service for reliable message delivery with acknowledgment tracking. Provides
|
||||
* guaranteed delivery with retry mechanism and acknowledgment handling.
|
||||
*/
|
||||
public interface MessageDeliveryService {
|
||||
|
||||
/**
|
||||
* Send a message to a specific client with delivery tracking and acknowledgment.
|
||||
* Send a message to a specific client with delivery tracking and
|
||||
* acknowledgment.
|
||||
*
|
||||
* @param clientId The target client identifier
|
||||
* @param messageType The type of message (e.g., "jobs", "message", "auth", "task")
|
||||
* @param payload The message payload (will be serialized to JSON)
|
||||
* @param options Delivery options (retries, timeout, etc.)
|
||||
* @param clientId
|
||||
* The target client identifier
|
||||
* @param messageType
|
||||
* The type of message (e.g., "jobs", "message", "auth", "task")
|
||||
* @param payload
|
||||
* The message payload (will be serialized to JSON)
|
||||
* @param options
|
||||
* Delivery options (retries, timeout, etc.)
|
||||
* @return CompletableFuture with delivery receipt
|
||||
*/
|
||||
CompletableFuture<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.
|
||||
*
|
||||
* @param clientId The target client identifier
|
||||
* @param messageType The type of message
|
||||
* @param payload The message payload
|
||||
* @param clientId
|
||||
* The target client identifier
|
||||
* @param messageType
|
||||
* The type of message
|
||||
* @param payload
|
||||
* The message payload
|
||||
* @return CompletableFuture with delivery receipt
|
||||
*/
|
||||
default CompletableFuture<DeliveryReceipt> sendToClient(String clientId, String messageType, Object payload) {
|
||||
@@ -36,11 +45,17 @@ public interface MessageDeliveryService {
|
||||
|
||||
/**
|
||||
* Send a message with delivery tracking and acknowledgment.
|
||||
* @deprecated Use {@link #sendToClient(String, String, Object, DeliveryOptions)} instead
|
||||
*
|
||||
* @param topic The destination topic
|
||||
* @param payload The message payload (will be serialized to JSON)
|
||||
* @param options Delivery options (retries, timeout, etc.)
|
||||
* @deprecated Use
|
||||
* {@link #sendToClient(String, String, Object, DeliveryOptions)}
|
||||
* instead
|
||||
*
|
||||
* @param topic
|
||||
* The destination topic
|
||||
* @param payload
|
||||
* The message payload (will be serialized to JSON)
|
||||
* @param options
|
||||
* Delivery options (retries, timeout, etc.)
|
||||
* @return CompletableFuture with delivery receipt
|
||||
*/
|
||||
@Deprecated
|
||||
@@ -48,10 +63,13 @@ public interface MessageDeliveryService {
|
||||
|
||||
/**
|
||||
* Send a message with default delivery options.
|
||||
*
|
||||
* @deprecated Use {@link #sendToClient(String, String, Object)} instead
|
||||
*
|
||||
* @param topic The destination topic
|
||||
* @param payload The message payload
|
||||
* @param topic
|
||||
* The destination topic
|
||||
* @param payload
|
||||
* The message payload
|
||||
* @return CompletableFuture with delivery receipt
|
||||
*/
|
||||
@Deprecated
|
||||
@@ -60,25 +78,28 @@ public interface MessageDeliveryService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming message envelope from transport layer.
|
||||
* Extracts payload and routes to application layer.
|
||||
* Handle incoming message envelope from transport layer. Extracts payload and
|
||||
* routes to application layer.
|
||||
*
|
||||
* @param envelope The received message envelope
|
||||
* @param envelope
|
||||
* The received message envelope
|
||||
*/
|
||||
void handleIncomingMessage(MessageEnvelope envelope);
|
||||
|
||||
/**
|
||||
* Handle acknowledgment from client.
|
||||
* Updates delivery status and removes from pending queue.
|
||||
* Handle acknowledgment from client. Updates delivery status and removes from
|
||||
* pending queue.
|
||||
*
|
||||
* @param ack The acknowledgment message
|
||||
* @param ack
|
||||
* The acknowledgment message
|
||||
*/
|
||||
void handleAcknowledgment(AcknowledgmentMessage ack);
|
||||
|
||||
/**
|
||||
* Get the current delivery status for a message.
|
||||
*
|
||||
* @param messageId The message ID
|
||||
* @param messageId
|
||||
* The message ID
|
||||
* @return Optional containing the delivery status, or empty if not found
|
||||
*/
|
||||
Optional<DeliveryStatus> getDeliveryStatus(String messageId);
|
||||
@@ -86,29 +107,29 @@ public interface MessageDeliveryService {
|
||||
/**
|
||||
* Get detailed pending delivery information.
|
||||
*
|
||||
* @param messageId The message ID
|
||||
* @param messageId
|
||||
* The message ID
|
||||
* @return Optional containing the pending delivery, or empty if not found
|
||||
*/
|
||||
Optional<PendingDelivery> getPendingDelivery(String messageId);
|
||||
|
||||
/**
|
||||
* Retry all pending deliveries that are ready for retry.
|
||||
* Called by scheduled task.
|
||||
* Retry all pending deliveries that are ready for retry. Called by scheduled
|
||||
* task.
|
||||
*/
|
||||
void retryPendingDeliveries();
|
||||
|
||||
/**
|
||||
* Retry pending deliveries for a specific client.
|
||||
* Called when a client reconnects.
|
||||
* Retry pending deliveries for a specific client. Called when a client
|
||||
* reconnects.
|
||||
*
|
||||
* @param clientId The client identifier
|
||||
* @param clientId
|
||||
* The client identifier
|
||||
*/
|
||||
void retryPendingDeliveriesForClient(String clientId);
|
||||
|
||||
/**
|
||||
* Clean up expired and completed deliveries.
|
||||
* Called by scheduled task.
|
||||
* Clean up expired and completed deliveries. Called by scheduled task.
|
||||
*/
|
||||
void cleanupOldDeliveries();
|
||||
}
|
||||
|
||||
|
||||
@@ -45,12 +45,8 @@ public class MessageDeliveryServiceImpl implements MessageDeliveryService {
|
||||
|
||||
private ScheduledExecutorService retryScheduler;
|
||||
|
||||
public MessageDeliveryServiceImpl(
|
||||
PluginManager pluginManager,
|
||||
PendingDeliveryRepository pendingDeliveryRepository,
|
||||
AcknowledgmentHandler acknowledgmentHandler,
|
||||
DeliveryConfig config,
|
||||
ObjectMapper objectMapper,
|
||||
public MessageDeliveryServiceImpl(PluginManager pluginManager, PendingDeliveryRepository pendingDeliveryRepository,
|
||||
AcknowledgmentHandler acknowledgmentHandler, DeliveryConfig config, ObjectMapper objectMapper,
|
||||
ClientConnectionService clientConnectionService) {
|
||||
this.pluginManager = pluginManager;
|
||||
this.pendingDeliveryRepository = pendingDeliveryRepository;
|
||||
@@ -67,14 +63,10 @@ public class MessageDeliveryServiceImpl implements MessageDeliveryService {
|
||||
t.setDaemon(true);
|
||||
return t;
|
||||
});
|
||||
retryScheduler.scheduleAtFixedRate(
|
||||
this::retryPendingDeliveries,
|
||||
ackRetryIntervalSeconds,
|
||||
ackRetryIntervalSeconds,
|
||||
TimeUnit.SECONDS
|
||||
);
|
||||
log.info("[MessageDelivery] Started retry scheduler (interval: {}s, max retries: {})",
|
||||
ackRetryIntervalSeconds, ackMaxRetries);
|
||||
retryScheduler.scheduleAtFixedRate(this::retryPendingDeliveries, ackRetryIntervalSeconds,
|
||||
ackRetryIntervalSeconds, TimeUnit.SECONDS);
|
||||
log.info("[MessageDelivery] Started retry scheduler (interval: {}s, max retries: {})", ackRetryIntervalSeconds,
|
||||
ackMaxRetries);
|
||||
}
|
||||
|
||||
@PreDestroy
|
||||
@@ -94,7 +86,8 @@ public class MessageDeliveryServiceImpl implements MessageDeliveryService {
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompletableFuture<DeliveryReceipt> sendToClient(String clientId, String messageType, Object payload, DeliveryOptions options) {
|
||||
public CompletableFuture<DeliveryReceipt> sendToClient(String clientId, String messageType, Object payload,
|
||||
DeliveryOptions options) {
|
||||
try {
|
||||
String destination = clientId + "/" + messageType;
|
||||
final LocalDateTime expiresAt = options.calculateExpiryTime();
|
||||
@@ -105,19 +98,12 @@ public class MessageDeliveryServiceImpl implements MessageDeliveryService {
|
||||
byte[] envelopeData = json.getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
if (options.isRequiresAck()) {
|
||||
PendingDelivery pending = new PendingDelivery(
|
||||
messageId,
|
||||
destination,
|
||||
envelopeData,
|
||||
options.getMaxRetries(),
|
||||
expiresAt
|
||||
);
|
||||
PendingDelivery pending = new PendingDelivery(messageId, destination, envelopeData,
|
||||
options.getMaxRetries(), expiresAt);
|
||||
pendingDeliveryRepository.save(pending);
|
||||
}
|
||||
|
||||
SendOptions sendOptions = SendOptions.builder()
|
||||
.qos(options.getQos())
|
||||
.retained(options.isRetained())
|
||||
SendOptions sendOptions = SendOptions.builder().qos(options.getQos()).retained(options.isRetained())
|
||||
.build();
|
||||
|
||||
final boolean requiresAck = options.isRequiresAck();
|
||||
@@ -125,20 +111,18 @@ public class MessageDeliveryServiceImpl implements MessageDeliveryService {
|
||||
|
||||
log.info("[MessageDelivery] Sending message {} to client {} (type: {})", messageId, clientId, messageType);
|
||||
|
||||
return pluginManager.sendToClient(clientId, messageType, envelopeData, sendOptions)
|
||||
.thenApply(v -> {
|
||||
if (requiresAck) {
|
||||
updatePendingDeliveryAfterSend(messageId, ackTimeout);
|
||||
}
|
||||
return DeliveryReceipt.submitted(messageId, destination, expiresAt);
|
||||
})
|
||||
.exceptionally(ex -> {
|
||||
log.error("[MessageDelivery] Failed to send message {}: {}", messageId, ex.getMessage());
|
||||
if (requiresAck) {
|
||||
markPendingDeliveryFailed(messageId, ex.getMessage());
|
||||
}
|
||||
return DeliveryReceipt.failed(messageId, destination);
|
||||
});
|
||||
return pluginManager.sendToClient(clientId, messageType, envelopeData, sendOptions).thenApply(v -> {
|
||||
if (requiresAck) {
|
||||
updatePendingDeliveryAfterSend(messageId, ackTimeout);
|
||||
}
|
||||
return DeliveryReceipt.submitted(messageId, destination, expiresAt);
|
||||
}).exceptionally(ex -> {
|
||||
log.error("[MessageDelivery] Failed to send message {}: {}", messageId, ex.getMessage());
|
||||
if (requiresAck) {
|
||||
markPendingDeliveryFailed(messageId, ex.getMessage());
|
||||
}
|
||||
return DeliveryReceipt.failed(messageId, destination);
|
||||
});
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("[MessageDelivery] Error creating message for client {}: {}", clientId, e.getMessage());
|
||||
@@ -162,8 +146,7 @@ public class MessageDeliveryServiceImpl implements MessageDeliveryService {
|
||||
@Override
|
||||
public void handleIncomingMessage(MessageEnvelope envelope) {
|
||||
try {
|
||||
log.info("[MessageDelivery] Received message {} on topic {}",
|
||||
envelope.getMessageId(), envelope.getTopic());
|
||||
log.info("[MessageDelivery] Received message {} on topic {}", envelope.getMessageId(), envelope.getTopic());
|
||||
|
||||
if (envelope.isRequiresAck()) {
|
||||
sendAcknowledgment(envelope);
|
||||
@@ -172,16 +155,15 @@ public class MessageDeliveryServiceImpl implements MessageDeliveryService {
|
||||
acknowledgmentHandler.routeIncomingMessage(envelope);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("[MessageDelivery] Error handling incoming message {}: {}",
|
||||
envelope.getMessageId(), e.getMessage());
|
||||
log.error("[MessageDelivery] Error handling incoming message {}: {}", envelope.getMessageId(),
|
||||
e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleAcknowledgment(AcknowledgmentMessage ack) {
|
||||
try {
|
||||
log.info("[MessageDelivery] Received ACK for message {} (status: {})",
|
||||
ack.getMessageId(), ack.getStatus());
|
||||
log.info("[MessageDelivery] Received ACK for message {} (status: {})", ack.getMessageId(), ack.getStatus());
|
||||
|
||||
Optional<PendingDelivery> pendingOpt = pendingDeliveryRepository.findByMessageId(ack.getMessageId());
|
||||
|
||||
@@ -192,27 +174,25 @@ public class MessageDeliveryServiceImpl implements MessageDeliveryService {
|
||||
PendingDelivery pending = pendingOpt.get();
|
||||
|
||||
switch (ack.getStatus()) {
|
||||
case RECEIVED, PROCESSED -> {
|
||||
pendingDeliveryRepository.delete(pending);
|
||||
}
|
||||
case FAILED -> {
|
||||
pending.markAsFailed(ack.getErrorMessage());
|
||||
pendingDeliveryRepository.save(pending);
|
||||
log.warn("[MessageDelivery] Message {} failed on client: {}",
|
||||
ack.getMessageId(), ack.getErrorMessage());
|
||||
}
|
||||
case RECEIVED, PROCESSED -> {
|
||||
pendingDeliveryRepository.delete(pending);
|
||||
}
|
||||
case FAILED -> {
|
||||
pending.markAsFailed(ack.getErrorMessage());
|
||||
pendingDeliveryRepository.save(pending);
|
||||
log.warn("[MessageDelivery] Message {} failed on client: {}", ack.getMessageId(),
|
||||
ack.getErrorMessage());
|
||||
}
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("[MessageDelivery] Error handling ACK for message {}: {}",
|
||||
ack.getMessageId(), e.getMessage());
|
||||
log.error("[MessageDelivery] Error handling ACK for message {}: {}", ack.getMessageId(), e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<DeliveryStatus> getDeliveryStatus(String messageId) {
|
||||
return pendingDeliveryRepository.findByMessageId(messageId)
|
||||
.map(PendingDelivery::getStatus);
|
||||
return pendingDeliveryRepository.findByMessageId(messageId).map(PendingDelivery::getStatus);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -272,11 +252,8 @@ public class MessageDeliveryServiceImpl implements MessageDeliveryService {
|
||||
pendingDeliveryRepository.deleteAll(oldAcknowledged);
|
||||
}
|
||||
|
||||
List<PendingDelivery> expired = pendingDeliveryRepository
|
||||
.findByStatusInAndExpiresAtBefore(
|
||||
List.of(DeliveryStatus.PENDING, DeliveryStatus.SENT),
|
||||
LocalDateTime.now()
|
||||
);
|
||||
List<PendingDelivery> expired = pendingDeliveryRepository.findByStatusInAndExpiresAtBefore(
|
||||
List.of(DeliveryStatus.PENDING, DeliveryStatus.SENT), LocalDateTime.now());
|
||||
|
||||
for (PendingDelivery pending : expired) {
|
||||
pending.markAsExpired();
|
||||
@@ -352,17 +329,15 @@ public class MessageDeliveryServiceImpl implements MessageDeliveryService {
|
||||
pending.incrementRetryCount();
|
||||
|
||||
SendOptions options = SendOptions.reliable();
|
||||
pluginManager.sendToClient(clientId, messageType, pending.getEnvelopeData(), options)
|
||||
.thenAccept(v -> {
|
||||
pending.markAsSent(nextRetry);
|
||||
pendingDeliveryRepository.save(pending);
|
||||
})
|
||||
.exceptionally(ex -> {
|
||||
log.error("[MessageDelivery] Retry failed for message {}: {}", pending.getMessageId(), ex.getMessage());
|
||||
pending.markAsFailed(ex.getMessage());
|
||||
pendingDeliveryRepository.save(pending);
|
||||
return null;
|
||||
});
|
||||
pluginManager.sendToClient(clientId, messageType, pending.getEnvelopeData(), options).thenAccept(v -> {
|
||||
pending.markAsSent(nextRetry);
|
||||
pendingDeliveryRepository.save(pending);
|
||||
}).exceptionally(ex -> {
|
||||
log.error("[MessageDelivery] Retry failed for message {}: {}", pending.getMessageId(), ex.getMessage());
|
||||
pending.markAsFailed(ex.getMessage());
|
||||
pendingDeliveryRepository.save(pending);
|
||||
return null;
|
||||
});
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("[MessageDelivery] Error retrying delivery {}: {}", pending.getMessageId(), e.getMessage());
|
||||
@@ -376,11 +351,8 @@ public class MessageDeliveryServiceImpl implements MessageDeliveryService {
|
||||
return;
|
||||
}
|
||||
|
||||
AcknowledgmentMessage ack = new AcknowledgmentMessage(
|
||||
envelope.getMessageId(),
|
||||
AckStatus.RECEIVED,
|
||||
"server"
|
||||
);
|
||||
AcknowledgmentMessage ack = new AcknowledgmentMessage(envelope.getMessageId(), AckStatus.RECEIVED,
|
||||
"server");
|
||||
|
||||
String ackJson = objectMapper.writeValueAsString(ack);
|
||||
byte[] ackData = ackJson.getBytes(StandardCharsets.UTF_8);
|
||||
@@ -389,12 +361,14 @@ public class MessageDeliveryServiceImpl implements MessageDeliveryService {
|
||||
|
||||
pluginManager.sendAckToClient(clientId, envelope.getMessageId(), ackData, SendOptions.fireAndForget())
|
||||
.exceptionally(ex -> {
|
||||
log.error("[MessageDelivery] Failed to send ACK for message {}: {}", envelope.getMessageId(), ex.getMessage());
|
||||
log.error("[MessageDelivery] Failed to send ACK for message {}: {}", envelope.getMessageId(),
|
||||
ex.getMessage());
|
||||
return null;
|
||||
});
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("[MessageDelivery] Error sending ACK for message {}: {}", envelope.getMessageId(), e.getMessage());
|
||||
log.error("[MessageDelivery] Error sending ACK for message {}: {}", envelope.getMessageId(),
|
||||
e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -43,4 +43,3 @@ public class RetryScheduler {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,4 +19,3 @@ public enum AckStatus {
|
||||
*/
|
||||
FAILED
|
||||
}
|
||||
|
||||
|
||||
@@ -60,4 +60,3 @@ public class AcknowledgmentMessage {
|
||||
this.errorMessage = errorMessage;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -71,22 +71,14 @@ public class DeliveryOptions {
|
||||
* Options for fire-and-forget messages (no acknowledgment required)
|
||||
*/
|
||||
public static DeliveryOptions fireAndForget() {
|
||||
return DeliveryOptions.builder()
|
||||
.requiresAck(false)
|
||||
.maxRetries(0)
|
||||
.build();
|
||||
return DeliveryOptions.builder().requiresAck(false).maxRetries(0).build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for critical messages with extended retry
|
||||
*/
|
||||
public static DeliveryOptions critical() {
|
||||
return DeliveryOptions.builder()
|
||||
.requiresAck(true)
|
||||
.maxRetries(5)
|
||||
.ackTimeout(Duration.ofMinutes(2))
|
||||
.expiryDuration(Duration.ofDays(7))
|
||||
.build();
|
||||
return DeliveryOptions.builder().requiresAck(true).maxRetries(5).ackTimeout(Duration.ofMinutes(2))
|
||||
.expiryDuration(Duration.ofDays(7)).build();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -43,26 +43,13 @@ public class DeliveryReceipt {
|
||||
* Create a receipt for a successfully submitted message
|
||||
*/
|
||||
public static DeliveryReceipt submitted(String messageId, String topic, LocalDateTime expiresAt) {
|
||||
return new DeliveryReceipt(
|
||||
messageId,
|
||||
topic,
|
||||
LocalDateTime.now(),
|
||||
DeliveryStatus.PENDING,
|
||||
expiresAt
|
||||
);
|
||||
return new DeliveryReceipt(messageId, topic, LocalDateTime.now(), DeliveryStatus.PENDING, expiresAt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a receipt for a failed submission
|
||||
*/
|
||||
public static DeliveryReceipt failed(String messageId, String topic) {
|
||||
return new DeliveryReceipt(
|
||||
messageId,
|
||||
topic,
|
||||
LocalDateTime.now(),
|
||||
DeliveryStatus.FAILED,
|
||||
null
|
||||
);
|
||||
return new DeliveryReceipt(messageId, topic, LocalDateTime.now(), DeliveryStatus.FAILED, null);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -29,4 +29,3 @@ public enum DeliveryStatus {
|
||||
*/
|
||||
EXPIRED
|
||||
}
|
||||
|
||||
|
||||
@@ -11,9 +11,9 @@ import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Envelope that wraps all messages sent through the messaging system.
|
||||
* Contains metadata for delivery tracking and acknowledgment.
|
||||
* This is a DTO class - not persisted to MongoDB.
|
||||
* Envelope that wraps all messages sent through the messaging system. Contains
|
||||
* metadata for delivery tracking and acknowledgment. This is a DTO class - not
|
||||
* persisted to MongoDB.
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
|
||||
@@ -14,8 +14,8 @@ import org.springframework.data.mongodb.core.mapping.Field;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* Represents a message delivery that is pending acknowledgment.
|
||||
* Stored in MongoDB for retry and tracking purposes.
|
||||
* Represents a message delivery that is pending acknowledgment. Stored in
|
||||
* MongoDB for retry and tracking purposes.
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@@ -112,8 +112,8 @@ public class PendingDelivery {
|
||||
/**
|
||||
* Constructor for new pending delivery
|
||||
*/
|
||||
public PendingDelivery(String messageId, String topic, byte[] envelopeData,
|
||||
int maxRetries, LocalDateTime expiresAt) {
|
||||
public PendingDelivery(String messageId, String topic, byte[] envelopeData, int maxRetries,
|
||||
LocalDateTime expiresAt) {
|
||||
this.messageId = messageId;
|
||||
this.topic = topic;
|
||||
this.envelopeData = envelopeData;
|
||||
@@ -183,11 +183,8 @@ public class PendingDelivery {
|
||||
* Check if ready for retry
|
||||
*/
|
||||
public boolean isReadyForRetry() {
|
||||
return status == DeliveryStatus.SENT
|
||||
&& nextRetryAt != null
|
||||
&& LocalDateTime.now().isAfter(nextRetryAt)
|
||||
&& !hasReachedMaxRetries()
|
||||
&& !isExpired();
|
||||
return status == DeliveryStatus.SENT && nextRetryAt != null && LocalDateTime.now().isAfter(nextRetryAt)
|
||||
&& !hasReachedMaxRetries() && !isExpired();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -211,4 +208,3 @@ public class PendingDelivery {
|
||||
return id != null ? id.toString() : null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -105,4 +105,3 @@ public class ConnectionStateEvent {
|
||||
return state == ConnectionState.ERROR;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,9 +3,9 @@ package de.assecutor.votianlt.messaging.plugin;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
/**
|
||||
* Interface for messaging transport plugins.
|
||||
* Plugins implement specific transport protocols (MQTT, WebSocket, gRPC, etc.)
|
||||
* and provide a unified interface for the messaging layer.
|
||||
* Interface for messaging transport plugins. Plugins implement specific
|
||||
* transport protocols (MQTT, WebSocket, gRPC, etc.) and provide a unified
|
||||
* interface for the messaging layer.
|
||||
*
|
||||
* The plugin is responsible for managing the internal topic/channel structure.
|
||||
* The messaging layer only uses clientId and messageType as identifiers.
|
||||
@@ -13,73 +13,95 @@ import java.util.concurrent.CompletableFuture;
|
||||
public interface MessagingPlugin {
|
||||
|
||||
/**
|
||||
* Initialize the plugin with configuration.
|
||||
* Called once during application startup.
|
||||
* Initialize the plugin with configuration. Called once during application
|
||||
* startup.
|
||||
*
|
||||
* @param config Plugin-specific configuration
|
||||
* @throws PluginException if initialization fails
|
||||
* @param config
|
||||
* Plugin-specific configuration
|
||||
* @throws PluginException
|
||||
* if initialization fails
|
||||
*/
|
||||
void init(PluginConfig config) throws PluginException;
|
||||
|
||||
/**
|
||||
* Shutdown the plugin and release resources.
|
||||
* Called during application shutdown.
|
||||
* Shutdown the plugin and release resources. Called during application
|
||||
* shutdown.
|
||||
*
|
||||
* @throws PluginException if shutdown fails
|
||||
* @throws PluginException
|
||||
* if shutdown fails
|
||||
*/
|
||||
void exit() throws PluginException;
|
||||
|
||||
/**
|
||||
* Callback when connection state changes.
|
||||
* The plugin should call this method when the underlying transport
|
||||
* connection state changes (connected, disconnected, error).
|
||||
* Callback when connection state changes. The plugin should call this method
|
||||
* when the underlying transport connection state changes (connected,
|
||||
* disconnected, error).
|
||||
*
|
||||
* @param listener Connection state listener
|
||||
* @param listener
|
||||
* Connection state listener
|
||||
*/
|
||||
void setConnectionListener(ConnectionStateListener listener);
|
||||
|
||||
/**
|
||||
* Send a message to a specific client.
|
||||
* The plugin is responsible for determining the correct topic/channel based on the messageType.
|
||||
* Send a message to a specific client. The plugin is responsible for
|
||||
* determining the correct topic/channel based on the messageType.
|
||||
*
|
||||
* @param clientId Target client identifier
|
||||
* @param messageType Type of message (e.g., "jobs", "message", "auth", "task")
|
||||
* @param payload Message payload as byte array
|
||||
* @param options Transport-specific options
|
||||
* @param clientId
|
||||
* Target client identifier
|
||||
* @param messageType
|
||||
* Type of message (e.g., "jobs", "message", "auth", "task")
|
||||
* @param payload
|
||||
* Message payload as byte array
|
||||
* @param options
|
||||
* Transport-specific options
|
||||
* @return CompletableFuture that completes when message is sent
|
||||
* @throws PluginException if sending fails
|
||||
* @throws PluginException
|
||||
* if sending fails
|
||||
*/
|
||||
CompletableFuture<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.
|
||||
* The plugin is responsible for determining the correct ACK topic/channel.
|
||||
* Send an acknowledgment to a specific client. The plugin is responsible for
|
||||
* determining the correct ACK topic/channel.
|
||||
*
|
||||
* @param clientId Target client identifier
|
||||
* @param messageId Message ID being acknowledged
|
||||
* @param payload ACK payload as byte array
|
||||
* @param options Transport-specific options
|
||||
* @param clientId
|
||||
* Target client identifier
|
||||
* @param messageId
|
||||
* Message ID being acknowledged
|
||||
* @param payload
|
||||
* ACK payload as byte array
|
||||
* @param options
|
||||
* Transport-specific options
|
||||
* @return CompletableFuture that completes when ACK is sent
|
||||
* @throws PluginException if sending fails
|
||||
* @throws PluginException
|
||||
* if sending fails
|
||||
*/
|
||||
CompletableFuture<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.
|
||||
* The plugin is responsible for subscribing to the appropriate topics/channels.
|
||||
* Register a handler for incoming messages of a specific type from clients. The
|
||||
* plugin is responsible for subscribing to the appropriate topics/channels.
|
||||
*
|
||||
* @param messageType Type of message to handle (e.g., "task_completed", "message", "jobs/assigned", "login")
|
||||
* @param handler Message handler to be called when a message is received
|
||||
* @throws PluginException if registration fails
|
||||
* @param messageType
|
||||
* Type of message to handle (e.g., "task_completed", "message",
|
||||
* "jobs/assigned", "login")
|
||||
* @param handler
|
||||
* Message handler to be called when a message is received
|
||||
* @throws PluginException
|
||||
* if registration fails
|
||||
*/
|
||||
void registerMessageHandler(String messageType, ClientMessageHandler handler) throws PluginException;
|
||||
|
||||
/**
|
||||
* Register a handler for incoming acknowledgments from clients.
|
||||
* The plugin is responsible for subscribing to the appropriate ACK topics/channels.
|
||||
* Register a handler for incoming acknowledgments from clients. The plugin is
|
||||
* responsible for subscribing to the appropriate ACK topics/channels.
|
||||
*
|
||||
* @param handler ACK handler to be called when an ACK is received
|
||||
* @throws PluginException if registration fails
|
||||
* @param handler
|
||||
* ACK handler to be called when an ACK is received
|
||||
* @throws PluginException
|
||||
* if registration fails
|
||||
*/
|
||||
void registerAckHandler(AckHandler handler) throws PluginException;
|
||||
|
||||
@@ -119,22 +141,25 @@ public interface MessagingPlugin {
|
||||
/**
|
||||
* Called when connection state changes.
|
||||
*
|
||||
* @param event Connection state event
|
||||
* @param event
|
||||
* Connection state event
|
||||
*/
|
||||
void onConnectionStateChanged(ConnectionStateEvent event);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler for received messages from clients.
|
||||
* Includes the clientId extracted from the topic/channel.
|
||||
* Handler for received messages from clients. Includes the clientId extracted
|
||||
* from the topic/channel.
|
||||
*/
|
||||
@FunctionalInterface
|
||||
interface ClientMessageHandler {
|
||||
/**
|
||||
* Called when a message is received from a client.
|
||||
*
|
||||
* @param clientId Client identifier extracted from the topic/channel
|
||||
* @param payload Message payload as byte array
|
||||
* @param clientId
|
||||
* Client identifier extracted from the topic/channel
|
||||
* @param payload
|
||||
* Message payload as byte array
|
||||
*/
|
||||
void onMessageReceived(String clientId, byte[] payload);
|
||||
}
|
||||
@@ -147,10 +172,11 @@ public interface MessagingPlugin {
|
||||
/**
|
||||
* Called when an ACK is received from a client.
|
||||
*
|
||||
* @param messageId Message ID being acknowledged
|
||||
* @param payload ACK payload as byte array
|
||||
* @param messageId
|
||||
* Message ID being acknowledged
|
||||
* @param payload
|
||||
* ACK payload as byte array
|
||||
*/
|
||||
void onAckReceived(String messageId, byte[] payload);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,8 +9,8 @@ import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Configuration for messaging plugins.
|
||||
* Provides a flexible key-value store for plugin-specific settings.
|
||||
* Configuration for messaging plugins. Provides a flexible key-value store for
|
||||
* plugin-specific settings.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@@ -27,7 +27,8 @@ public class PluginConfig {
|
||||
/**
|
||||
* Get a string property.
|
||||
*
|
||||
* @param key Property key
|
||||
* @param key
|
||||
* Property key
|
||||
* @return Property value or null if not found
|
||||
*/
|
||||
public String getString(String key) {
|
||||
@@ -38,8 +39,10 @@ public class PluginConfig {
|
||||
/**
|
||||
* Get a string property with default value.
|
||||
*
|
||||
* @param key Property key
|
||||
* @param defaultValue Default value if property not found
|
||||
* @param key
|
||||
* Property key
|
||||
* @param defaultValue
|
||||
* Default value if property not found
|
||||
* @return Property value or default
|
||||
*/
|
||||
public String getString(String key, String defaultValue) {
|
||||
@@ -50,7 +53,8 @@ public class PluginConfig {
|
||||
/**
|
||||
* Get an integer property.
|
||||
*
|
||||
* @param key Property key
|
||||
* @param key
|
||||
* Property key
|
||||
* @return Property value or null if not found
|
||||
*/
|
||||
public Integer getInt(String key) {
|
||||
@@ -70,8 +74,10 @@ public class PluginConfig {
|
||||
/**
|
||||
* Get an integer property with default value.
|
||||
*
|
||||
* @param key Property key
|
||||
* @param defaultValue Default value if property not found
|
||||
* @param key
|
||||
* Property key
|
||||
* @param defaultValue
|
||||
* Default value if property not found
|
||||
* @return Property value or default
|
||||
*/
|
||||
public int getInt(String key, int defaultValue) {
|
||||
@@ -82,7 +88,8 @@ public class PluginConfig {
|
||||
/**
|
||||
* Get a boolean property.
|
||||
*
|
||||
* @param key Property key
|
||||
* @param key
|
||||
* Property key
|
||||
* @return Property value or null if not found
|
||||
*/
|
||||
public Boolean getBoolean(String key) {
|
||||
@@ -98,8 +105,10 @@ public class PluginConfig {
|
||||
/**
|
||||
* Get a boolean property with default value.
|
||||
*
|
||||
* @param key Property key
|
||||
* @param defaultValue Default value if property not found
|
||||
* @param key
|
||||
* Property key
|
||||
* @param defaultValue
|
||||
* Default value if property not found
|
||||
* @return Property value or default
|
||||
*/
|
||||
public boolean getBoolean(String key, boolean defaultValue) {
|
||||
@@ -110,8 +119,10 @@ public class PluginConfig {
|
||||
/**
|
||||
* Set a property.
|
||||
*
|
||||
* @param key Property key
|
||||
* @param value Property value
|
||||
* @param key
|
||||
* Property key
|
||||
* @param value
|
||||
* Property value
|
||||
*/
|
||||
public void setProperty(String key, Object value) {
|
||||
properties.put(key, value);
|
||||
@@ -120,11 +131,11 @@ public class PluginConfig {
|
||||
/**
|
||||
* Check if a property exists.
|
||||
*
|
||||
* @param key Property key
|
||||
* @param key
|
||||
* Property key
|
||||
* @return true if property exists
|
||||
*/
|
||||
public boolean hasProperty(String key) {
|
||||
return properties.containsKey(key);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,4 +17,3 @@ public class PluginException extends Exception {
|
||||
super(cause);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,8 +10,8 @@ import java.util.Optional;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
/**
|
||||
* Manager for messaging plugins.
|
||||
* Handles plugin lifecycle, registration, and delegation.
|
||||
* Manager for messaging plugins. Handles plugin lifecycle, registration, and
|
||||
* delegation.
|
||||
*/
|
||||
@Component
|
||||
@Slf4j
|
||||
@@ -24,9 +24,12 @@ public class PluginManager {
|
||||
/**
|
||||
* Initialize and activate a plugin.
|
||||
*
|
||||
* @param plugin Plugin to activate
|
||||
* @param config Plugin configuration
|
||||
* @throws PluginException if initialization fails
|
||||
* @param plugin
|
||||
* Plugin to activate
|
||||
* @param config
|
||||
* Plugin configuration
|
||||
* @throws PluginException
|
||||
* if initialization fails
|
||||
*/
|
||||
public void activatePlugin(MessagingPlugin plugin, PluginConfig config) throws PluginException {
|
||||
log.info("[PluginManager] Activating plugin: {}", plugin.getPluginName());
|
||||
@@ -43,11 +46,8 @@ public class PluginManager {
|
||||
|
||||
// Set connection listener
|
||||
plugin.setConnectionListener(event -> {
|
||||
String previousState = event.getPreviousState() != null
|
||||
? event.getPreviousState().toString()
|
||||
: "NONE";
|
||||
log.info("[PluginManager] Connection state changed: {} -> {}",
|
||||
previousState, event.getState());
|
||||
String previousState = event.getPreviousState() != null ? event.getPreviousState().toString() : "NONE";
|
||||
log.info("[PluginManager] Connection state changed: {} -> {}", previousState, event.getState());
|
||||
connectionHistory.add(event);
|
||||
notifyStateListeners(event);
|
||||
});
|
||||
@@ -56,8 +56,7 @@ public class PluginManager {
|
||||
plugin.init(config);
|
||||
activePlugin = plugin;
|
||||
|
||||
log.info("[PluginManager] Plugin activated: {} v{}",
|
||||
plugin.getPluginName(), plugin.getPluginVersion());
|
||||
log.info("[PluginManager] Plugin activated: {} v{}", plugin.getPluginName(), plugin.getPluginVersion());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -72,14 +71,20 @@ public class PluginManager {
|
||||
/**
|
||||
* Send a message to a specific client via the active plugin.
|
||||
*
|
||||
* @param clientId Target client identifier
|
||||
* @param messageType Type of message (e.g., "jobs", "message", "auth", "task")
|
||||
* @param payload Message payload
|
||||
* @param options Send options
|
||||
* @param clientId
|
||||
* Target client identifier
|
||||
* @param messageType
|
||||
* Type of message (e.g., "jobs", "message", "auth", "task")
|
||||
* @param payload
|
||||
* Message payload
|
||||
* @param options
|
||||
* Send options
|
||||
* @return CompletableFuture that completes when message is sent
|
||||
* @throws PluginException if no plugin is active or sending fails
|
||||
* @throws PluginException
|
||||
* if no plugin is active or sending fails
|
||||
*/
|
||||
public CompletableFuture<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) {
|
||||
return CompletableFuture.failedFuture(new PluginException("No active plugin"));
|
||||
}
|
||||
@@ -94,14 +99,20 @@ public class PluginManager {
|
||||
/**
|
||||
* Send an acknowledgment to a specific client via the active plugin.
|
||||
*
|
||||
* @param clientId Target client identifier
|
||||
* @param messageId Message ID being acknowledged
|
||||
* @param payload ACK payload
|
||||
* @param options Send options
|
||||
* @param clientId
|
||||
* Target client identifier
|
||||
* @param messageId
|
||||
* Message ID being acknowledged
|
||||
* @param payload
|
||||
* ACK payload
|
||||
* @param options
|
||||
* Send options
|
||||
* @return CompletableFuture that completes when ACK is sent
|
||||
* @throws PluginException if no plugin is active or sending fails
|
||||
* @throws PluginException
|
||||
* if no plugin is active or sending fails
|
||||
*/
|
||||
public CompletableFuture<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) {
|
||||
return CompletableFuture.failedFuture(new PluginException("No active plugin"));
|
||||
}
|
||||
@@ -116,11 +127,15 @@ public class PluginManager {
|
||||
/**
|
||||
* Register a handler for incoming messages of a specific type from clients.
|
||||
*
|
||||
* @param messageType Type of message to handle
|
||||
* @param handler Message handler
|
||||
* @throws PluginException if no plugin is active or registration fails
|
||||
* @param messageType
|
||||
* Type of message to handle
|
||||
* @param handler
|
||||
* Message handler
|
||||
* @throws PluginException
|
||||
* if no plugin is active or registration fails
|
||||
*/
|
||||
public void registerMessageHandler(String messageType, MessagingPlugin.ClientMessageHandler handler) throws PluginException {
|
||||
public void registerMessageHandler(String messageType, MessagingPlugin.ClientMessageHandler handler)
|
||||
throws PluginException {
|
||||
if (activePlugin == null) {
|
||||
throw new PluginException("No active plugin");
|
||||
}
|
||||
@@ -131,8 +146,10 @@ public class PluginManager {
|
||||
/**
|
||||
* Register a handler for incoming acknowledgments from clients.
|
||||
*
|
||||
* @param handler ACK handler
|
||||
* @throws PluginException if no plugin is active or registration fails
|
||||
* @param handler
|
||||
* ACK handler
|
||||
* @throws PluginException
|
||||
* if no plugin is active or registration fails
|
||||
*/
|
||||
public void registerAckHandler(MessagingPlugin.AckHandler handler) throws PluginException {
|
||||
if (activePlugin == null) {
|
||||
@@ -184,7 +201,8 @@ public class PluginManager {
|
||||
/**
|
||||
* Add a plugin state listener.
|
||||
*
|
||||
* @param listener State listener
|
||||
* @param listener
|
||||
* State listener
|
||||
*/
|
||||
public void addStateListener(PluginStateListener listener) {
|
||||
stateListeners.add(listener);
|
||||
@@ -193,7 +211,8 @@ public class PluginManager {
|
||||
/**
|
||||
* Remove a plugin state listener.
|
||||
*
|
||||
* @param listener State listener
|
||||
* @param listener
|
||||
* State listener
|
||||
*/
|
||||
public void removeStateListener(PluginStateListener listener) {
|
||||
stateListeners.remove(listener);
|
||||
@@ -202,7 +221,8 @@ public class PluginManager {
|
||||
/**
|
||||
* Notify all state listeners of a connection state change.
|
||||
*
|
||||
* @param event Connection state event
|
||||
* @param event
|
||||
* Connection state event
|
||||
*/
|
||||
private void notifyStateListeners(ConnectionStateEvent event) {
|
||||
for (PluginStateListener listener : stateListeners) {
|
||||
@@ -243,9 +263,9 @@ public class PluginManager {
|
||||
/**
|
||||
* Called when plugin connection state changes.
|
||||
*
|
||||
* @param event Connection state event
|
||||
* @param event
|
||||
* Connection state event
|
||||
*/
|
||||
void onConnectionStateChanged(ConnectionStateEvent event);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -70,7 +70,8 @@ public class PluginMetadata {
|
||||
/**
|
||||
* Check if a feature is supported.
|
||||
*
|
||||
* @param feature Feature name
|
||||
* @param feature
|
||||
* Feature name
|
||||
* @return true if supported
|
||||
*/
|
||||
public boolean supportsFeature(String feature) {
|
||||
@@ -80,7 +81,8 @@ public class PluginMetadata {
|
||||
/**
|
||||
* Add a supported feature.
|
||||
*
|
||||
* @param feature Feature name
|
||||
* @param feature
|
||||
* Feature name
|
||||
*/
|
||||
public void addSupportedFeature(String feature) {
|
||||
if (!supportedFeatures.contains(feature)) {
|
||||
@@ -88,4 +90,3 @@ public class PluginMetadata {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -53,7 +53,8 @@ public class ReceivedMessage {
|
||||
/**
|
||||
* Get metadata value.
|
||||
*
|
||||
* @param key Metadata key
|
||||
* @param key
|
||||
* Metadata key
|
||||
* @return Metadata value or null
|
||||
*/
|
||||
public Object getMetadata(String key) {
|
||||
@@ -63,8 +64,10 @@ public class ReceivedMessage {
|
||||
/**
|
||||
* Set metadata value.
|
||||
*
|
||||
* @param key Metadata key
|
||||
* @param value Metadata value
|
||||
* @param key
|
||||
* Metadata key
|
||||
* @param value
|
||||
* Metadata value
|
||||
*/
|
||||
public void setMetadata(String key, Object value) {
|
||||
metadata.put(key, value);
|
||||
@@ -79,4 +82,3 @@ public class ReceivedMessage {
|
||||
return payload != null ? new String(payload, java.nio.charset.StandardCharsets.UTF_8) : null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,8 +9,8 @@ import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Options for sending messages via plugins.
|
||||
* Provides transport-agnostic options with extensibility for plugin-specific settings.
|
||||
* Options for sending messages via plugins. Provides transport-agnostic options
|
||||
* with extensibility for plugin-specific settings.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@@ -50,7 +50,8 @@ public class SendOptions {
|
||||
/**
|
||||
* Get an additional option.
|
||||
*
|
||||
* @param key Option key
|
||||
* @param key
|
||||
* Option key
|
||||
* @return Option value or null
|
||||
*/
|
||||
public Object getAdditionalOption(String key) {
|
||||
@@ -60,8 +61,10 @@ public class SendOptions {
|
||||
/**
|
||||
* Set an additional option.
|
||||
*
|
||||
* @param key Option key
|
||||
* @param value Option value
|
||||
* @param key
|
||||
* Option key
|
||||
* @param value
|
||||
* Option value
|
||||
*/
|
||||
public void setAdditionalOption(String key, Object value) {
|
||||
additionalOptions.put(key, value);
|
||||
@@ -82,10 +85,7 @@ public class SendOptions {
|
||||
* @return Fire-and-forget options
|
||||
*/
|
||||
public static SendOptions fireAndForget() {
|
||||
return SendOptions.builder()
|
||||
.qos(0)
|
||||
.retained(false)
|
||||
.build();
|
||||
return SendOptions.builder().qos(0).retained(false).build();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -94,10 +94,6 @@ public class SendOptions {
|
||||
* @return Reliable delivery options
|
||||
*/
|
||||
public static SendOptions reliable() {
|
||||
return SendOptions.builder()
|
||||
.qos(2)
|
||||
.retained(false)
|
||||
.build();
|
||||
return SendOptions.builder().qos(2).retained(false).build();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,14 +15,14 @@ import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* MQTT implementation of the MessagingPlugin interface.
|
||||
* Uses HiveMQ MQTT 5 client for communication.
|
||||
* MQTT implementation of the MessagingPlugin interface. Uses HiveMQ MQTT 5
|
||||
* client for communication.
|
||||
*
|
||||
* Topic Structure (managed internally):
|
||||
* - Server -> Client: /client/{clientId}/{messageType}
|
||||
* - Client -> Server: /server/{clientId}/{messageType}
|
||||
* - ACK Server -> Client: /client/{clientId}/ack (messageId in payload)
|
||||
* - ACK Client -> Server: /server/{clientId}/ack (messageId in payload)
|
||||
* Topic Structure (managed internally): - Server -> Client:
|
||||
* /client/{clientId}/{messageType} - Client -> Server:
|
||||
* /server/{clientId}/{messageType} - ACK Server -> Client:
|
||||
* /client/{clientId}/ack (messageId in payload) - ACK Client -> Server:
|
||||
* /server/{clientId}/ack (messageId in payload)
|
||||
*/
|
||||
@Slf4j
|
||||
public class MqttMessagingPlugin implements MessagingPlugin {
|
||||
@@ -31,12 +31,12 @@ public class MqttMessagingPlugin implements MessagingPlugin {
|
||||
private static final String PLUGIN_VERSION = "2.0.0";
|
||||
|
||||
// Topic templates
|
||||
private static final String TOPIC_TO_CLIENT = "/client/%s/%s"; // /client/{clientId}/{messageType}
|
||||
private static final String TOPIC_ACK_TO_CLIENT = "/client/%s/ack"; // /client/{clientId}/ack (messageId in payload)
|
||||
private static final String TOPIC_TO_CLIENT = "/client/%s/%s"; // /client/{clientId}/{messageType}
|
||||
private static final String TOPIC_ACK_TO_CLIENT = "/client/%s/ack"; // /client/{clientId}/ack (messageId in payload)
|
||||
|
||||
// Subscription patterns
|
||||
private static final String PATTERN_FROM_CLIENT = "/server/+/%s"; // /server/+/{messageType}
|
||||
private static final String PATTERN_ACK_FROM_CLIENT = "/server/+/ack"; // /server/+/ack
|
||||
private static final String PATTERN_FROM_CLIENT = "/server/+/%s"; // /server/+/{messageType}
|
||||
private static final String PATTERN_ACK_FROM_CLIENT = "/server/+/ack"; // /server/+/ack
|
||||
|
||||
private Mqtt5AsyncClient mqttClient;
|
||||
private ConnectionStateListener connectionListener;
|
||||
@@ -71,31 +71,22 @@ public class MqttMessagingPlugin implements MessagingPlugin {
|
||||
int connectionTimeout = config.getInt(CONFIG_CONNECTION_TIMEOUT, 60);
|
||||
int keepAlive = config.getInt(CONFIG_KEEP_ALIVE, 60);
|
||||
|
||||
log.info("[MqttPlugin] Connecting to {}:{} with clientId: {} (timeout: {}s, keepAlive: {}s)",
|
||||
brokerHost, brokerPort, clientId, connectionTimeout, keepAlive);
|
||||
log.info("[MqttPlugin] Connecting to {}:{} with clientId: {} (timeout: {}s, keepAlive: {}s)", brokerHost,
|
||||
brokerPort, clientId, connectionTimeout, keepAlive);
|
||||
|
||||
// Build MQTT client
|
||||
var clientBuilder = MqttClient.builder()
|
||||
.useMqttVersion5()
|
||||
.identifier(clientId)
|
||||
.serverHost(brokerHost)
|
||||
.serverPort(brokerPort)
|
||||
.automaticReconnect()
|
||||
.initialDelay(1, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.maxDelay(30, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.applyAutomaticReconnect();
|
||||
var clientBuilder = MqttClient.builder().useMqttVersion5().identifier(clientId).serverHost(brokerHost)
|
||||
.serverPort(brokerPort).automaticReconnect().initialDelay(1, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.maxDelay(30, java.util.concurrent.TimeUnit.SECONDS).applyAutomaticReconnect();
|
||||
|
||||
mqttClient = clientBuilder.buildAsync();
|
||||
|
||||
// Build connect options
|
||||
var connectBuilder = com.hivemq.client.mqtt.mqtt5.message.connect.Mqtt5Connect.builder()
|
||||
.cleanStart(cleanStart)
|
||||
.keepAlive(keepAlive);
|
||||
.cleanStart(cleanStart).keepAlive(keepAlive);
|
||||
|
||||
if (username != null && password != null) {
|
||||
connectBuilder.simpleAuth()
|
||||
.username(username)
|
||||
.password(password.getBytes(StandardCharsets.UTF_8))
|
||||
connectBuilder.simpleAuth().username(username).password(password.getBytes(StandardCharsets.UTF_8))
|
||||
.applySimpleAuth();
|
||||
}
|
||||
|
||||
@@ -108,17 +99,19 @@ public class MqttMessagingPlugin implements MessagingPlugin {
|
||||
.orTimeout(connectionTimeout, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.whenComplete((connAck, throwable) -> {
|
||||
if (throwable != null) {
|
||||
String errorMsg = String.format("Connection to %s:%d failed: %s",
|
||||
brokerHost, brokerPort, throwable.getMessage());
|
||||
String errorMsg = String.format("Connection to %s:%d failed: %s", brokerHost, brokerPort,
|
||||
throwable.getMessage());
|
||||
log.error("[MqttPlugin] {}", errorMsg, throwable);
|
||||
|
||||
// Check for specific error types
|
||||
if (throwable instanceof java.util.concurrent.TimeoutException) {
|
||||
log.error("[MqttPlugin] Connection timeout - broker may be unreachable or firewall blocking connection");
|
||||
log.error(
|
||||
"[MqttPlugin] Connection timeout - broker may be unreachable or firewall blocking connection");
|
||||
} else if (throwable.getCause() instanceof java.net.UnknownHostException) {
|
||||
log.error("[MqttPlugin] Unknown host - DNS resolution failed for {}", brokerHost);
|
||||
} else if (throwable.getCause() instanceof java.net.ConnectException) {
|
||||
log.error("[MqttPlugin] Connection refused - broker may be down or port {} is blocked", brokerPort);
|
||||
log.error("[MqttPlugin] Connection refused - broker may be down or port {} is blocked",
|
||||
brokerPort);
|
||||
}
|
||||
|
||||
connected = false;
|
||||
@@ -185,7 +178,8 @@ public class MqttMessagingPlugin implements MessagingPlugin {
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompletableFuture<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) {
|
||||
return CompletableFuture.failedFuture(new PluginException("MQTT client is not connected"));
|
||||
}
|
||||
@@ -197,7 +191,8 @@ public class MqttMessagingPlugin implements MessagingPlugin {
|
||||
}
|
||||
|
||||
@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) {
|
||||
return CompletableFuture.failedFuture(new PluginException("MQTT client is not connected"));
|
||||
}
|
||||
@@ -221,10 +216,7 @@ public class MqttMessagingPlugin implements MessagingPlugin {
|
||||
String loginTopic = "/server/login";
|
||||
log.info("[MqttPlugin] Registering handler for message type '{}' with topic: {}", messageType, loginTopic);
|
||||
|
||||
mqttClient.subscribeWith()
|
||||
.topicFilter(loginTopic)
|
||||
.qos(MqttQos.EXACTLY_ONCE)
|
||||
.send()
|
||||
mqttClient.subscribeWith().topicFilter(loginTopic).qos(MqttQos.EXACTLY_ONCE).send()
|
||||
.whenComplete((subAck, throwable) -> {
|
||||
if (throwable != null) {
|
||||
log.error("[MqttPlugin] Subscription to {} failed: {}", loginTopic, throwable.getMessage());
|
||||
@@ -236,15 +228,14 @@ public class MqttMessagingPlugin implements MessagingPlugin {
|
||||
} else {
|
||||
// Standard pattern: /server/+/{messageType}
|
||||
String topicPattern = String.format(PATTERN_FROM_CLIENT, messageType);
|
||||
log.info("[MqttPlugin] Registering handler for message type '{}' with pattern: {}", messageType, topicPattern);
|
||||
log.info("[MqttPlugin] Registering handler for message type '{}' with pattern: {}", messageType,
|
||||
topicPattern);
|
||||
|
||||
mqttClient.subscribeWith()
|
||||
.topicFilter(topicPattern)
|
||||
.qos(MqttQos.EXACTLY_ONCE)
|
||||
.send()
|
||||
mqttClient.subscribeWith().topicFilter(topicPattern).qos(MqttQos.EXACTLY_ONCE).send()
|
||||
.whenComplete((subAck, throwable) -> {
|
||||
if (throwable != null) {
|
||||
log.error("[MqttPlugin] Subscription to {} failed: {}", topicPattern, throwable.getMessage());
|
||||
log.error("[MqttPlugin] Subscription to {} failed: {}", topicPattern,
|
||||
throwable.getMessage());
|
||||
messageHandlers.remove(messageType);
|
||||
} else {
|
||||
log.info("[MqttPlugin] Successfully subscribed to: {}", topicPattern);
|
||||
@@ -264,13 +255,11 @@ public class MqttMessagingPlugin implements MessagingPlugin {
|
||||
this.ackHandler = handler;
|
||||
|
||||
// Subscribe to ACK topic pattern
|
||||
mqttClient.subscribeWith()
|
||||
.topicFilter(PATTERN_ACK_FROM_CLIENT)
|
||||
.qos(MqttQos.EXACTLY_ONCE)
|
||||
.send()
|
||||
mqttClient.subscribeWith().topicFilter(PATTERN_ACK_FROM_CLIENT).qos(MqttQos.EXACTLY_ONCE).send()
|
||||
.whenComplete((subAck, throwable) -> {
|
||||
if (throwable != null) {
|
||||
log.error("[MqttPlugin] Subscription to {} failed: {}", PATTERN_ACK_FROM_CLIENT, throwable.getMessage());
|
||||
log.error("[MqttPlugin] Subscription to {} failed: {}", PATTERN_ACK_FROM_CLIENT,
|
||||
throwable.getMessage());
|
||||
this.ackHandler = null;
|
||||
} else {
|
||||
log.info("[MqttPlugin] Successfully subscribed to: {}", PATTERN_ACK_FROM_CLIENT);
|
||||
@@ -295,19 +284,14 @@ public class MqttMessagingPlugin implements MessagingPlugin {
|
||||
|
||||
@Override
|
||||
public PluginMetadata getMetadata() {
|
||||
return PluginMetadata.builder()
|
||||
.name(PLUGIN_NAME)
|
||||
.version(PLUGIN_VERSION)
|
||||
.description("MQTT v5 messaging plugin using HiveMQ client")
|
||||
.supportsWildcards(true)
|
||||
.supportsRetainedMessages(true)
|
||||
.supportsQos(true)
|
||||
.maxQosLevel(2)
|
||||
.build();
|
||||
return PluginMetadata.builder().name(PLUGIN_NAME).version(PLUGIN_VERSION)
|
||||
.description("MQTT v5 messaging plugin using HiveMQ client").supportsWildcards(true)
|
||||
.supportsRetainedMessages(true).supportsQos(true).maxQosLevel(2).build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup global message handler to route incoming messages to registered handlers.
|
||||
* Setup global message handler to route incoming messages to registered
|
||||
* handlers.
|
||||
*/
|
||||
private void setupGlobalMessageHandler() {
|
||||
mqttClient.publishes(com.hivemq.client.mqtt.MqttGlobalPublishFilter.ALL, publish -> {
|
||||
@@ -334,8 +318,7 @@ public class MqttMessagingPlugin implements MessagingPlugin {
|
||||
// Check if it's a client message
|
||||
else if (topic.startsWith("/server/")) {
|
||||
handleClientMessage(topic, payload);
|
||||
}
|
||||
else {
|
||||
} else {
|
||||
log.warn("[MqttPlugin] Received message on unexpected topic: {}", topic);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
@@ -343,12 +326,9 @@ public class MqttMessagingPlugin implements MessagingPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Handle ACK message from client.
|
||||
* Topic format: /server/{clientId}/ack (messageId in payload)
|
||||
* Handle ACK message from client. Topic format: /server/{clientId}/ack
|
||||
* (messageId in payload)
|
||||
*/
|
||||
private void handleAckMessage(String topic, byte[] payload) {
|
||||
if (ackHandler == null) {
|
||||
@@ -359,7 +339,7 @@ public class MqttMessagingPlugin implements MessagingPlugin {
|
||||
// Extract clientId from topic: /server/{clientId}/ack
|
||||
String[] parts = topic.split("/");
|
||||
if (parts.length >= 4) {
|
||||
String clientId = parts[2]; // clientId is at index 2
|
||||
String clientId = parts[2]; // clientId is at index 2
|
||||
|
||||
// Extract messageId from payload
|
||||
String payloadStr = new String(payload, StandardCharsets.UTF_8);
|
||||
@@ -377,9 +357,8 @@ public class MqttMessagingPlugin implements MessagingPlugin {
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract messageId from ACK payload.
|
||||
* Expected payload format: JSON with "messageId" field, e.g., {"messageId": "abc-123"}
|
||||
* or plain messageId string.
|
||||
* Extract messageId from ACK payload. Expected payload format: JSON with
|
||||
* "messageId" field, e.g., {"messageId": "abc-123"} or plain messageId string.
|
||||
*/
|
||||
private String extractMessageIdFromPayload(String payload) {
|
||||
if (payload == null || payload.isBlank()) {
|
||||
@@ -418,9 +397,9 @@ public class MqttMessagingPlugin implements MessagingPlugin {
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle client message.
|
||||
* Topic format: /server/{clientId}/{messageType} or /server/{messageType} (for login)
|
||||
* messageType can contain slashes, e.g., "jobs/assigned"
|
||||
* Handle client message. Topic format: /server/{clientId}/{messageType} or
|
||||
* /server/{messageType} (for login) messageType can contain slashes, e.g.,
|
||||
* "jobs/assigned"
|
||||
*/
|
||||
private void handleClientMessage(String topic, byte[] payload) {
|
||||
// Extract clientId and messageType from topic
|
||||
@@ -463,17 +442,13 @@ public class MqttMessagingPlugin implements MessagingPlugin {
|
||||
*/
|
||||
private CompletableFuture<Void> sendToTopic(String topic, byte[] payload, SendOptions options) {
|
||||
try {
|
||||
var publishBuilder = Mqtt5Publish.builder()
|
||||
.topic(topic)
|
||||
.payload(payload)
|
||||
.qos(mapQos(options.getQos()))
|
||||
var publishBuilder = Mqtt5Publish.builder().topic(topic).payload(payload).qos(mapQos(options.getQos()))
|
||||
.retain(options.isRetained());
|
||||
|
||||
return mqttClient.publish(publishBuilder.build())
|
||||
.thenApply(publishResult -> {
|
||||
log.debug("[MqttPlugin] Message published to topic: {}", topic);
|
||||
return null;
|
||||
});
|
||||
return mqttClient.publish(publishBuilder.build()).thenApply(publishResult -> {
|
||||
log.debug("[MqttPlugin] Message published to topic: {}", topic);
|
||||
return null;
|
||||
});
|
||||
} catch (Exception e) {
|
||||
log.error("[MqttPlugin] Failed to publish to topic {}: {}", topic, e.getMessage(), e);
|
||||
return CompletableFuture.failedFuture(new PluginException("Failed to publish message", e));
|
||||
@@ -485,10 +460,10 @@ public class MqttMessagingPlugin implements MessagingPlugin {
|
||||
*/
|
||||
private MqttQos mapQos(int qos) {
|
||||
return switch (qos) {
|
||||
case 0 -> MqttQos.AT_MOST_ONCE;
|
||||
case 1 -> MqttQos.AT_LEAST_ONCE;
|
||||
case 2 -> MqttQos.EXACTLY_ONCE;
|
||||
default -> MqttQos.AT_LEAST_ONCE;
|
||||
case 0 -> MqttQos.AT_MOST_ONCE;
|
||||
case 1 -> MqttQos.AT_LEAST_ONCE;
|
||||
case 2 -> MqttQos.EXACTLY_ONCE;
|
||||
default -> MqttQos.AT_LEAST_ONCE;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -496,14 +471,11 @@ public class MqttMessagingPlugin implements MessagingPlugin {
|
||||
* Notify connection state listener.
|
||||
*/
|
||||
private void notifyConnectionState(ConnectionState state, String message) {
|
||||
log.debug("[MqttPlugin] notifyConnectionState called: state={}, listener={}", state, connectionListener != null ? "present" : "null");
|
||||
log.debug("[MqttPlugin] notifyConnectionState called: state={}, listener={}", state,
|
||||
connectionListener != null ? "present" : "null");
|
||||
if (connectionListener != null) {
|
||||
ConnectionStateEvent event = ConnectionStateEvent.builder()
|
||||
.state(state)
|
||||
.previousState(null)
|
||||
.errorMessage(message)
|
||||
.pluginName(PLUGIN_NAME)
|
||||
.build();
|
||||
ConnectionStateEvent event = ConnectionStateEvent.builder().state(state).previousState(null)
|
||||
.errorMessage(message).pluginName(PLUGIN_NAME).build();
|
||||
try {
|
||||
log.debug("[MqttPlugin] Calling connectionListener.onConnectionStateChanged");
|
||||
connectionListener.onConnectionStateChanged(event);
|
||||
|
||||
@@ -50,7 +50,6 @@ public class AppUser {
|
||||
@Field("geraet")
|
||||
private String geraet;
|
||||
|
||||
|
||||
@Field("owner")
|
||||
private ObjectId owner;
|
||||
|
||||
|
||||
@@ -49,7 +49,8 @@ public class Message {
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
/**
|
||||
* Origin of the message: INCOMING (from client), OUTGOING (to client), or SERVER (from server)
|
||||
* Origin of the message: INCOMING (from client), OUTGOING (to client), or
|
||||
* SERVER (from server)
|
||||
*/
|
||||
@Field("origin")
|
||||
private MessageOrigin origin;
|
||||
@@ -94,8 +95,7 @@ public class Message {
|
||||
/**
|
||||
* Constructor for general messages with explicit content type
|
||||
*/
|
||||
public Message(String content, String receiver, MessageOrigin origin,
|
||||
MessageContentType contentType) {
|
||||
public Message(String content, String receiver, MessageOrigin origin, MessageContentType contentType) {
|
||||
initializeBaseFields(content, receiver, origin, contentType);
|
||||
this.messageType = MessageType.GENERAL;
|
||||
}
|
||||
@@ -103,16 +103,15 @@ public class Message {
|
||||
/**
|
||||
* Constructor for job-related messages
|
||||
*/
|
||||
public Message(String content, String receiver, MessageOrigin origin,
|
||||
ObjectId jobId, String jobNumber) {
|
||||
public Message(String content, String receiver, MessageOrigin origin, ObjectId jobId, String jobNumber) {
|
||||
this(content, receiver, origin, MessageContentType.TEXT, jobId, jobNumber);
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructor for job-related messages with explicit content type
|
||||
*/
|
||||
public Message(String content, String receiver, MessageOrigin origin,
|
||||
MessageContentType contentType, ObjectId jobId, String jobNumber) {
|
||||
public Message(String content, String receiver, MessageOrigin origin, MessageContentType contentType,
|
||||
ObjectId jobId, String jobNumber) {
|
||||
initializeBaseFields(content, receiver, origin, contentType);
|
||||
this.messageType = MessageType.JOB_RELATED;
|
||||
this.jobId = jobId;
|
||||
|
||||
@@ -4,6 +4,5 @@ package de.assecutor.votianlt.model;
|
||||
* Supported content variants for chat messages.
|
||||
*/
|
||||
public enum MessageContentType {
|
||||
TEXT,
|
||||
IMAGE
|
||||
TEXT, IMAGE
|
||||
}
|
||||
|
||||
@@ -17,7 +17,8 @@ public class PriceTable {
|
||||
public PriceTable() {
|
||||
}
|
||||
|
||||
public PriceTable(String monthlyBasePackage, String appUsageLicense, String revenueParticipation, String statisticalEvaluation) {
|
||||
public PriceTable(String monthlyBasePackage, String appUsageLicense, String revenueParticipation,
|
||||
String statisticalEvaluation) {
|
||||
this.monthlyBasePackage = monthlyBasePackage;
|
||||
this.appUsageLicense = appUsageLicense;
|
||||
this.revenueParticipation = revenueParticipation;
|
||||
|
||||
@@ -13,7 +13,7 @@ public class CustomerInvoice {
|
||||
private String id;
|
||||
|
||||
// Pflichtangaben nach §14 UStG (German VAT law)
|
||||
private String invoiceNumber; // Fortlaufende Rechnungsnummer
|
||||
private String invoiceNumber; // Fortlaufende Rechnungsnummer
|
||||
private LocalDate invoiceDate; // Rechnungsdatum
|
||||
private LocalDate deliveryDate; // Leistungsdatum
|
||||
|
||||
@@ -24,7 +24,7 @@ public class CustomerInvoice {
|
||||
private String senderCity;
|
||||
private String senderCountry;
|
||||
private String senderTaxNumber; // Steuernummer
|
||||
private String senderVatId; // USt-IdNr.
|
||||
private String senderVatId; // USt-IdNr.
|
||||
private String senderPhone;
|
||||
private String senderEmail;
|
||||
private String senderWebsite;
|
||||
@@ -43,20 +43,20 @@ public class CustomerInvoice {
|
||||
private List<CustomerInvoiceItem> items;
|
||||
|
||||
// Beträge
|
||||
private BigDecimal netAmount; // Nettobetrag
|
||||
private BigDecimal vatRate; // Steuersatz (z.B. 19% = 0.19)
|
||||
private BigDecimal vatAmount; // Steuerbetrag
|
||||
private BigDecimal netAmount; // Nettobetrag
|
||||
private BigDecimal vatRate; // Steuersatz (z.B. 19% = 0.19)
|
||||
private BigDecimal vatAmount; // Steuerbetrag
|
||||
private BigDecimal totalAmount; // Bruttobetrag
|
||||
|
||||
// Zahlungsdetails
|
||||
private String paymentTerms; // Zahlungsbedingungen
|
||||
private String paymentTerms; // Zahlungsbedingungen
|
||||
private LocalDate paymentDueDate; // Fälligkeitsdatum
|
||||
private String bankAccount; // Bankverbindung
|
||||
private String bankAccount; // Bankverbindung
|
||||
private String iban;
|
||||
private String bic;
|
||||
|
||||
// Zusätzliche rechtliche Angaben
|
||||
private String legalNotes; // Rechtliche Hinweise
|
||||
private String legalNotes; // Rechtliche Hinweise
|
||||
private String reverseChargeNote; // Hinweis auf Reverse Charge (falls zutreffend)
|
||||
|
||||
// Constructors
|
||||
|
||||
@@ -5,19 +5,20 @@ import java.math.BigDecimal;
|
||||
public class CustomerInvoiceItem {
|
||||
|
||||
private BigDecimal quantity;
|
||||
private String unit; // Einheit (Stk., h, kg, etc.)
|
||||
private String unit; // Einheit (Stk., h, kg, etc.)
|
||||
private String description;
|
||||
private BigDecimal unitPrice; // Einzelpreis netto
|
||||
private BigDecimal netTotal; // Gesamtpreis netto
|
||||
private BigDecimal vatRate; // Steuersatz
|
||||
private BigDecimal vatAmount; // Steuerbetrag
|
||||
private BigDecimal unitPrice; // Einzelpreis netto
|
||||
private BigDecimal netTotal; // Gesamtpreis netto
|
||||
private BigDecimal vatRate; // Steuersatz
|
||||
private BigDecimal vatAmount; // Steuerbetrag
|
||||
private BigDecimal grossTotal; // Gesamtpreis brutto
|
||||
|
||||
// Constructors
|
||||
public CustomerInvoiceItem() {
|
||||
}
|
||||
|
||||
public CustomerInvoiceItem(BigDecimal quantity, String unit, String description, BigDecimal unitPrice, BigDecimal vatRate) {
|
||||
public CustomerInvoiceItem(BigDecimal quantity, String unit, String description, BigDecimal unitPrice,
|
||||
BigDecimal vatRate) {
|
||||
this.quantity = quantity;
|
||||
this.unit = unit;
|
||||
this.description = description;
|
||||
|
||||
@@ -30,79 +30,194 @@ public class SystemInvoiceData {
|
||||
|
||||
private String paymentTerms = "Zahlungsbedingungen: Gesamtbetrag bis spätestens zum 10. Werktag nach Rechnungserhalt auf unser u. g. Konto.";
|
||||
|
||||
private String footerText = "Geschäftsführer: Carsten Annacker, Halstenbek · Gunnar Timm, Geesthacht<br>" +
|
||||
"Steuernummer: 22 294 53099 · USt-IdNr.: DE261094748 · Sitz: Geesthacht · Handelsregister: Lübeck HRB 8595<br>" +
|
||||
"Bankverbindung: Hamburger Sparkasse · IBAN DE67200505501217139888 · BIC HASPDEHHXXX";
|
||||
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>"
|
||||
+ "Bankverbindung: Hamburger Sparkasse · IBAN DE67200505501217139888 · BIC HASPDEHHXXX";
|
||||
|
||||
public SystemInvoiceData() {
|
||||
}
|
||||
|
||||
public String getCompanyName() { return companyName; }
|
||||
public void setCompanyName(String companyName) { this.companyName = companyName; }
|
||||
public String getCompanyName() {
|
||||
return companyName;
|
||||
}
|
||||
|
||||
public String getCompanySubtitle() { return companySubtitle; }
|
||||
public void setCompanySubtitle(String companySubtitle) { this.companySubtitle = companySubtitle; }
|
||||
public void setCompanyName(String companyName) {
|
||||
this.companyName = companyName;
|
||||
}
|
||||
|
||||
public String getCompanyStreet() { return companyStreet; }
|
||||
public void setCompanyStreet(String companyStreet) { this.companyStreet = companyStreet; }
|
||||
public String getCompanySubtitle() {
|
||||
return companySubtitle;
|
||||
}
|
||||
|
||||
public String getCompanyCity() { return companyCity; }
|
||||
public void setCompanyCity(String companyCity) { this.companyCity = companyCity; }
|
||||
public void setCompanySubtitle(String companySubtitle) {
|
||||
this.companySubtitle = companySubtitle;
|
||||
}
|
||||
|
||||
public String getCompanyPhone() { return companyPhone; }
|
||||
public void setCompanyPhone(String companyPhone) { this.companyPhone = companyPhone; }
|
||||
public String getCompanyStreet() {
|
||||
return companyStreet;
|
||||
}
|
||||
|
||||
public String getCompanyFax() { return companyFax; }
|
||||
public void setCompanyFax(String companyFax) { this.companyFax = companyFax; }
|
||||
public void setCompanyStreet(String companyStreet) {
|
||||
this.companyStreet = companyStreet;
|
||||
}
|
||||
|
||||
public String getCompanyEmail() { return companyEmail; }
|
||||
public void setCompanyEmail(String companyEmail) { this.companyEmail = companyEmail; }
|
||||
public String getCompanyCity() {
|
||||
return companyCity;
|
||||
}
|
||||
|
||||
public String getCompanyWebsite() { return companyWebsite; }
|
||||
public void setCompanyWebsite(String companyWebsite) { this.companyWebsite = companyWebsite; }
|
||||
public void setCompanyCity(String companyCity) {
|
||||
this.companyCity = companyCity;
|
||||
}
|
||||
|
||||
public String getInvoiceNumber() { return invoiceNumber; }
|
||||
public void setInvoiceNumber(String invoiceNumber) { this.invoiceNumber = invoiceNumber; }
|
||||
public String getCompanyPhone() {
|
||||
return companyPhone;
|
||||
}
|
||||
|
||||
public String getInvoiceDate() { return invoiceDate; }
|
||||
public void setInvoiceDate(String invoiceDate) { this.invoiceDate = invoiceDate; }
|
||||
public void setCompanyPhone(String companyPhone) {
|
||||
this.companyPhone = companyPhone;
|
||||
}
|
||||
|
||||
public String getInvoiceText() { return invoiceText; }
|
||||
public void setInvoiceText(String invoiceText) { this.invoiceText = invoiceText; }
|
||||
public String getCompanyFax() {
|
||||
return companyFax;
|
||||
}
|
||||
|
||||
public String getSenderLine() { return senderLine; }
|
||||
public void setSenderLine(String senderLine) { this.senderLine = senderLine; }
|
||||
public void setCompanyFax(String companyFax) {
|
||||
this.companyFax = companyFax;
|
||||
}
|
||||
|
||||
public String getRecipientName() { return recipientName; }
|
||||
public void setRecipientName(String recipientName) { this.recipientName = recipientName; }
|
||||
public String getCompanyEmail() {
|
||||
return companyEmail;
|
||||
}
|
||||
|
||||
public String getRecipientDepartment() { return recipientDepartment; }
|
||||
public void setRecipientDepartment(String recipientDepartment) { this.recipientDepartment = recipientDepartment; }
|
||||
public void setCompanyEmail(String companyEmail) {
|
||||
this.companyEmail = companyEmail;
|
||||
}
|
||||
|
||||
public String getRecipientStreet() { return recipientStreet; }
|
||||
public void setRecipientStreet(String recipientStreet) { this.recipientStreet = recipientStreet; }
|
||||
public String getCompanyWebsite() {
|
||||
return companyWebsite;
|
||||
}
|
||||
|
||||
public String getRecipientCity() { return recipientCity; }
|
||||
public void setRecipientCity(String recipientCity) { this.recipientCity = recipientCity; }
|
||||
public void setCompanyWebsite(String companyWebsite) {
|
||||
this.companyWebsite = companyWebsite;
|
||||
}
|
||||
|
||||
public List<SystemInvoiceItem> getInvoiceItems() { return systemInvoiceItems; }
|
||||
public void setInvoiceItems(List<SystemInvoiceItem> systemInvoiceItems) { this.systemInvoiceItems = systemInvoiceItems; }
|
||||
public String getInvoiceNumber() {
|
||||
return invoiceNumber;
|
||||
}
|
||||
|
||||
public String getNetAmount() { return netAmount; }
|
||||
public void setNetAmount(String netAmount) { this.netAmount = netAmount; }
|
||||
public void setInvoiceNumber(String invoiceNumber) {
|
||||
this.invoiceNumber = invoiceNumber;
|
||||
}
|
||||
|
||||
public String getVatRate() { return vatRate; }
|
||||
public void setVatRate(String vatRate) { this.vatRate = vatRate; }
|
||||
public String getInvoiceDate() {
|
||||
return invoiceDate;
|
||||
}
|
||||
|
||||
public String getVatAmount() { return vatAmount; }
|
||||
public void setVatAmount(String vatAmount) { this.vatAmount = vatAmount; }
|
||||
public void setInvoiceDate(String invoiceDate) {
|
||||
this.invoiceDate = invoiceDate;
|
||||
}
|
||||
|
||||
public String getTotalAmount() { return totalAmount; }
|
||||
public void setTotalAmount(String totalAmount) { this.totalAmount = totalAmount; }
|
||||
public String getInvoiceText() {
|
||||
return invoiceText;
|
||||
}
|
||||
|
||||
public String getPaymentTerms() { return paymentTerms; }
|
||||
public void setPaymentTerms(String paymentTerms) { this.paymentTerms = paymentTerms; }
|
||||
public void setInvoiceText(String invoiceText) {
|
||||
this.invoiceText = invoiceText;
|
||||
}
|
||||
|
||||
public String getFooterText() { return footerText; }
|
||||
public void setFooterText(String footerText) { this.footerText = footerText; }
|
||||
public String getSenderLine() {
|
||||
return senderLine;
|
||||
}
|
||||
|
||||
public void setSenderLine(String senderLine) {
|
||||
this.senderLine = senderLine;
|
||||
}
|
||||
|
||||
public String getRecipientName() {
|
||||
return recipientName;
|
||||
}
|
||||
|
||||
public void setRecipientName(String recipientName) {
|
||||
this.recipientName = recipientName;
|
||||
}
|
||||
|
||||
public String getRecipientDepartment() {
|
||||
return recipientDepartment;
|
||||
}
|
||||
|
||||
public void setRecipientDepartment(String recipientDepartment) {
|
||||
this.recipientDepartment = recipientDepartment;
|
||||
}
|
||||
|
||||
public String getRecipientStreet() {
|
||||
return recipientStreet;
|
||||
}
|
||||
|
||||
public void setRecipientStreet(String recipientStreet) {
|
||||
this.recipientStreet = recipientStreet;
|
||||
}
|
||||
|
||||
public String getRecipientCity() {
|
||||
return recipientCity;
|
||||
}
|
||||
|
||||
public void setRecipientCity(String recipientCity) {
|
||||
this.recipientCity = recipientCity;
|
||||
}
|
||||
|
||||
public List<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;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,12 @@
|
||||
package de.assecutor.votianlt.model.task;
|
||||
|
||||
public enum TaskType {
|
||||
CONFIRMATION("Bestätigung"), SIGNATURE("Unterschrift"), TODOLIST("To-Do Liste"), PHOTO("Foto"), BARCODE("Barcode"), COMMENT("Kommentar");
|
||||
CONFIRMATION("Bestätigung"),
|
||||
SIGNATURE("Unterschrift"),
|
||||
TODOLIST("To-Do Liste"),
|
||||
PHOTO("Foto"),
|
||||
BARCODE("Barcode"),
|
||||
COMMENT("Kommentar");
|
||||
|
||||
private final String displayName;
|
||||
|
||||
|
||||
@@ -54,33 +54,28 @@ class MqttPublisherImpl implements MqttPublisher {
|
||||
String messageType = parts[3];
|
||||
|
||||
// Use MessageDeliveryService for reliable delivery
|
||||
DeliveryOptions options = DeliveryOptions.builder()
|
||||
.requiresAck(true)
|
||||
.retained(retained)
|
||||
.build();
|
||||
DeliveryOptions options = DeliveryOptions.builder().requiresAck(true).retained(retained).build();
|
||||
|
||||
deliveryService.sendToClient(clientId, messageType, payload, options)
|
||||
.thenAccept(receipt -> {
|
||||
log.info("=== MESSAGE DELIVERY SUBMITTED ===");
|
||||
log.info("Topic: {}", topic);
|
||||
log.info("Message ID: {}", receipt.getMessageId());
|
||||
log.info("Status: {}", receipt.getStatus());
|
||||
log.info("Retained: {}", retained);
|
||||
deliveryService.sendToClient(clientId, messageType, payload, options).thenAccept(receipt -> {
|
||||
log.info("=== MESSAGE DELIVERY SUBMITTED ===");
|
||||
log.info("Topic: {}", topic);
|
||||
log.info("Message ID: {}", receipt.getMessageId());
|
||||
log.info("Status: {}", receipt.getStatus());
|
||||
log.info("Retained: {}", retained);
|
||||
|
||||
// Log payload for debugging
|
||||
try {
|
||||
String json = (payload instanceof String s) ? s : objectMapper.writeValueAsString(payload);
|
||||
log.info("Payload: {}", json);
|
||||
} catch (Exception e) {
|
||||
log.debug("Could not serialize payload for logging: {}", e.getMessage());
|
||||
}
|
||||
// Log payload for debugging
|
||||
try {
|
||||
String json = (payload instanceof String s) ? s : objectMapper.writeValueAsString(payload);
|
||||
log.info("Payload: {}", json);
|
||||
} catch (Exception e) {
|
||||
log.debug("Could not serialize payload for logging: {}", e.getMessage());
|
||||
}
|
||||
|
||||
log.info("=== END MESSAGE DELIVERY ===");
|
||||
})
|
||||
.exceptionally(ex -> {
|
||||
log.error("Failed to submit message for delivery to topic {}: {}", topic, ex.getMessage(), ex);
|
||||
return null;
|
||||
});
|
||||
log.info("=== END MESSAGE DELIVERY ===");
|
||||
}).exceptionally(ex -> {
|
||||
log.error("Failed to submit message for delivery to topic {}: {}", topic, ex.getMessage(), ex);
|
||||
return null;
|
||||
});
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to publish message for topic {}: {}", topic, e.getMessage(), e);
|
||||
|
||||
@@ -79,16 +79,19 @@ public final class AdminLayout extends AppLayout {
|
||||
SideNavItem dashboard = new SideNavItem("Dashboard", "admin-dashboard", new Icon(VaadinIcon.DASHBOARD));
|
||||
SideNavItem pdfTest = new SideNavItem("PDF Test", "pdf-test", new Icon(VaadinIcon.FILE_TEXT_O));
|
||||
SideNavItem priceTable = new SideNavItem("Preis-Tabelle", "admin-price-table", new Icon(VaadinIcon.COG));
|
||||
//SideNavItem systemSettings = new SideNavItem("Systemeinstellungen", "admin-settings", new Icon(VaadinIcon.COG));
|
||||
//SideNavItem userManagement = new SideNavItem("Benutzerverwaltung", "admin-users", new Icon(VaadinIcon.USERS));
|
||||
//SideNavItem systemLogs = new SideNavItem("System-Logs", "admin-logs", new Icon(VaadinIcon.FILE_TEXT));
|
||||
// SideNavItem systemSettings = new SideNavItem("Systemeinstellungen",
|
||||
// "admin-settings", new Icon(VaadinIcon.COG));
|
||||
// SideNavItem userManagement = new SideNavItem("Benutzerverwaltung",
|
||||
// "admin-users", new Icon(VaadinIcon.USERS));
|
||||
// SideNavItem systemLogs = new SideNavItem("System-Logs", "admin-logs", new
|
||||
// Icon(VaadinIcon.FILE_TEXT));
|
||||
|
||||
nav.addItem(dashboard);
|
||||
nav.addItem(pdfTest);
|
||||
nav.addItem(priceTable);
|
||||
//nav.addItem(systemSettings);
|
||||
//nav.addItem(userManagement);
|
||||
//nav.addItem(systemLogs);
|
||||
// nav.addItem(systemSettings);
|
||||
// nav.addItem(userManagement);
|
||||
// nav.addItem(systemLogs);
|
||||
|
||||
// Create a vertical layout to hold menu items
|
||||
VerticalLayout navContainer = new VerticalLayout();
|
||||
|
||||
@@ -10,10 +10,12 @@ import de.assecutor.votianlt.security.SecurityService;
|
||||
import de.assecutor.votianlt.repository.CargoItemRepository;
|
||||
import de.assecutor.votianlt.service.JobHistoryService;
|
||||
import de.assecutor.votianlt.service.EmailService;
|
||||
import de.assecutor.votianlt.event.JobCreatedEvent;
|
||||
import java.util.Objects;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.bson.types.ObjectId;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
@@ -32,6 +34,7 @@ public class AddJobService {
|
||||
private final SecurityService securityService;
|
||||
private final JobHistoryService jobHistoryService;
|
||||
private final EmailService emailService;
|
||||
private final ApplicationEventPublisher eventPublisher;
|
||||
|
||||
/**
|
||||
* Speichert einen neuen Auftrag samt CargoItems und Tasks
|
||||
@@ -118,6 +121,14 @@ public class AddJobService {
|
||||
e.getMessage());
|
||||
}
|
||||
|
||||
// Publish job created event for real-time UI updates
|
||||
try {
|
||||
eventPublisher.publishEvent(new JobCreatedEvent(this, savedJob));
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to publish job created event for job {}: {}", savedJob.getIdAsString(),
|
||||
e.getMessage());
|
||||
}
|
||||
|
||||
log.info("Auftrag erfolgreich gespeichert: {}", savedJob.getJobNumber());
|
||||
return savedJob;
|
||||
|
||||
|
||||
@@ -26,8 +26,7 @@ public class UserInvoiceDataService {
|
||||
}
|
||||
|
||||
public UserInvoiceData createOrUpdate(ObjectId userId, boolean billingEnabled, String prefix, String ustId,
|
||||
String taxNumber, String bankName, String iban, String taxRate,
|
||||
String introText, String paymentTerms) {
|
||||
String taxNumber, String bankName, String iban, String taxRate, String introText, String paymentTerms) {
|
||||
// If billing is disabled, delete any existing record and return null
|
||||
if (!billingEnabled) {
|
||||
deleteByUserId(userId);
|
||||
|
||||
@@ -102,7 +102,6 @@ public class AddAppUserView extends VerticalLayout {
|
||||
phoneField.setRequiredIndicatorVisible(true);
|
||||
phoneField.addBlurListener(e -> validateField(phoneField, "Telefonnummer ist ein Pflichtfeld"));
|
||||
|
||||
|
||||
emailField.setWidthFull();
|
||||
emailField.setRequiredIndicatorVisible(true);
|
||||
emailField.addBlurListener(e -> validateEmailField());
|
||||
@@ -122,7 +121,6 @@ public class AddAppUserView extends VerticalLayout {
|
||||
confirmPasswordField.setRequiredIndicatorVisible(true);
|
||||
confirmPasswordField.addBlurListener(e -> validateConfirmPasswordField());
|
||||
|
||||
|
||||
// Add fields to form
|
||||
formLayout.add(designationField);
|
||||
formLayout.add(nameLayout);
|
||||
@@ -200,10 +198,12 @@ public class AddAppUserView extends VerticalLayout {
|
||||
emailField.setInvalid(true);
|
||||
emailField.setErrorMessage("E-Mail-Adresse bereits vorhanden");
|
||||
} else {
|
||||
Notification.show("Ein Fehler ist aufgetreten: Doppelter Wert gefunden", 5000, Notification.Position.MIDDLE);
|
||||
Notification.show("Ein Fehler ist aufgetreten: Doppelter Wert gefunden", 5000,
|
||||
Notification.Position.MIDDLE);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Notification.show("Fehler beim Anlegen des App-Nutzers: " + e.getMessage(), 5000, Notification.Position.MIDDLE);
|
||||
Notification.show("Fehler beim Anlegen des App-Nutzers: " + e.getMessage(), 5000,
|
||||
Notification.Position.MIDDLE);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,7 +269,6 @@ public class AddAppUserView extends VerticalLayout {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private boolean validateAllFields() {
|
||||
validateField(designationField, "Kennung ist ein Pflichtfeld");
|
||||
validateField(firstnameField, "Vorname ist ein Pflichtfeld");
|
||||
@@ -280,7 +279,7 @@ public class AddAppUserView extends VerticalLayout {
|
||||
validateConfirmPasswordField();
|
||||
|
||||
return !designationField.isInvalid() && !firstnameField.isInvalid() && !lastnameField.isInvalid()
|
||||
&& !phoneField.isInvalid() && !emailField.isInvalid()
|
||||
&& !passwordField.isInvalid() && !confirmPasswordField.isInvalid();
|
||||
&& !phoneField.isInvalid() && !emailField.isInvalid() && !passwordField.isInvalid()
|
||||
&& !confirmPasswordField.isInvalid();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -264,8 +264,8 @@ public class AddCustomerView extends Main {
|
||||
validateField(city);
|
||||
validateEmail();
|
||||
|
||||
return !companyName.isInvalid() && !firstName.isInvalid() && !lastName.isInvalid()
|
||||
&& !telephone.isInvalid() && !mail.isInvalid() && !street.isInvalid()
|
||||
&& !houseNumber.isInvalid() && !zip.isInvalid() && !city.isInvalid();
|
||||
return !companyName.isInvalid() && !firstName.isInvalid() && !lastName.isInvalid() && !telephone.isInvalid()
|
||||
&& !mail.isInvalid() && !street.isInvalid() && !houseNumber.isInvalid() && !zip.isInvalid()
|
||||
&& !city.isInvalid();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,6 +132,7 @@ public class AddJobView extends Main {
|
||||
private Span cargoError;
|
||||
private VerticalLayout cargoList;
|
||||
private VerticalLayout tasksList;
|
||||
private ComboBox<TaskTemplate> templateComboBox;
|
||||
private TextArea remarkArea;
|
||||
private VerticalLayout pickupSection;
|
||||
private VerticalLayout deliverySection;
|
||||
@@ -145,8 +146,8 @@ public class AddJobView extends Main {
|
||||
private List<AppUser> availableAppUsers;
|
||||
|
||||
public AddJobView(AddJobService addJobService, AddCustomerService addCustomerService,
|
||||
CustomerService customerService, AppUserService appUserService,
|
||||
TaskTemplateService taskTemplateService, SecurityService securityService) {
|
||||
CustomerService customerService, AppUserService appUserService, TaskTemplateService taskTemplateService,
|
||||
SecurityService securityService) {
|
||||
this.addJobService = addJobService;
|
||||
this.addCustomerService = addCustomerService;
|
||||
this.customerService = customerService;
|
||||
@@ -367,28 +368,22 @@ public class AddJobView extends Main {
|
||||
pickupDate = new DatePicker("Datum");
|
||||
pickupDate.setRequiredIndicatorVisible(true);
|
||||
pickupDate.setLocale(java.util.Locale.GERMANY); // Monday as first day of week
|
||||
pickupDate.setI18n(new DatePicker.DatePickerI18n()
|
||||
.setFirstDayOfWeek(1) // 1 = Monday
|
||||
.setMonthNames(java.util.Arrays.asList(
|
||||
"Januar", "Februar", "März", "April", "Mai", "Juni",
|
||||
"Juli", "August", "September", "Oktober", "November", "Dezember"))
|
||||
.setWeekdays(java.util.Arrays.asList(
|
||||
"Sonntag", "Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag"))
|
||||
.setWeekdaysShort(java.util.Arrays.asList(
|
||||
"So", "Mo", "Di", "Mi", "Do", "Fr", "Sa")));
|
||||
pickupDate.setI18n(new DatePicker.DatePickerI18n().setFirstDayOfWeek(1) // 1 = Monday
|
||||
.setMonthNames(java.util.Arrays.asList("Januar", "Februar", "März", "April", "Mai", "Juni", "Juli",
|
||||
"August", "September", "Oktober", "November", "Dezember"))
|
||||
.setWeekdays(java.util.Arrays.asList("Sonntag", "Montag", "Dienstag", "Mittwoch", "Donnerstag",
|
||||
"Freitag", "Samstag"))
|
||||
.setWeekdaysShort(java.util.Arrays.asList("So", "Mo", "Di", "Mi", "Do", "Fr", "Sa")));
|
||||
|
||||
deliveryDate = new DatePicker("Datum");
|
||||
deliveryDate.setRequiredIndicatorVisible(true);
|
||||
deliveryDate.setLocale(java.util.Locale.GERMANY); // Monday as first day of week
|
||||
deliveryDate.setI18n(new DatePicker.DatePickerI18n()
|
||||
.setFirstDayOfWeek(1) // 1 = Monday
|
||||
.setMonthNames(java.util.Arrays.asList(
|
||||
"Januar", "Februar", "März", "April", "Mai", "Juni",
|
||||
"Juli", "August", "September", "Oktober", "November", "Dezember"))
|
||||
.setWeekdays(java.util.Arrays.asList(
|
||||
"Sonntag", "Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag"))
|
||||
.setWeekdaysShort(java.util.Arrays.asList(
|
||||
"So", "Mo", "Di", "Mi", "Do", "Fr", "Sa")));
|
||||
deliveryDate.setI18n(new DatePicker.DatePickerI18n().setFirstDayOfWeek(1) // 1 = Monday
|
||||
.setMonthNames(java.util.Arrays.asList("Januar", "Februar", "März", "April", "Mai", "Juni", "Juli",
|
||||
"August", "September", "Oktober", "November", "Dezember"))
|
||||
.setWeekdays(java.util.Arrays.asList("Sonntag", "Montag", "Dienstag", "Mittwoch", "Donnerstag",
|
||||
"Freitag", "Samstag"))
|
||||
.setWeekdaysShort(java.util.Arrays.asList("So", "Mo", "Di", "Mi", "Do", "Fr", "Sa")));
|
||||
|
||||
// Submit button - initially disabled until all required fields are valid
|
||||
submitButton = new Button("Auftrag anlegen", event -> submit());
|
||||
@@ -423,6 +418,15 @@ public class AddJobView extends Main {
|
||||
// Tab 4: Tasks
|
||||
tasksTab = tabSheet.add("Aufgaben", createTasksTab());
|
||||
|
||||
// Disable tasks tab initially if digital processing is off
|
||||
if (!Boolean.TRUE.equals(digitalProcessing.getValue())) {
|
||||
tasksTab.setEnabled(false);
|
||||
tasksState.clear();
|
||||
if (tasksList != null) {
|
||||
tasksList.removeAll();
|
||||
}
|
||||
}
|
||||
|
||||
// Tab 5: Price & Submit
|
||||
priceTab = tabSheet.add("Preis & Abschluss", createPriceAndSubmitTab());
|
||||
|
||||
@@ -853,8 +857,7 @@ public class AddJobView extends Main {
|
||||
binder.bind(deliveryPhone, Job::getDeliveryPhone, Job::setDeliveryPhone);
|
||||
binder.bind(deliveryAddressAddition, Job::getDeliveryAddressAddition, Job::setDeliveryAddressAddition);
|
||||
|
||||
binder.forField(digitalProcessing).bind(
|
||||
Job::isDigitalProcessing,
|
||||
binder.forField(digitalProcessing).bind(Job::isDigitalProcessing,
|
||||
(job, value) -> job.setDigitalProcessing(Boolean.TRUE.equals(value)));
|
||||
|
||||
// Bind appUser with converter: AppUser object <-> String ID
|
||||
@@ -876,10 +879,31 @@ public class AddJobView extends Main {
|
||||
}, "Bitte App-Nutzer auswählen, wenn Digitale Abwicklung aktiv ist")
|
||||
.bind(Job::getAppUser, Job::setAppUser);
|
||||
|
||||
// Toggle required indicator for App-Nutzer based on digitalProcessing
|
||||
// Toggle required indicator and visibility for App-Nutzer based on
|
||||
// digitalProcessing
|
||||
digitalProcessing.addValueChangeListener(e -> {
|
||||
boolean required = Boolean.TRUE.equals(e.getValue());
|
||||
appUser.setRequiredIndicatorVisible(required);
|
||||
appUser.setVisible(required);
|
||||
|
||||
// Enable/disable tasks tab based on digital processing
|
||||
if (tasksTab != null) {
|
||||
tasksTab.setEnabled(required);
|
||||
}
|
||||
if (!required) {
|
||||
// Clear app user and all tasks when digital processing is disabled
|
||||
appUser.clear();
|
||||
tasksState.clear();
|
||||
if (tasksList != null) {
|
||||
tasksList.removeAll();
|
||||
}
|
||||
} else {
|
||||
// Add an empty task row when digital processing is re-enabled
|
||||
if (tasksState.isEmpty() && tasksList != null) {
|
||||
createTaskRow();
|
||||
}
|
||||
}
|
||||
|
||||
triggerValidation();
|
||||
updateTabLabels();
|
||||
});
|
||||
@@ -888,8 +912,10 @@ public class AddJobView extends Main {
|
||||
triggerValidation();
|
||||
updateTabLabels();
|
||||
});
|
||||
// Initialize required indicator state
|
||||
appUser.setRequiredIndicatorVisible(Boolean.TRUE.equals(digitalProcessing.getValue()));
|
||||
// Initialize required indicator and visibility state
|
||||
boolean digitalInitial = Boolean.TRUE.equals(digitalProcessing.getValue());
|
||||
appUser.setRequiredIndicatorVisible(digitalInitial);
|
||||
appUser.setVisible(digitalInitial);
|
||||
|
||||
// Set up validation triggers and visual styling
|
||||
setupValidationTriggers();
|
||||
@@ -956,11 +982,8 @@ public class AddJobView extends Main {
|
||||
|
||||
// Update submit button state based on all validation checks
|
||||
if (submitButton != null) {
|
||||
boolean hasErrors = hasAddressValidationErrors()
|
||||
|| hasAppointmentValidationErrors()
|
||||
|| hasCargoValidationErrors()
|
||||
|| hasPriceValidationErrors()
|
||||
|| hasTasksValidationErrors();
|
||||
boolean hasErrors = hasAddressValidationErrors() || hasAppointmentValidationErrors()
|
||||
|| hasCargoValidationErrors() || hasPriceValidationErrors() || hasTasksValidationErrors();
|
||||
submitButton.setEnabled(!hasErrors);
|
||||
}
|
||||
}
|
||||
@@ -1073,8 +1096,7 @@ public class AddJobView extends Main {
|
||||
return true;
|
||||
}
|
||||
// Check if at least one todo item is non-empty
|
||||
boolean hasValidTodoItem = todoItems.stream()
|
||||
.anyMatch(item -> item != null && !item.trim().isEmpty());
|
||||
boolean hasValidTodoItem = todoItems.stream().anyMatch(item -> item != null && !item.trim().isEmpty());
|
||||
if (!hasValidTodoItem) {
|
||||
return true;
|
||||
}
|
||||
@@ -1164,9 +1186,10 @@ public class AddJobView extends Main {
|
||||
addCustomerService.addCustomer(deliveryCustomer);
|
||||
}
|
||||
|
||||
// All validations passed, save the job with cargo items and tasks (tasks may be
|
||||
// empty)
|
||||
Job savedJob = addJobService.addJobWithCargo(job, cargoFilled, tasksState);
|
||||
// All validations passed, save the job with cargo items and tasks
|
||||
// If digital processing is disabled, don't save any tasks
|
||||
List<BaseTask> tasksToSave = job.isDigitalProcessing() ? tasksState : List.of();
|
||||
Job savedJob = addJobService.addJobWithCargo(job, cargoFilled, tasksToSave);
|
||||
|
||||
// Erfolgsmeldung und Navigation zur Zusammenfassung
|
||||
Notification successNotification = Notification
|
||||
@@ -1426,7 +1449,7 @@ public class AddJobView extends Main {
|
||||
tasksTitle.getStyle().set("margin", "0");
|
||||
tasksTitle.getStyle().set("white-space", "nowrap");
|
||||
|
||||
ComboBox<TaskTemplate> templateComboBox = new ComboBox<>();
|
||||
templateComboBox = new ComboBox<>();
|
||||
templateComboBox.setPlaceholder("Template auswählen...");
|
||||
templateComboBox.setItemLabelGenerator(TaskTemplate::getTemplateName);
|
||||
templateComboBox.setClearButtonVisible(true);
|
||||
@@ -1688,24 +1711,31 @@ public class AddJobView extends Main {
|
||||
BaseTask newTask = createTaskByType(selectedType);
|
||||
BaseTask oldTask = currentTask[0];
|
||||
|
||||
newTask.setDescription(oldTask.getDescription());
|
||||
newTask.setCompleted(oldTask.isCompleted());
|
||||
newTask.setCompletedAt(oldTask.getCompletedAt());
|
||||
newTask.setCompletedBy(oldTask.getCompletedBy());
|
||||
|
||||
// Preserve task-specific properties
|
||||
switch (oldTask) {
|
||||
case ConfirmationTask oldConfirmationTask when newTask instanceof ConfirmationTask newConfirmationTask -> newConfirmationTask.setButtonText(oldConfirmationTask.getButtonText());
|
||||
case TodoListTask oldTodoTask when newTask instanceof TodoListTask newTodoTask -> newTodoTask.setTodoItems(oldTodoTask.getTodoItems());
|
||||
case PhotoTask oldPhotoTask when newTask instanceof PhotoTask newPhotoTask -> {
|
||||
newPhotoTask.setMinPhotoCount(oldPhotoTask.getMinPhotoCount());
|
||||
newPhotoTask.setMaxPhotoCount(oldPhotoTask.getMaxPhotoCount());
|
||||
}
|
||||
case BarcodeTask oldBarcodeTask when newTask instanceof BarcodeTask newBarcodeTask -> {
|
||||
newBarcodeTask.setMinBarcodeCount(oldBarcodeTask.getMinBarcodeCount());
|
||||
newBarcodeTask.setMaxBarcodeCount(oldBarcodeTask.getMaxBarcodeCount());
|
||||
}
|
||||
default -> {
|
||||
}
|
||||
case ConfirmationTask oldConfirmationTask when newTask instanceof ConfirmationTask newConfirmationTask ->
|
||||
newConfirmationTask.setButtonText(oldConfirmationTask.getButtonText());
|
||||
case TodoListTask oldTodoTask when newTask instanceof TodoListTask newTodoTask ->
|
||||
newTodoTask.setTodoItems(oldTodoTask.getTodoItems());
|
||||
case PhotoTask oldPhotoTask when newTask instanceof PhotoTask newPhotoTask -> {
|
||||
newPhotoTask.setMinPhotoCount(oldPhotoTask.getMinPhotoCount());
|
||||
newPhotoTask.setMaxPhotoCount(oldPhotoTask.getMaxPhotoCount());
|
||||
}
|
||||
case BarcodeTask oldBarcodeTask when newTask instanceof BarcodeTask newBarcodeTask -> {
|
||||
newBarcodeTask.setMinBarcodeCount(oldBarcodeTask.getMinBarcodeCount());
|
||||
newBarcodeTask.setMaxBarcodeCount(oldBarcodeTask.getMaxBarcodeCount());
|
||||
}
|
||||
case CommentTask oldCommentTask when newTask instanceof CommentTask newCommentTask -> {
|
||||
newCommentTask.setCommentText(oldCommentTask.getCommentText());
|
||||
newCommentTask.setRequired(oldCommentTask.isRequired());
|
||||
}
|
||||
default -> {
|
||||
}
|
||||
}
|
||||
|
||||
// Replace in state and preserve order
|
||||
@@ -1974,6 +2004,27 @@ public class AddJobView extends Main {
|
||||
configContainer.add(barcodeLayout);
|
||||
break;
|
||||
|
||||
case COMMENT:
|
||||
CommentTask commentTask = (CommentTask) task;
|
||||
|
||||
TextField commentTextField = new TextField("Kommentar-Platzhalter");
|
||||
commentTextField.setPlaceholder("Hinweis für den Benutzer...");
|
||||
commentTextField.setWidthFull();
|
||||
commentTextField.setValue(commentTask.getCommentText() != null ? commentTask.getCommentText() : "");
|
||||
commentTextField.addValueChangeListener(ev -> {
|
||||
commentTask.setCommentText(ev.getValue());
|
||||
});
|
||||
|
||||
com.vaadin.flow.component.checkbox.Checkbox requiredCheckbox = new com.vaadin.flow.component.checkbox.Checkbox(
|
||||
"Pflichtfeld");
|
||||
requiredCheckbox.setValue(commentTask.isRequired());
|
||||
requiredCheckbox.addValueChangeListener(ev -> {
|
||||
commentTask.setRequired(ev.getValue());
|
||||
});
|
||||
|
||||
configContainer.add(commentTextField, requiredCheckbox);
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new IllegalArgumentException("Unbekannter TaskType: " + taskType);
|
||||
}
|
||||
@@ -2028,7 +2079,8 @@ public class AddJobView extends Main {
|
||||
saveButton.addClickListener(e -> {
|
||||
String templateName = templateNameField.getValue();
|
||||
if (templateName == null || templateName.trim().isEmpty()) {
|
||||
Notification.show("Bitte geben Sie einen Template-Namen ein", 3000, Notification.Position.BOTTOM_END);
|
||||
Notification.show("Bitte geben Sie einen Template-Namen ein", 3000,
|
||||
Notification.Position.BOTTOM_END);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2042,20 +2094,20 @@ public class AddJobView extends Main {
|
||||
}
|
||||
|
||||
// Save template with task type information and specific data
|
||||
taskTemplateService.createTemplate(
|
||||
securityService.getCurrentDatabaseUser().getId(),
|
||||
templateName.trim(),
|
||||
tasksCopy
|
||||
);
|
||||
taskTemplateService.createTemplate(securityService.getCurrentDatabaseUser().getId(),
|
||||
templateName.trim(), tasksCopy);
|
||||
|
||||
dialog.close();
|
||||
Notification.show("Template '" + templateName + "' erfolgreich gespeichert", 3000, Notification.Position.BOTTOM_END);
|
||||
loadTemplatesIntoComboBox(templateComboBox);
|
||||
Notification.show("Template '" + templateName + "' erfolgreich gespeichert", 3000,
|
||||
Notification.Position.BOTTOM_END);
|
||||
|
||||
} catch (RuntimeException ex) {
|
||||
Notification.show(ex.getMessage(), 4000, Notification.Position.MIDDLE);
|
||||
} catch (Exception ex) {
|
||||
log.error("Error saving task template", ex);
|
||||
Notification.show("Fehler beim Speichern des Templates: " + ex.getMessage(), 4000, Notification.Position.MIDDLE);
|
||||
Notification.show("Fehler beim Speichern des Templates: " + ex.getMessage(), 4000,
|
||||
Notification.Position.MIDDLE);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2076,8 +2128,8 @@ public class AddJobView extends Main {
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a deep copy of a task to avoid reference issues in templates
|
||||
* Saves all task-specific data including type and specific properties
|
||||
* Creates a deep copy of a task to avoid reference issues in templates Saves
|
||||
* all task-specific data including type and specific properties
|
||||
*/
|
||||
private BaseTask createTaskCopy(BaseTask original) {
|
||||
BaseTask copy = null;
|
||||
@@ -2102,28 +2154,23 @@ public class AddJobView extends Main {
|
||||
} else if (original instanceof PhotoTask) {
|
||||
PhotoTask origTask = (PhotoTask) original;
|
||||
// Copy with all photo-specific parameters
|
||||
copy = new PhotoTask(
|
||||
origTask.getMinPhotoCount() != null ? origTask.getMinPhotoCount() : 1,
|
||||
origTask.getMaxPhotoCount() != null ? origTask.getMaxPhotoCount() : 10
|
||||
);
|
||||
copy = new PhotoTask(origTask.getMinPhotoCount() != null ? origTask.getMinPhotoCount() : 1,
|
||||
origTask.getMaxPhotoCount() != null ? origTask.getMaxPhotoCount() : 10);
|
||||
} else if (original instanceof BarcodeTask) {
|
||||
BarcodeTask origTask = (BarcodeTask) original;
|
||||
// Copy with all barcode-specific parameters
|
||||
copy = new BarcodeTask(
|
||||
origTask.getMinBarcodeCount() != null ? origTask.getMinBarcodeCount() : 1,
|
||||
origTask.getMaxBarcodeCount() != null ? origTask.getMaxBarcodeCount() : 10
|
||||
);
|
||||
copy = new BarcodeTask(origTask.getMinBarcodeCount() != null ? origTask.getMinBarcodeCount() : 1,
|
||||
origTask.getMaxBarcodeCount() != null ? origTask.getMaxBarcodeCount() : 10);
|
||||
} else if (original instanceof CommentTask) {
|
||||
CommentTask origTask = (CommentTask) original;
|
||||
// Copy with all comment-specific parameters
|
||||
copy = new CommentTask(
|
||||
origTask.getCommentText() != null ? origTask.getCommentText() : "",
|
||||
origTask.isRequired()
|
||||
);
|
||||
copy = new CommentTask(origTask.getCommentText() != null ? origTask.getCommentText() : "",
|
||||
origTask.isRequired());
|
||||
}
|
||||
|
||||
if (copy != null) {
|
||||
// Copy all base task properties
|
||||
copy.setDescription(original.getDescription());
|
||||
copy.setTaskOrder(original.getTaskOrder() != null ? original.getTaskOrder() : 0);
|
||||
copy.setCompleted(original.isCompleted());
|
||||
copy.setCompletedAt(original.getCompletedAt());
|
||||
@@ -2138,9 +2185,8 @@ public class AddJobView extends Main {
|
||||
*/
|
||||
private void loadTemplatesIntoComboBox(ComboBox<TaskTemplate> templateComboBox) {
|
||||
try {
|
||||
List<TaskTemplate> templates = taskTemplateService.findByUserId(
|
||||
securityService.getCurrentDatabaseUser().getId()
|
||||
);
|
||||
List<TaskTemplate> templates = taskTemplateService
|
||||
.findByUserId(securityService.getCurrentDatabaseUser().getId());
|
||||
templateComboBox.setItems(templates);
|
||||
} catch (Exception e) {
|
||||
log.error("Error loading templates", e);
|
||||
@@ -2154,8 +2200,8 @@ public class AddJobView extends Main {
|
||||
private void loadTasksFromTemplate(TaskTemplate template, ComboBox<TaskTemplate> templateComboBox) {
|
||||
ConfirmDialog confirmDialog = new ConfirmDialog();
|
||||
confirmDialog.setHeader("Template laden");
|
||||
confirmDialog.setText("Möchten Sie wirklich das Template '" + template.getTemplateName() +
|
||||
"' laden? Alle aktuellen Aufgaben werden ersetzt.");
|
||||
confirmDialog.setText("Möchten Sie wirklich das Template '" + template.getTemplateName()
|
||||
+ "' laden? Alle aktuellen Aufgaben werden ersetzt.");
|
||||
confirmDialog.setCancelable(true);
|
||||
confirmDialog.setCancelText("Abbrechen");
|
||||
confirmDialog.setConfirmText("Laden");
|
||||
@@ -2181,13 +2227,17 @@ public class AddJobView extends Main {
|
||||
// Clear the combobox selection
|
||||
templateComboBox.clear();
|
||||
|
||||
Notification.show("Template '" + template.getTemplateName() + "' erfolgreich geladen",
|
||||
3000, Notification.Position.BOTTOM_END);
|
||||
// Re-validate to enable submit button if all fields are valid
|
||||
triggerValidation();
|
||||
updateTabLabels();
|
||||
|
||||
Notification.show("Template '" + template.getTemplateName() + "' erfolgreich geladen", 3000,
|
||||
Notification.Position.BOTTOM_END);
|
||||
|
||||
} catch (Exception ex) {
|
||||
log.error("Error loading template tasks", ex);
|
||||
Notification.show("Fehler beim Laden des Templates: " + ex.getMessage(),
|
||||
4000, Notification.Position.MIDDLE);
|
||||
Notification.show("Fehler beim Laden des Templates: " + ex.getMessage(), 4000,
|
||||
Notification.Position.MIDDLE);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2200,11 +2250,12 @@ public class AddJobView extends Main {
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a task row from an existing task (used when loading templates)
|
||||
* This creates a UI row and populates it with the task's specific data
|
||||
* Creates a task row from an existing task (used when loading templates) This
|
||||
* creates a UI row and populates it with the task's specific data
|
||||
*/
|
||||
private void createTaskRowFromTask(BaseTask task) {
|
||||
// Don't call createTaskRow() directly, as it would create a default ConfirmationTask
|
||||
// Don't call createTaskRow() directly, as it would create a default
|
||||
// ConfirmationTask
|
||||
// Instead, create the UI components and set them up with the loaded task
|
||||
|
||||
VerticalLayout taskContainer = new VerticalLayout();
|
||||
@@ -2251,36 +2302,46 @@ public class AddJobView extends Main {
|
||||
|
||||
final BaseTask[] currentTask = { task };
|
||||
|
||||
// Set up the value change listener for the combo box
|
||||
// Set the combo value BEFORE registering the listener so the listener does
|
||||
// NOT fire during initialization. The loaded task object is already correct
|
||||
// and is already in tasksState — no replacement needed.
|
||||
TaskType taskType = getTaskTypeFromTask(task);
|
||||
if (taskType != null) {
|
||||
taskTypeCombo.setValue(taskType);
|
||||
}
|
||||
|
||||
// Register the listener for user-initiated type changes only
|
||||
taskTypeCombo.addValueChangeListener(ev -> {
|
||||
TaskType selectedType = ev.getValue();
|
||||
if (selectedType != null) {
|
||||
BaseTask newTask = createTaskByType(selectedType);
|
||||
BaseTask oldTask = currentTask[0];
|
||||
|
||||
newTask.setDescription(oldTask.getDescription());
|
||||
newTask.setCompleted(oldTask.isCompleted());
|
||||
newTask.setCompletedAt(oldTask.getCompletedAt());
|
||||
newTask.setCompletedBy(oldTask.getCompletedBy());
|
||||
|
||||
// Preserve task-specific properties
|
||||
switch (oldTask) {
|
||||
case ConfirmationTask oldConfirmationTask when newTask instanceof ConfirmationTask newConfirmationTask ->
|
||||
newConfirmationTask.setButtonText(oldConfirmationTask.getButtonText());
|
||||
case TodoListTask oldTodoTask when newTask instanceof TodoListTask newTodoTask ->
|
||||
newTodoTask.setTodoItems(oldTodoTask.getTodoItems());
|
||||
case PhotoTask oldPhotoTask when newTask instanceof PhotoTask newPhotoTask -> {
|
||||
newPhotoTask.setMinPhotoCount(oldPhotoTask.getMinPhotoCount());
|
||||
newPhotoTask.setMaxPhotoCount(oldPhotoTask.getMaxPhotoCount());
|
||||
}
|
||||
case BarcodeTask oldBarcodeTask when newTask instanceof BarcodeTask newBarcodeTask -> {
|
||||
newBarcodeTask.setMinBarcodeCount(oldBarcodeTask.getMinBarcodeCount());
|
||||
newBarcodeTask.setMaxBarcodeCount(oldBarcodeTask.getMaxBarcodeCount());
|
||||
}
|
||||
case CommentTask oldCommentTask when newTask instanceof CommentTask newCommentTask -> {
|
||||
newCommentTask.setCommentText(oldCommentTask.getCommentText());
|
||||
newCommentTask.setRequired(oldCommentTask.isRequired());
|
||||
}
|
||||
default -> {}
|
||||
case ConfirmationTask oldConfirmationTask when newTask instanceof ConfirmationTask newConfirmationTask ->
|
||||
newConfirmationTask.setButtonText(oldConfirmationTask.getButtonText());
|
||||
case TodoListTask oldTodoTask when newTask instanceof TodoListTask newTodoTask ->
|
||||
newTodoTask.setTodoItems(oldTodoTask.getTodoItems());
|
||||
case PhotoTask oldPhotoTask when newTask instanceof PhotoTask newPhotoTask -> {
|
||||
newPhotoTask.setMinPhotoCount(oldPhotoTask.getMinPhotoCount());
|
||||
newPhotoTask.setMaxPhotoCount(oldPhotoTask.getMaxPhotoCount());
|
||||
}
|
||||
case BarcodeTask oldBarcodeTask when newTask instanceof BarcodeTask newBarcodeTask -> {
|
||||
newBarcodeTask.setMinBarcodeCount(oldBarcodeTask.getMinBarcodeCount());
|
||||
newBarcodeTask.setMaxBarcodeCount(oldBarcodeTask.getMaxBarcodeCount());
|
||||
}
|
||||
case CommentTask oldCommentTask when newTask instanceof CommentTask newCommentTask -> {
|
||||
newCommentTask.setCommentText(oldCommentTask.getCommentText());
|
||||
newCommentTask.setRequired(oldCommentTask.isRequired());
|
||||
}
|
||||
default -> {
|
||||
}
|
||||
}
|
||||
|
||||
// Replace in state and preserve order
|
||||
@@ -2297,14 +2358,10 @@ public class AddJobView extends Main {
|
||||
}
|
||||
});
|
||||
|
||||
// Set the correct task type based on the loaded task
|
||||
TaskType taskType = getTaskTypeFromTask(task);
|
||||
if (taskType != null) {
|
||||
taskTypeCombo.setValue(taskType);
|
||||
updateTaskConfiguration(configContainer, task);
|
||||
triggerValidation();
|
||||
updateTabLabels();
|
||||
}
|
||||
// Render the UI with the loaded task directly (which IS in tasksState)
|
||||
updateTaskConfiguration(configContainer, task);
|
||||
triggerValidation();
|
||||
updateTabLabels();
|
||||
|
||||
tasksList.add(taskContainer);
|
||||
}
|
||||
@@ -2313,14 +2370,19 @@ public class AddJobView extends Main {
|
||||
* Gets the TaskType enum value from a BaseTask instance
|
||||
*/
|
||||
private TaskType getTaskTypeFromTask(BaseTask task) {
|
||||
if (task instanceof ConfirmationTask) return TaskType.CONFIRMATION;
|
||||
if (task instanceof SignatureTask) return TaskType.SIGNATURE;
|
||||
if (task instanceof TodoListTask) return TaskType.TODOLIST;
|
||||
if (task instanceof PhotoTask) return TaskType.PHOTO;
|
||||
if (task instanceof BarcodeTask) return TaskType.BARCODE;
|
||||
if (task instanceof CommentTask) return TaskType.COMMENT;
|
||||
if (task instanceof ConfirmationTask)
|
||||
return TaskType.CONFIRMATION;
|
||||
if (task instanceof SignatureTask)
|
||||
return TaskType.SIGNATURE;
|
||||
if (task instanceof TodoListTask)
|
||||
return TaskType.TODOLIST;
|
||||
if (task instanceof PhotoTask)
|
||||
return TaskType.PHOTO;
|
||||
if (task instanceof BarcodeTask)
|
||||
return TaskType.BARCODE;
|
||||
if (task instanceof CommentTask)
|
||||
return TaskType.COMMENT;
|
||||
return TaskType.CONFIRMATION; // fallback
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -16,12 +16,12 @@ import com.vaadin.flow.router.Route;
|
||||
import com.vaadin.flow.theme.lumo.LumoUtility;
|
||||
import de.assecutor.votianlt.model.JobStatus;
|
||||
import de.assecutor.votianlt.repository.*;
|
||||
import de.assecutor.votianlt.util.DateTimeFormatUtil;
|
||||
import jakarta.annotation.security.RolesAllowed;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
@Route(value = "admin-dashboard", layout = de.assecutor.votianlt.pages.base.ui.view.AdminLayout.class)
|
||||
@@ -43,19 +43,12 @@ public class AdminDashboardView extends Main {
|
||||
private final PendingMqttMessageRepository pendingMqttMessageRepository;
|
||||
|
||||
private final Div statisticsContainer;
|
||||
private final DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm");
|
||||
|
||||
@Autowired
|
||||
public AdminDashboardView(
|
||||
JobRepository jobRepository,
|
||||
TaskRepository taskRepository,
|
||||
UserRepository userRepository,
|
||||
AppUserRepository appUserRepository,
|
||||
CargoItemRepository cargoItemRepository,
|
||||
PhotoRepository photoRepository,
|
||||
BarcodeRepository barcodeRepository,
|
||||
SignatureRepository signatureRepository,
|
||||
CommentRepository commentRepository,
|
||||
public AdminDashboardView(JobRepository jobRepository, TaskRepository taskRepository, UserRepository userRepository,
|
||||
AppUserRepository appUserRepository, CargoItemRepository cargoItemRepository,
|
||||
PhotoRepository photoRepository, BarcodeRepository barcodeRepository,
|
||||
SignatureRepository signatureRepository, CommentRepository commentRepository,
|
||||
PendingMqttMessageRepository pendingMqttMessageRepository) {
|
||||
|
||||
this.jobRepository = jobRepository;
|
||||
@@ -70,8 +63,8 @@ public class AdminDashboardView extends Main {
|
||||
this.pendingMqttMessageRepository = pendingMqttMessageRepository;
|
||||
|
||||
setSizeFull();
|
||||
addClassNames(LumoUtility.BoxSizing.BORDER, LumoUtility.Display.FLEX,
|
||||
LumoUtility.FlexDirection.COLUMN, LumoUtility.Padding.MEDIUM);
|
||||
addClassNames(LumoUtility.BoxSizing.BORDER, LumoUtility.Display.FLEX, LumoUtility.FlexDirection.COLUMN,
|
||||
LumoUtility.Padding.MEDIUM);
|
||||
|
||||
// Header
|
||||
H1 title = new H1("Administrator Dashboard");
|
||||
@@ -170,7 +163,7 @@ public class AdminDashboardView extends Main {
|
||||
cards.add(createStatCard("App-Benutzer", String.valueOf(totalAppUsers), VaadinIcon.MOBILE, "purple"));
|
||||
|
||||
// Current time
|
||||
String currentTime = LocalDateTime.now().format(dateTimeFormatter);
|
||||
String currentTime = DateTimeFormatUtil.formatDateTime(LocalDateTime.now());
|
||||
cards.add(createStatCard("Letzte Aktualisierung", currentTime, VaadinIcon.CLOCK, "gray"));
|
||||
|
||||
section.add(title, cards);
|
||||
@@ -240,7 +233,8 @@ public class AdminDashboardView extends Main {
|
||||
|
||||
// Completion rate
|
||||
double completionRate = totalTasks > 0 ? (completedTasks * 100.0 / totalTasks) : 0;
|
||||
cards.add(createStatCard("Erfolgsquote", String.format("%.1f%%", completionRate), VaadinIcon.TRENDING_UP, "purple"));
|
||||
cards.add(createStatCard("Erfolgsquote", String.format("%.1f%%", completionRate), VaadinIcon.TRENDING_UP,
|
||||
"purple"));
|
||||
|
||||
section.add(title, cards);
|
||||
return section;
|
||||
@@ -321,11 +315,8 @@ public class AdminDashboardView extends Main {
|
||||
private Div createStatCard(String title, String value, VaadinIcon icon, String color) {
|
||||
Div card = new Div();
|
||||
card.addClassName(LumoUtility.Background.BASE);
|
||||
card.getStyle()
|
||||
.set("border-radius", "8px")
|
||||
.set("padding", "1rem")
|
||||
.set("box-shadow", "0 2px 4px rgba(0,0,0,0.1)")
|
||||
.set("min-width", "200px")
|
||||
card.getStyle().set("border-radius", "8px").set("padding", "1rem")
|
||||
.set("box-shadow", "0 2px 4px rgba(0,0,0,0.1)").set("min-width", "200px")
|
||||
.set("border-left", "4px solid var(--lumo-" + color + "-color, #007bff)");
|
||||
|
||||
HorizontalLayout header = new HorizontalLayout();
|
||||
|
||||
@@ -66,9 +66,7 @@ public class AdminPricetableView extends VerticalLayout {
|
||||
private void savePriceTable() {
|
||||
try {
|
||||
// Get first entry or create new one
|
||||
PriceTable priceTable = priceTableRepository.findAll().stream()
|
||||
.findFirst()
|
||||
.orElse(new PriceTable());
|
||||
PriceTable priceTable = priceTableRepository.findAll().stream().findFirst().orElse(new PriceTable());
|
||||
|
||||
priceTable.setMonthlyBasePackage(monthlyBasePackage.getValue());
|
||||
priceTable.setAppUsageLicense(appUsageLicense.getValue());
|
||||
@@ -83,17 +81,19 @@ public class AdminPricetableView extends VerticalLayout {
|
||||
|
||||
private void loadPriceTable() {
|
||||
try {
|
||||
PriceTable priceTable = priceTableRepository.findAll().stream()
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
PriceTable priceTable = priceTableRepository.findAll().stream().findFirst().orElse(null);
|
||||
|
||||
if (priceTable != null) {
|
||||
monthlyBasePackage.setValue(priceTable.getMonthlyBasePackage() != null ? priceTable.getMonthlyBasePackage() : "");
|
||||
appUsageLicense.setValue(priceTable.getAppUsageLicense() != null ? priceTable.getAppUsageLicense() : "");
|
||||
revenueParticipation.setValue(priceTable.getRevenueParticipation() != null ? priceTable.getRevenueParticipation() : "");
|
||||
monthlyBasePackage
|
||||
.setValue(priceTable.getMonthlyBasePackage() != null ? priceTable.getMonthlyBasePackage() : "");
|
||||
appUsageLicense
|
||||
.setValue(priceTable.getAppUsageLicense() != null ? priceTable.getAppUsageLicense() : "");
|
||||
revenueParticipation.setValue(
|
||||
priceTable.getRevenueParticipation() != null ? priceTable.getRevenueParticipation() : "");
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
Notification.show("Fehler beim Laden der Daten: " + ex.getMessage(), 5000, Notification.Position.BOTTOM_CENTER);
|
||||
Notification.show("Fehler beim Laden der Daten: " + ex.getMessage(), 5000,
|
||||
Notification.Position.BOTTOM_CENTER);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import jakarta.annotation.security.RolesAllowed;
|
||||
|
||||
@Route(value = "dashboard", layout = MainLayout.class)
|
||||
@PageTitle("VotianLT - Dashboard")
|
||||
@RolesAllowed({"USER"})
|
||||
@RolesAllowed({ "USER" })
|
||||
public class AuthenticatedStartView extends VerticalLayout {
|
||||
|
||||
private final SecurityService securityService;
|
||||
|
||||
@@ -24,7 +24,6 @@ import jakarta.annotation.security.RolesAllowed;
|
||||
import org.bson.types.ObjectId;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
|
||||
|
||||
@PageTitle("App-Nutzer bearbeiten")
|
||||
@Route(value = "edit-app-user", layout = de.assecutor.votianlt.pages.base.ui.view.MainLayout.class)
|
||||
@RolesAllowed({ "USER", "ADMIN" })
|
||||
@@ -194,7 +193,6 @@ public class EditAppUserView extends VerticalLayout implements HasUrlParameter<S
|
||||
appUser.setPassword(originalPassword);
|
||||
}
|
||||
|
||||
|
||||
appUserService.updateAppUser(appUser);
|
||||
Notification.show("App-Nutzer erfolgreich gespeichert", 3000, Notification.Position.MIDDLE);
|
||||
navigateBack();
|
||||
@@ -230,7 +228,6 @@ public class EditAppUserView extends VerticalLayout implements HasUrlParameter<S
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
private void deleteAppUser() {
|
||||
// Show confirmation dialog
|
||||
com.vaadin.flow.component.dialog.Dialog confirmDialog = new com.vaadin.flow.component.dialog.Dialog();
|
||||
|
||||
@@ -61,7 +61,7 @@ public class EditProfileView extends HorizontalLayout {
|
||||
private Checkbox billingEnabled;
|
||||
|
||||
public EditProfileView(UserService userService, UserInvoiceDataService userInvoiceDataService,
|
||||
CustomerInvoiceService customerInvoiceService, SecurityService securityService) {
|
||||
CustomerInvoiceService customerInvoiceService, SecurityService securityService) {
|
||||
this.userInvoiceDataService = userInvoiceDataService;
|
||||
this.customerInvoiceService = customerInvoiceService;
|
||||
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(companyAddField).bind(User::getCompanyAddition, User::setCompanyAddition);
|
||||
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(zipField).asRequired("Postleitzahl ist erforderlich").bind(User::getZip, User::setZip);
|
||||
binder.forField(cityField).asRequired("Stadt ist erforderlich").bind(User::getCity, User::setCity);
|
||||
|
||||
// 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(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"))
|
||||
.bind(User::getEmail, User::setEmail);
|
||||
binder.forField(emailField).asRequired("E-Mail ist erforderlich")
|
||||
.withValidator(new EmailValidator("Ungültige E-Mail-Adresse")).bind(User::getEmail, User::setEmail);
|
||||
// Optionale Felder
|
||||
binder.forField(mobileField).bind(User::getPhone2, User::setPhone2);
|
||||
binder.forField(faxField).bind(User::getFax, User::setFax);
|
||||
|
||||
// Abweichende Rechnungsadresse binden
|
||||
binder.forField(diffInvoiceAddress).bind(
|
||||
User::isDiffInvoiceAddress,
|
||||
binder.forField(diffInvoiceAddress).bind(User::isDiffInvoiceAddress,
|
||||
(user, value) -> user.setDiffInvoiceAddress(Boolean.TRUE.equals(value)));
|
||||
binder.forField(invCompanyField).bind(User::getInvCompany, User::setInvCompany);
|
||||
binder.forField(invCompanyAddField).bind(User::getInvCompanyAddition, User::setInvCompanyAddition);
|
||||
@@ -364,16 +365,14 @@ public class EditProfileView extends HorizontalLayout {
|
||||
|
||||
Span digitalProcessInfo = new Span("Aktiviert die digitale Auftragsabwicklung über die mobile App");
|
||||
digitalProcessInfo.getStyle().set("font-size", "var(--lumo-font-size-s)")
|
||||
.set("color", "var(--lumo-secondary-text-color)")
|
||||
.set("margin-left", "var(--lumo-space-xl)");
|
||||
.set("color", "var(--lumo-secondary-text-color)").set("margin-left", "var(--lumo-space-xl)");
|
||||
|
||||
Checkbox locateAppUser = new Checkbox("App-Nutzer orten");
|
||||
locateAppUser.setValue(currentUser.isLocationTrackingEnabled());
|
||||
|
||||
Span locateAppUserInfo = new Span("Ermöglicht die Ortung von App-Nutzern während der Auftragsausführung");
|
||||
locateAppUserInfo.getStyle().set("font-size", "var(--lumo-font-size-s)")
|
||||
.set("color", "var(--lumo-secondary-text-color)")
|
||||
.set("margin-left", "var(--lumo-space-xl)");
|
||||
.set("color", "var(--lumo-secondary-text-color)").set("margin-left", "var(--lumo-space-xl)");
|
||||
|
||||
// Save checkbox states when changed
|
||||
digitalProcess.addValueChangeListener(e -> {
|
||||
@@ -403,8 +402,7 @@ public class EditProfileView extends HorizontalLayout {
|
||||
|
||||
Span twoFactorDescription = new Span("Bei Aktivierung wird bei jeder Anmeldung ein Code per E-Mail gesendet");
|
||||
twoFactorDescription.getStyle().set("font-size", "var(--lumo-font-size-s)")
|
||||
.set("color", "var(--lumo-secondary-text-color)")
|
||||
.set("margin-left", "var(--lumo-space-xl)");
|
||||
.set("color", "var(--lumo-secondary-text-color)").set("margin-left", "var(--lumo-space-xl)");
|
||||
|
||||
securityTab.add(twoFactorLayout, twoFactorDescription);
|
||||
|
||||
@@ -426,10 +424,11 @@ public class EditProfileView extends HorizontalLayout {
|
||||
saveProfile.addClickListener(e -> {
|
||||
// Validate all required fields first
|
||||
boolean isValid = validateAllProfileFields(companyField, firstnameField, lastnameField, phoneField,
|
||||
emailField, streetField, houseNumberField, zipField, cityField);
|
||||
emailField, streetField, houseNumberField, zipField, cityField);
|
||||
|
||||
if (!isValid) {
|
||||
Notification.show("Bitte füllen Sie alle Pflichtfelder korrekt aus", 3000, Notification.Position.MIDDLE);
|
||||
Notification.show("Bitte füllen Sie alle Pflichtfelder korrekt aus", 3000,
|
||||
Notification.Position.MIDDLE);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -571,21 +570,18 @@ public class EditProfileView extends HorizontalLayout {
|
||||
List<CustomerInvoiceItem> items = new ArrayList<>();
|
||||
BigDecimal vatRate = parseVatRate(safe(taxRateField));
|
||||
|
||||
CustomerInvoiceItem item1 = new CustomerInvoiceItem(
|
||||
new BigDecimal("1"), "Stk.", "Beispiel-Dienstleistung 1",
|
||||
new BigDecimal("100.00"), vatRate);
|
||||
CustomerInvoiceItem item2 = new CustomerInvoiceItem(
|
||||
new BigDecimal("2"), "Std.", "Beispiel-Dienstleistung 2",
|
||||
new BigDecimal("50.00"), vatRate);
|
||||
CustomerInvoiceItem item1 = new CustomerInvoiceItem(new BigDecimal("1"), "Stk.",
|
||||
"Beispiel-Dienstleistung 1", new BigDecimal("100.00"), vatRate);
|
||||
CustomerInvoiceItem item2 = new CustomerInvoiceItem(new BigDecimal("2"), "Std.",
|
||||
"Beispiel-Dienstleistung 2", new BigDecimal("50.00"), vatRate);
|
||||
|
||||
items.add(item1);
|
||||
items.add(item2);
|
||||
invoiceData.setItems(items);
|
||||
|
||||
// Calculate amounts
|
||||
BigDecimal netAmount = items.stream()
|
||||
.map(CustomerInvoiceItem::getNetTotal)
|
||||
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
BigDecimal netAmount = items.stream().map(CustomerInvoiceItem::getNetTotal).reduce(BigDecimal.ZERO,
|
||||
BigDecimal::add);
|
||||
BigDecimal vatAmount = netAmount.multiply(vatRate);
|
||||
BigDecimal totalAmount = netAmount.add(vatAmount);
|
||||
|
||||
@@ -664,18 +660,9 @@ public class EditProfileView extends HorizontalLayout {
|
||||
}
|
||||
|
||||
private void saveInvoiceData() {
|
||||
currentInvoiceData = userInvoiceDataService.createOrUpdate(
|
||||
currentUser.getId(),
|
||||
billingEnabled.getValue(),
|
||||
prefixField.getValue(),
|
||||
ustIdField.getValue(),
|
||||
taxNumberField.getValue(),
|
||||
bankNameField.getValue(),
|
||||
ibanField.getValue(),
|
||||
taxRateField.getValue(),
|
||||
introTextArea.getValue(),
|
||||
termsTextArea.getValue()
|
||||
);
|
||||
currentInvoiceData = userInvoiceDataService.createOrUpdate(currentUser.getId(), billingEnabled.getValue(),
|
||||
prefixField.getValue(), ustIdField.getValue(), taxNumberField.getValue(), bankNameField.getValue(),
|
||||
ibanField.getValue(), taxRateField.getValue(), introTextArea.getValue(), termsTextArea.getValue());
|
||||
}
|
||||
|
||||
private String safe(String value) {
|
||||
@@ -708,8 +695,8 @@ public class EditProfileView extends HorizontalLayout {
|
||||
}
|
||||
|
||||
private boolean validateAllProfileFields(TextField companyField, TextField firstnameField, TextField lastnameField,
|
||||
TextField phoneField, EmailField emailField, TextField streetField,
|
||||
TextField houseNumberField, TextField zipField, TextField cityField) {
|
||||
TextField phoneField, EmailField emailField, TextField streetField, TextField houseNumberField,
|
||||
TextField zipField, TextField cityField) {
|
||||
validateField(companyField, "Firma ist ein Pflichtfeld");
|
||||
validateField(firstnameField, "Vorname ist ein Pflichtfeld");
|
||||
validateField(lastnameField, "Nachname ist ein Pflichtfeld");
|
||||
|
||||
@@ -12,12 +12,12 @@ import de.assecutor.votianlt.model.invoices.SystemInvoice;
|
||||
import de.assecutor.votianlt.model.invoices.SystemInvoiceData;
|
||||
import de.assecutor.votianlt.model.invoices.SystemInvoiceItem;
|
||||
import de.assecutor.votianlt.service.SystemInvoiceService;
|
||||
import de.assecutor.votianlt.util.DateTimeFormatUtil;
|
||||
import jakarta.annotation.security.RolesAllowed;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.text.NumberFormat;
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
@@ -94,12 +94,11 @@ public class InvoicesView extends VerticalLayout {
|
||||
}
|
||||
|
||||
private byte[] generateSystemInvoicePdf(SystemInvoice systemInvoice) throws Exception {
|
||||
DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("dd.MM.yyyy");
|
||||
NumberFormat CURRENCY_FMT = NumberFormat.getCurrencyInstance(Locale.GERMANY);
|
||||
|
||||
SystemInvoiceData data = new SystemInvoiceData();
|
||||
data.setInvoiceNumber(systemInvoice.getId());
|
||||
data.setInvoiceDate(DATE_FMT.format(systemInvoice.getDatum()));
|
||||
data.setInvoiceDate(DateTimeFormatUtil.formatDate(systemInvoice.getDatum()));
|
||||
data.setInvoiceText(systemInvoice.getBeschreibung());
|
||||
|
||||
// Empfänger aus der Zeile (nur Name in den Testdaten vorhanden)
|
||||
|
||||
@@ -26,6 +26,7 @@ import de.assecutor.votianlt.repository.JobRepository;
|
||||
import de.assecutor.votianlt.repository.PhotoRepository;
|
||||
import de.assecutor.votianlt.repository.SignatureRepository;
|
||||
import de.assecutor.votianlt.service.JobHistoryService;
|
||||
import de.assecutor.votianlt.util.DateTimeFormatUtil;
|
||||
import jakarta.annotation.security.RolesAllowed;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.bson.types.ObjectId;
|
||||
@@ -294,9 +295,7 @@ public class JobHistoryView extends Main implements HasUrlParameter<String> {
|
||||
if (dateTime == null)
|
||||
return "";
|
||||
try {
|
||||
java.time.format.DateTimeFormatter formatter = java.time.format.DateTimeFormatter
|
||||
.ofPattern("dd.MM.yyyy HH:mm");
|
||||
return dateTime.format(formatter);
|
||||
return DateTimeFormatUtil.formatDateTime(dateTime);
|
||||
} catch (Exception e) {
|
||||
return dateTime.toString();
|
||||
}
|
||||
|
||||
@@ -42,12 +42,18 @@ import de.assecutor.votianlt.model.Signature;
|
||||
import de.assecutor.votianlt.model.Barcode;
|
||||
import de.assecutor.votianlt.model.Photo;
|
||||
import de.assecutor.votianlt.model.Comment;
|
||||
import de.assecutor.votianlt.model.JobStatus;
|
||||
import de.assecutor.votianlt.pages.service.AppUserService;
|
||||
import de.assecutor.votianlt.service.JobHistoryService;
|
||||
import de.assecutor.votianlt.service.MessageService;
|
||||
import de.assecutor.votianlt.util.DateTimeFormatUtil;
|
||||
import com.vaadin.flow.component.confirmdialog.ConfirmDialog;
|
||||
import jakarta.annotation.security.RolesAllowed;
|
||||
import org.bson.types.ObjectId;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
@@ -66,6 +72,7 @@ public class JobSummaryView extends Main implements HasUrlParameter<String> {
|
||||
private final PhotoRepository photoRepository;
|
||||
private final CommentRepository commentRepository;
|
||||
private final AppUserService appUserService;
|
||||
private final JobHistoryService jobHistoryService;
|
||||
|
||||
@Value("${app.google.maps.api-key}")
|
||||
private String googleMapsApiKey;
|
||||
@@ -76,7 +83,7 @@ public class JobSummaryView extends Main implements HasUrlParameter<String> {
|
||||
public JobSummaryView(JobRepository jobRepository, CargoItemRepository cargoItemRepository,
|
||||
TaskRepository taskRepository, SignatureRepository signatureRepository, BarcodeRepository barcodeRepository,
|
||||
PhotoRepository photoRepository, CommentRepository commentRepository, AppUserService appUserService,
|
||||
MessageService messageService) {
|
||||
MessageService messageService, JobHistoryService jobHistoryService) {
|
||||
this.jobRepository = jobRepository;
|
||||
this.cargoItemRepository = cargoItemRepository;
|
||||
this.taskRepository = taskRepository;
|
||||
@@ -85,6 +92,7 @@ public class JobSummaryView extends Main implements HasUrlParameter<String> {
|
||||
this.photoRepository = photoRepository;
|
||||
this.commentRepository = commentRepository;
|
||||
this.appUserService = appUserService;
|
||||
this.jobHistoryService = jobHistoryService;
|
||||
|
||||
setSizeFull();
|
||||
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 -> {
|
||||
// Check if job has an app user assigned
|
||||
if (job.getAppUser() == null || job.getAppUser().isBlank()) {
|
||||
Notification.show(
|
||||
"Diesem Auftrag ist kein App-Nutzer zugeordnet",
|
||||
3000,
|
||||
Notification.Position.MIDDLE
|
||||
).addThemeVariants(NotificationVariant.LUMO_ERROR);
|
||||
Notification.show("Diesem Auftrag ist kein App-Nutzer zugeordnet", 3000, Notification.Position.MIDDLE)
|
||||
.addThemeVariants(NotificationVariant.LUMO_ERROR);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -270,6 +275,51 @@ public class JobSummaryView extends Main implements HasUrlParameter<String> {
|
||||
|
||||
// Google Maps Karte mit Route
|
||||
addRouteMap(job);
|
||||
|
||||
// Manual completion button for jobs without digital processing
|
||||
if (!job.isDigitalProcessing() && job.getStatus() != JobStatus.COMPLETED
|
||||
&& job.getStatus() != JobStatus.CANCELLED) {
|
||||
HorizontalLayout buttonRow = new HorizontalLayout();
|
||||
buttonRow.setWidthFull();
|
||||
buttonRow.setJustifyContentMode(HorizontalLayout.JustifyContentMode.CENTER);
|
||||
buttonRow.getStyle().set("margin-top", "var(--lumo-space-l)");
|
||||
|
||||
Button completeButton = new Button("Auftrag manuell abschließen", new Icon(VaadinIcon.CHECK_CIRCLE));
|
||||
completeButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY, ButtonVariant.LUMO_SUCCESS);
|
||||
completeButton.addClickListener(e -> {
|
||||
ConfirmDialog dialog = new ConfirmDialog();
|
||||
dialog.setHeader("Auftrag abschließen");
|
||||
dialog.setText("Möchten Sie den Auftrag " + job.getJobNumber() + " manuell abschließen?");
|
||||
dialog.setCancelable(true);
|
||||
dialog.setCancelText("Abbrechen");
|
||||
dialog.setConfirmText("Abschließen");
|
||||
dialog.setConfirmButtonTheme("primary");
|
||||
dialog.addConfirmListener(ev -> {
|
||||
try {
|
||||
JobStatus oldStatus = job.getStatus();
|
||||
job.setStatus(JobStatus.COMPLETED);
|
||||
job.setUpdatedAt(LocalDateTime.now());
|
||||
jobRepository.save(job);
|
||||
jobHistoryService.logStatusChange(job, oldStatus, JobStatus.COMPLETED, "Manuell");
|
||||
Notification
|
||||
.show("Auftrag " + job.getJobNumber() + " wurde abgeschlossen.", 3000,
|
||||
Notification.Position.BOTTOM_END)
|
||||
.addThemeVariants(NotificationVariant.LUMO_SUCCESS);
|
||||
// Re-render the page
|
||||
getUI().ifPresent(ui -> ui.navigate("job_summary/" + job.getId().toHexString()));
|
||||
} catch (Exception ex) {
|
||||
Notification
|
||||
.show("Fehler beim Abschließen: " + ex.getMessage(), 5000,
|
||||
Notification.Position.BOTTOM_END)
|
||||
.addThemeVariants(NotificationVariant.LUMO_ERROR);
|
||||
}
|
||||
});
|
||||
dialog.open();
|
||||
});
|
||||
|
||||
buttonRow.add(completeButton);
|
||||
content.add(buttonRow);
|
||||
}
|
||||
}
|
||||
|
||||
private VerticalLayout borderedBox() {
|
||||
@@ -284,9 +334,7 @@ public class JobSummaryView extends Main implements HasUrlParameter<String> {
|
||||
|
||||
private String formatLocalDate(java.time.LocalDate date) {
|
||||
try {
|
||||
java.time.format.DateTimeFormatter fmt = java.time.format.DateTimeFormatter.ofPattern("dd.MM.yyyy")
|
||||
.withLocale(Locale.GERMANY);
|
||||
return date.format(fmt);
|
||||
return DateTimeFormatUtil.formatDate(date);
|
||||
} catch (Exception e) {
|
||||
return "";
|
||||
}
|
||||
@@ -406,8 +454,9 @@ public class JobSummaryView extends Main implements HasUrlParameter<String> {
|
||||
+ " }" + " });" + " if (!bounds.isEmpty()) { map.fitBounds(bounds); }"
|
||||
+ " }});" + " }" + " if (!(window.google && window.google.maps)) {"
|
||||
+ " var s=document.createElement('script');"
|
||||
+ " s.src='https://maps.googleapis.com/maps/api/js?key=" + getGoogleMapsApiKey() + "&libraries=places';"
|
||||
+ " s.onload=init; document.head.appendChild(s);" + " } else { init(); }" + "})();");
|
||||
+ " s.src='https://maps.googleapis.com/maps/api/js?key=" + getGoogleMapsApiKey()
|
||||
+ "&libraries=places';" + " s.onload=init; document.head.appendChild(s);" + " } else { init(); }"
|
||||
+ "})();");
|
||||
|
||||
map.getElement().executeJs(js, map.getElement(), routeInfo.getElement());
|
||||
}
|
||||
@@ -636,13 +685,9 @@ public class JobSummaryView extends Main implements HasUrlParameter<String> {
|
||||
|
||||
for (Comment comment : comments) {
|
||||
Div commentContainer = new Div();
|
||||
commentContainer.getStyle()
|
||||
.set("background-color", "#f5f5f5")
|
||||
.set("border", "1px solid #ddd")
|
||||
.set("border-radius", "4px")
|
||||
.set("padding", "8px")
|
||||
.set("margin", "4px 0")
|
||||
.set("font-family", "monospace")
|
||||
commentContainer.getStyle().set("background-color", "#f5f5f5")
|
||||
.set("border", "1px solid #ddd").set("border-radius", "4px").set("padding", "8px")
|
||||
.set("margin", "4px 0").set("font-family", "monospace")
|
||||
.set("white-space", "pre-wrap");
|
||||
|
||||
Span commentText = new Span(comment.getCommentText());
|
||||
@@ -658,13 +703,7 @@ public class JobSummaryView extends Main implements HasUrlParameter<String> {
|
||||
}
|
||||
|
||||
private String formatDateTime(java.time.LocalDateTime dateTime) {
|
||||
try {
|
||||
java.time.format.DateTimeFormatter fmt = java.time.format.DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm")
|
||||
.withLocale(Locale.GERMANY);
|
||||
return dateTime.format(fmt);
|
||||
} catch (Exception e) {
|
||||
return dateTime.toString();
|
||||
}
|
||||
return DateTimeFormatUtil.formatDateTime(dateTime);
|
||||
}
|
||||
|
||||
private Div createTaskCard(BaseTask task, String displayName) {
|
||||
|
||||
@@ -93,8 +93,7 @@ public class LoginView extends VerticalLayout implements BeforeEnterObserver, Af
|
||||
// Version display - will be set in @PostConstruct
|
||||
versionSpan = new Span("");
|
||||
versionSpan.getStyle().set("color", "var(--lumo-secondary-text-color)")
|
||||
.set("font-size", "var(--lumo-font-size-s)")
|
||||
.set("margin-top", "var(--lumo-space-m)");
|
||||
.set("font-size", "var(--lumo-font-size-s)").set("margin-top", "var(--lumo-space-m)");
|
||||
|
||||
// Inline flash message box (hidden by default)
|
||||
flashBox.getStyle().set("background", "var(--lumo-error-color-10pct)")
|
||||
@@ -139,8 +138,7 @@ public class LoginView extends VerticalLayout implements BeforeEnterObserver, Af
|
||||
.authenticate(new UsernamePasswordAuthenticationToken(username, password));
|
||||
|
||||
// Prüfe ob 2FA für diesen Nutzer aktiviert ist (global UND nutzer-spezifisch)
|
||||
boolean userTwoFactorEnabled = userRepository.findByEmail(username)
|
||||
.map(User::isTwoFactorEnabled)
|
||||
boolean userTwoFactorEnabled = userRepository.findByEmail(username).map(User::isTwoFactorEnabled)
|
||||
.orElse(true); // Standardmäßig aktiviert falls Nutzer nicht gefunden
|
||||
|
||||
if (twoFactorEnabledGlobal && userTwoFactorEnabled) {
|
||||
|
||||
@@ -37,6 +37,7 @@ import de.assecutor.votianlt.model.MessageType;
|
||||
import de.assecutor.votianlt.pages.service.AppUserService;
|
||||
import de.assecutor.votianlt.service.MessageBroadcaster;
|
||||
import de.assecutor.votianlt.service.MessageService;
|
||||
import de.assecutor.votianlt.util.DateTimeFormatUtil;
|
||||
import de.assecutor.votianlt.event.MessageReadStatusChangedEvent;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import jakarta.annotation.security.RolesAllowed;
|
||||
@@ -58,7 +59,6 @@ import javax.imageio.ImageWriter;
|
||||
import javax.imageio.stream.ImageOutputStream;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
@@ -89,15 +89,12 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
|
||||
private List<Message> currentMessages; // Current messages being displayed for redrawing
|
||||
private Registration broadcasterRegistration; // Track listener registration
|
||||
private TextArea messageInput;
|
||||
private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("HH:mm");
|
||||
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("dd.MM.yyyy");
|
||||
private static final int MAX_IMAGE_FILE_SIZE = 32 * 1024 * 1024; // 32 MB aligns with Spring settings
|
||||
private static final int TARGET_IMAGE_WIDTH = 1920;
|
||||
private static final float JPEG_COMPRESSION_QUALITY = 0.8f;
|
||||
|
||||
public MessageDetailsView(AppUserService appUserService, MessageService messageService,
|
||||
MessageBroadcaster messageBroadcaster,
|
||||
ApplicationEventPublisher eventPublisher) {
|
||||
MessageBroadcaster messageBroadcaster, ApplicationEventPublisher eventPublisher) {
|
||||
this.appUserService = appUserService;
|
||||
this.messageService = messageService;
|
||||
this.messageBroadcaster = messageBroadcaster;
|
||||
@@ -128,7 +125,8 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
|
||||
log.info("MessageDetailsView - participant: {}, conversationId: {}", participantKey, conversationId);
|
||||
|
||||
if (participantKey == null || conversationId == null) {
|
||||
log.warn("Missing required route parameters: participantKey={}, conversationId={}", participantKey, conversationId);
|
||||
log.warn("Missing required route parameters: participantKey={}, conversationId={}", participantKey,
|
||||
conversationId);
|
||||
event.rerouteToError(IllegalArgumentException.class, "Missing required parameters");
|
||||
return;
|
||||
}
|
||||
@@ -175,10 +173,8 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
|
||||
messagesContainer.setPadding(false);
|
||||
messagesContainer.setSpacing(false);
|
||||
messagesContainer.setWidthFull();
|
||||
messagesContainer.getStyle()
|
||||
.set("background-color", "#f0f0f0")
|
||||
.set("border-radius", "8px")
|
||||
.set("padding", "15px");
|
||||
messagesContainer.getStyle().set("background-color", "#f0f0f0").set("border-radius", "8px").set("padding",
|
||||
"15px");
|
||||
|
||||
// Wrap messages container in scroller for vertical scrolling
|
||||
messagesScroller = new Scroller(messagesContainer);
|
||||
@@ -229,14 +225,15 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
|
||||
messagesContainer.add(createMessageBubble(message, timestamp));
|
||||
}
|
||||
|
||||
// After rendering, mark any unread messages directed to the current user as read
|
||||
// After rendering, mark any unread messages directed to the current user as
|
||||
// read
|
||||
markVisibleMessagesAsRead();
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks all currently visible messages that are addressed to the logged-in user as read.
|
||||
* This is triggered after (re)rendering the conversation and will also update the in-memory
|
||||
* message objects to keep UI state consistent.
|
||||
* Marks all currently visible messages that are addressed to the logged-in user
|
||||
* as read. This is triggered after (re)rendering the conversation and will also
|
||||
* update the in-memory message objects to keep UI state consistent.
|
||||
*/
|
||||
private void markVisibleMessagesAsRead() {
|
||||
try {
|
||||
@@ -278,27 +275,19 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
|
||||
upload.setWidthFull();
|
||||
|
||||
Span helper = new Span("Unterstützte Formate: PNG, JPG, GIF, WebP (max. 32 MB)");
|
||||
helper.getStyle()
|
||||
.set("font-size", "12px")
|
||||
.set("color", "#666666");
|
||||
helper.getStyle().set("font-size", "12px").set("color", "#666666");
|
||||
|
||||
Image preview = new Image();
|
||||
preview.setAlt("Vorschau des ausgewählten Bildes");
|
||||
preview.setVisible(false);
|
||||
preview.setWidth(null);
|
||||
preview.setHeight(null);
|
||||
preview.getStyle()
|
||||
.set("max-width", "100%")
|
||||
.set("max-height", "320px")
|
||||
.set("height", "auto")
|
||||
.set("border-radius", "12px")
|
||||
.set("display", "inline-block");
|
||||
preview.getStyle().set("max-width", "100%").set("max-height", "320px").set("height", "auto")
|
||||
.set("border-radius", "12px").set("display", "inline-block");
|
||||
|
||||
Div previewWrapper = new Div(preview);
|
||||
previewWrapper.setWidthFull();
|
||||
previewWrapper.getStyle()
|
||||
.set("text-align", "center")
|
||||
.set("margin-top", "10px");
|
||||
previewWrapper.getStyle().set("text-align", "center").set("margin-top", "10px");
|
||||
|
||||
AtomicReference<String> base64Ref = new AtomicReference<>();
|
||||
|
||||
@@ -325,8 +314,8 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
|
||||
base64Ref.set(null);
|
||||
preview.setVisible(false);
|
||||
confirmButton.setEnabled(false);
|
||||
Notification.show("Das Bild konnte nicht verarbeitet werden.", 3000,
|
||||
Notification.Position.MIDDLE).addThemeVariants(NotificationVariant.LUMO_ERROR);
|
||||
Notification.show("Das Bild konnte nicht verarbeitet werden.", 3000, Notification.Position.MIDDLE)
|
||||
.addThemeVariants(NotificationVariant.LUMO_ERROR);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -400,22 +389,14 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
|
||||
*/
|
||||
private Div createDateSeparator(LocalDate date) {
|
||||
Div separator = new Div();
|
||||
separator.getStyle()
|
||||
.set("display", "flex")
|
||||
.set("justify-content", "center")
|
||||
.set("text-align", "center")
|
||||
.set("margin", "20px 0");
|
||||
separator.getStyle().set("display", "flex").set("justify-content", "center").set("text-align", "center")
|
||||
.set("margin", "20px 0");
|
||||
separator.setWidthFull();
|
||||
|
||||
Span dateLabel = new Span(date.format(DATE_FORMATTER));
|
||||
dateLabel.getStyle()
|
||||
.set("background-color", "#d0d0d0")
|
||||
.set("padding", "4px 10px")
|
||||
.set("border-radius", "12px")
|
||||
.set("font-size", "12px")
|
||||
.set("font-weight", "500")
|
||||
.set("color", "#333333")
|
||||
.set("display", "inline-block");
|
||||
Span dateLabel = new Span(DateTimeFormatUtil.formatDate(date));
|
||||
dateLabel.getStyle().set("background-color", "#d0d0d0").set("padding", "4px 10px").set("border-radius", "12px")
|
||||
.set("font-size", "12px").set("font-weight", "500").set("color", "#333333")
|
||||
.set("display", "inline-block");
|
||||
|
||||
separator.add(dateLabel);
|
||||
return separator;
|
||||
@@ -426,41 +407,31 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
|
||||
*/
|
||||
private Div createMessageBubble(Message message, LocalDateTime timestamp) {
|
||||
// Determine alignment based on message origin
|
||||
// CLIENT origin = client messages (left), SERVER origin = server messages (right)
|
||||
// CLIENT origin = client messages (left), SERVER origin = server messages
|
||||
// (right)
|
||||
boolean isServerMessage = message.getOrigin() == MessageOrigin.SERVER;
|
||||
|
||||
// Container for the message (aligns left or right)
|
||||
Div messageWrapper = new Div();
|
||||
String alignment = isServerMessage ? "right" : "left";
|
||||
|
||||
messageWrapper.getStyle()
|
||||
.set("display", "flex")
|
||||
.set("justify-content", isServerMessage ? "flex-end" : "flex-start")
|
||||
.set("margin", "5px 0")
|
||||
.set("width", "100%");
|
||||
messageWrapper.getStyle().set("display", "flex")
|
||||
.set("justify-content", isServerMessage ? "flex-end" : "flex-start").set("margin", "5px 0")
|
||||
.set("width", "100%");
|
||||
|
||||
// Message bubble
|
||||
Div bubble = new Div();
|
||||
bubble.getStyle()
|
||||
.set("background-color", isServerMessage ? "#dcf8c6" : "#ffffff")
|
||||
.set("padding", "10px 15px")
|
||||
.set("border-radius", "18px")
|
||||
.set("max-width", "70%")
|
||||
.set("box-shadow", "0 1px 2px rgba(0,0,0,0.1)")
|
||||
.set("word-wrap", "break-word")
|
||||
.set("white-space", "pre-wrap")
|
||||
.set("text-align", alignment);
|
||||
bubble.getStyle().set("background-color", isServerMessage ? "#dcf8c6" : "#ffffff").set("padding", "10px 15px")
|
||||
.set("border-radius", "18px").set("max-width", "70%").set("box-shadow", "0 1px 2px rgba(0,0,0,0.1)")
|
||||
.set("word-wrap", "break-word").set("white-space", "pre-wrap").set("text-align", alignment);
|
||||
|
||||
// Message content component (text or media)
|
||||
Component contentComponent = createContentComponent(message, alignment);
|
||||
|
||||
// Timestamp
|
||||
Span timeSpan = new Span(timestamp.format(TIME_FORMATTER));
|
||||
timeSpan.getStyle()
|
||||
.set("font-size", "11px")
|
||||
.set("color", isServerMessage ? "#666666" : "#999999")
|
||||
.set("display", "block")
|
||||
.set("text-align", alignment);
|
||||
Span timeSpan = new Span(DateTimeFormatUtil.formatTime(timestamp));
|
||||
timeSpan.getStyle().set("font-size", "11px").set("color", isServerMessage ? "#666666" : "#999999")
|
||||
.set("display", "block").set("text-align", alignment);
|
||||
|
||||
bubble.add(contentComponent, timeSpan);
|
||||
messageWrapper.add(bubble);
|
||||
@@ -471,30 +442,24 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
|
||||
private Component createContentComponent(Message message, String alignment) {
|
||||
MessageContentType contentType = message.getContentType();
|
||||
return switch (contentType) {
|
||||
case IMAGE -> createImageContent(message.getContent(), alignment);
|
||||
case TEXT -> createTextContent(message.getContent(), alignment);
|
||||
case IMAGE -> createImageContent(message.getContent(), alignment);
|
||||
case TEXT -> createTextContent(message.getContent(), alignment);
|
||||
};
|
||||
}
|
||||
|
||||
private Component createTextContent(String contentValue, String alignment) {
|
||||
Div contentDiv = new Div();
|
||||
String content = Optional.ofNullable(contentValue).filter(value -> !value.isBlank())
|
||||
.orElse("(kein Inhalt)");
|
||||
String content = Optional.ofNullable(contentValue).filter(value -> !value.isBlank()).orElse("(kein Inhalt)");
|
||||
contentDiv.setText(content);
|
||||
contentDiv.getStyle()
|
||||
.set("font-size", "14px")
|
||||
.set("color", "#000000")
|
||||
.set("margin-bottom", "5px")
|
||||
.set("text-align", alignment);
|
||||
contentDiv.getStyle().set("font-size", "14px").set("color", "#000000").set("margin-bottom", "5px")
|
||||
.set("text-align", alignment);
|
||||
return contentDiv;
|
||||
}
|
||||
|
||||
private Component createImageContent(String base64Value, String alignment) {
|
||||
Div wrapper = new Div();
|
||||
wrapper.getStyle()
|
||||
.set("margin-bottom", "5px")
|
||||
.set("display", "flex")
|
||||
.set("justify-content", "right".equals(alignment) ? "flex-end" : "flex-start");
|
||||
wrapper.getStyle().set("margin-bottom", "5px").set("display", "flex").set("justify-content",
|
||||
"right".equals(alignment) ? "flex-end" : "flex-start");
|
||||
|
||||
String trimmed = Optional.ofNullable(base64Value).map(String::trim).orElse("");
|
||||
if (trimmed.isEmpty()) {
|
||||
@@ -509,12 +474,8 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
|
||||
}
|
||||
|
||||
Image image = new Image(dataUrl, "Nachrichtenbild");
|
||||
image.getStyle()
|
||||
.set("max-width", "100%")
|
||||
.set("border-radius", "12px")
|
||||
.set("display", "block")
|
||||
.set("max-height", "320px")
|
||||
.set("height", "auto");
|
||||
image.getStyle().set("max-width", "100%").set("border-radius", "12px").set("display", "block")
|
||||
.set("max-height", "320px").set("height", "auto");
|
||||
image.getElement().setAttribute("loading", "lazy");
|
||||
|
||||
wrapper.add(image);
|
||||
@@ -676,18 +637,12 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
|
||||
i18n.setError(error);
|
||||
|
||||
UploadI18N.Uploading uploading = new UploadI18N.Uploading();
|
||||
uploading.setStatus(new UploadI18N.Uploading.Status()
|
||||
.setConnecting("Verbindung wird aufgebaut...")
|
||||
.setStalled("Upload pausiert")
|
||||
.setProcessing("Verarbeitung...")
|
||||
.setHeld("Warten auf Upload..."));
|
||||
uploading.setRemainingTime(new UploadI18N.Uploading.RemainingTime()
|
||||
.setPrefix("Verbleibende Zeit: ")
|
||||
uploading.setStatus(new UploadI18N.Uploading.Status().setConnecting("Verbindung wird aufgebaut...")
|
||||
.setStalled("Upload pausiert").setProcessing("Verarbeitung...").setHeld("Warten auf Upload..."));
|
||||
uploading.setRemainingTime(new UploadI18N.Uploading.RemainingTime().setPrefix("Verbleibende Zeit: ")
|
||||
.setUnknown("Verbleibende Zeit unbekannt"));
|
||||
uploading.setError(new UploadI18N.Uploading.Error()
|
||||
.setServerUnavailable("Server nicht erreichbar")
|
||||
.setUnexpectedServerError("Unerwarteter Serverfehler")
|
||||
.setForbidden("Upload nicht erlaubt"));
|
||||
uploading.setError(new UploadI18N.Uploading.Error().setServerUnavailable("Server nicht erreichbar")
|
||||
.setUnexpectedServerError("Unerwarteter Serverfehler").setForbidden("Upload nicht erlaubt"));
|
||||
i18n.setUploading(uploading);
|
||||
|
||||
return i18n;
|
||||
@@ -704,9 +659,7 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
|
||||
if (scrollAnchor == null) {
|
||||
scrollAnchor = new Div();
|
||||
scrollAnchor.setId("scroll-anchor");
|
||||
scrollAnchor.getStyle()
|
||||
.set("height", "1px")
|
||||
.set("width", "100%");
|
||||
scrollAnchor.getStyle().set("height", "1px").set("width", "100%");
|
||||
}
|
||||
|
||||
if (scrollAnchor.getParent().isEmpty()) {
|
||||
@@ -748,7 +701,6 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
|
||||
return layout;
|
||||
}
|
||||
|
||||
|
||||
private HorizontalLayout createMessageInputArea() {
|
||||
messageInput = new TextArea();
|
||||
messageInput.setPlaceholder("Nachricht eingeben...");
|
||||
@@ -803,12 +755,11 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
|
||||
Message saved;
|
||||
if (jobConversation) {
|
||||
// participantKey = AppUser ID (receiver)
|
||||
saved = messageService.sendJobMessageToClient(payload, participantKey,
|
||||
contentType, jobIdContext, jobNumberContext);
|
||||
saved = messageService.sendJobMessageToClient(payload, participantKey, contentType, jobIdContext,
|
||||
jobNumberContext);
|
||||
} else {
|
||||
// participantKey = AppUser ID (receiver)
|
||||
saved = messageService.sendGeneralMessageToClient(payload, participantKey,
|
||||
contentType);
|
||||
saved = messageService.sendGeneralMessageToClient(payload, participantKey, contentType);
|
||||
}
|
||||
|
||||
// Mark own outgoing message as read immediately
|
||||
@@ -831,8 +782,7 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
|
||||
} catch (Exception ex) {
|
||||
log.error("Failed to send message to {}: {}", participantKey, ex.getMessage(), ex);
|
||||
Notification.show("Nachricht konnte nicht gesendet werden: " + ex.getMessage(), 4000,
|
||||
Notification.Position.MIDDLE)
|
||||
.addThemeVariants(NotificationVariant.LUMO_ERROR);
|
||||
Notification.Position.MIDDLE).addThemeVariants(NotificationVariant.LUMO_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -846,8 +796,7 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
|
||||
} catch (IllegalArgumentException ex) {
|
||||
return appUserService.findAll().stream()
|
||||
.filter(user -> participantKey.equals(user.getEmail()) || participantKey.equals(user.getAppCode()))
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
.findFirst().orElse(null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -856,14 +805,15 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
|
||||
return List.of();
|
||||
}
|
||||
if ("general".equalsIgnoreCase(conversationId)) {
|
||||
return messages.stream()
|
||||
.filter(msg -> Optional.ofNullable(msg.getMessageType()).orElse(MessageType.GENERAL) == MessageType.GENERAL)
|
||||
return messages.stream().filter(
|
||||
msg -> Optional.ofNullable(msg.getMessageType()).orElse(MessageType.GENERAL) == MessageType.GENERAL)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
if (conversationId.startsWith("job-")) {
|
||||
String token = conversationId.substring(4);
|
||||
return messages.stream()
|
||||
.filter(msg -> Optional.ofNullable(msg.getMessageType()).orElse(MessageType.GENERAL) == MessageType.JOB_RELATED
|
||||
.filter(msg -> Optional.ofNullable(msg.getMessageType())
|
||||
.orElse(MessageType.GENERAL) == MessageType.JOB_RELATED
|
||||
&& matchesJobConversation(msg, token))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
@@ -879,8 +829,7 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
|
||||
String jobId = Optional.ofNullable(message.getJobIdAsString()).orElse("");
|
||||
|
||||
return sanitize(jobNumber).equalsIgnoreCase(normalizedToken)
|
||||
|| sanitize(jobId).equalsIgnoreCase(normalizedToken)
|
||||
|| jobNumber.equalsIgnoreCase(token)
|
||||
|| sanitize(jobId).equalsIgnoreCase(normalizedToken) || jobNumber.equalsIgnoreCase(token)
|
||||
|| jobId.equalsIgnoreCase(token);
|
||||
}
|
||||
|
||||
@@ -912,11 +861,8 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
|
||||
}
|
||||
|
||||
if (messages != null && (jobNumberContext == null || jobNumberContext.isBlank())) {
|
||||
jobNumberContext = messages.stream()
|
||||
.map(Message::getJobNumber)
|
||||
.filter(value -> value != null && !value.isBlank())
|
||||
.findFirst()
|
||||
.orElse(jobNumberContext);
|
||||
jobNumberContext = messages.stream().map(Message::getJobNumber)
|
||||
.filter(value -> value != null && !value.isBlank()).findFirst().orElse(jobNumberContext);
|
||||
}
|
||||
|
||||
if (jobIdContext == null || jobNumberContext == null || jobNumberContext.isBlank()) {
|
||||
@@ -927,8 +873,7 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
|
||||
if (jobOptional.isPresent()) {
|
||||
Job job = jobOptional.get();
|
||||
jobIdContext = Optional.ofNullable(job.getId()).orElse(jobIdContext);
|
||||
jobNumberContext = Optional.ofNullable(job.getJobNumber())
|
||||
.filter(value -> !value.isBlank())
|
||||
jobNumberContext = Optional.ofNullable(job.getJobNumber()).filter(value -> !value.isBlank())
|
||||
.orElse(jobNumberContext);
|
||||
}
|
||||
}
|
||||
@@ -955,11 +900,13 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
|
||||
if (conversationId.startsWith("job-")) {
|
||||
if (messages != null && !messages.isEmpty()) {
|
||||
for (Message message : messages) {
|
||||
String jobNumber = Optional.ofNullable(message.getJobNumber()).filter(s -> !s.isBlank()).orElse(null);
|
||||
String jobNumber = Optional.ofNullable(message.getJobNumber()).filter(s -> !s.isBlank())
|
||||
.orElse(null);
|
||||
if (jobNumber != null) {
|
||||
return "Auftrag " + jobNumber;
|
||||
}
|
||||
String jobId = Optional.ofNullable(message.getJobIdAsString()).filter(s -> !s.isBlank()).orElse(null);
|
||||
String jobId = Optional.ofNullable(message.getJobIdAsString()).filter(s -> !s.isBlank())
|
||||
.orElse(null);
|
||||
if (jobId != null) {
|
||||
return "Auftrag " + jobId;
|
||||
}
|
||||
@@ -975,8 +922,8 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the view is attached to the UI
|
||||
* Registers listener for incoming messages
|
||||
* Called when the view is attached to the UI Registers listener for incoming
|
||||
* messages
|
||||
*/
|
||||
@Override
|
||||
protected void onAttach(AttachEvent attachEvent) {
|
||||
@@ -992,8 +939,8 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the view is detached from the UI
|
||||
* Unregisters listener to prevent memory leaks
|
||||
* Called when the view is detached from the UI Unregisters listener to prevent
|
||||
* memory leaks
|
||||
*/
|
||||
@Override
|
||||
protected void onDetach(DetachEvent detachEvent) {
|
||||
@@ -1006,8 +953,8 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming message broadcast
|
||||
* Filters messages to only show those belonging to the current conversation
|
||||
* Handle incoming message broadcast Filters messages to only show those
|
||||
* belonging to the current conversation
|
||||
*/
|
||||
private void handleIncomingMessage(UI ui, Message message) {
|
||||
if (message == null || participantKey == null || conversationId == null) {
|
||||
@@ -1057,6 +1004,12 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
|
||||
ensureScrollAnchor();
|
||||
scrollToBottom();
|
||||
|
||||
// Play notification sound and show browser notification for incoming client messages
|
||||
if (message.getOrigin() == MessageOrigin.CLIENT) {
|
||||
playNotificationSound(ui);
|
||||
showBrowserNotification(ui, message);
|
||||
}
|
||||
|
||||
log.info("Messages re-rendered with new message");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
@@ -1064,4 +1017,62 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Play a notification sound when a new message arrives
|
||||
*/
|
||||
private void playNotificationSound(UI ui) {
|
||||
ui.getPage().executeJs(
|
||||
"const audio = new Audio('data:audio/wav;base64,UklGRnoGAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQoGAACBhYqFbF1fdJivrJBhNjVgodDbq2EcBj+a2/LDciUFLIHO8tiJNwgZaLvt559NEAxQp+PwtmMcBjiR1/LMeSwFJHfH8N2QQAoUXrTp66hVFApGn+DyvmwhBTGH0fPTgjMGHm7A7+OZSA0PVK3m8LRjHAU7k9nzyn0tBSd5ye/glEIKEl206O2oVhQLSKHi8r5uIgU0idT01IMzByBwwvHjmEgNDlWs5PCzYhsFO5TY88p+Kwcme8jw4JVCChNdt+jvp1QVDEih4vK+bSIGNIrV9dODMggib8Lx5JdIDQ9VrObws2IbBT6U2PXKfi0IJnzH8OCVQgoVXbfp76dVFQ5IouLyvW0jCDSL1fTSgTQJJG7C8eSWSA8RVq3m8LJgGwg/lNj0yn4tCSV7x+/glUILFl237++nVhYOSKPi8rxtIwo0i9X00oE1CiNuwvDklkkREVat5u+yXxwJP5PY9Ml+Lgoge8fv4JVCDBVct+7vqFYYEUij4vG8bSQKNIvV89GBNQshbcLw5JZJERFV\u003d\u003d'); audio.play().catch(err => console.log('Audio play failed:', err));");
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a browser notification when a new message arrives
|
||||
*/
|
||||
private void showBrowserNotification(UI ui, Message message) {
|
||||
String senderName = resolveAppUserName();
|
||||
String preview = resolveNotificationPreview(message);
|
||||
String title = senderName != null ? senderName : "Neue Nachricht";
|
||||
|
||||
ui.getPage().executeJs(
|
||||
"if (!('Notification' in window)) {" + " console.log('Browser does not support notifications');"
|
||||
+ "} else if (Notification.permission === 'granted') {" + " new Notification($0, { body: $1 });"
|
||||
+ "} else if (Notification.permission !== 'denied') {" + " Notification.requestPermission().then(permission => {"
|
||||
+ " if (permission === 'granted') {" + " new Notification($0, { body: $1 });" + " }" + " });" + "}",
|
||||
title, preview);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the AppUser name for the current conversation
|
||||
*/
|
||||
private String resolveAppUserName() {
|
||||
if (participantKey == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
ObjectId appUserId = new ObjectId(participantKey);
|
||||
AppUser appUser = appUserService.findById(appUserId);
|
||||
return appUser != null ? appUser.getBezeichnung() : null;
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a short preview text for the notification
|
||||
*/
|
||||
private String resolveNotificationPreview(Message message) {
|
||||
if (message == null) {
|
||||
return "";
|
||||
}
|
||||
if (message.getContentType() == MessageContentType.IMAGE) {
|
||||
return "📷 Bild";
|
||||
}
|
||||
String content = message.getContent();
|
||||
if (content == null || content.isBlank()) {
|
||||
return "(kein Inhalt)";
|
||||
}
|
||||
// Limit preview to 100 characters
|
||||
return content.length() > 100 ? content.substring(0, 97) + "..." : content;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,11 +21,13 @@ import de.assecutor.votianlt.model.Message;
|
||||
import de.assecutor.votianlt.model.MessageContentType;
|
||||
import de.assecutor.votianlt.model.MessageOrigin;
|
||||
import de.assecutor.votianlt.pages.service.AppUserService;
|
||||
import de.assecutor.votianlt.service.MessageBroadcaster;
|
||||
import de.assecutor.votianlt.service.MessageService;
|
||||
import de.assecutor.votianlt.util.DateTimeFormatUtil;
|
||||
import jakarta.annotation.security.RolesAllowed;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.bson.types.ObjectId;
|
||||
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
@@ -47,15 +49,19 @@ public class MessagesView extends Main {
|
||||
|
||||
private final MessageService messageService;
|
||||
private final AppUserService appUserService;
|
||||
private final MessageBroadcaster messageBroadcaster;
|
||||
|
||||
private Grid<ClientMessageSummary> clientGrid;
|
||||
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm");
|
||||
private final AtomicBoolean loading = new AtomicBoolean(false);
|
||||
private Registration pollRegistration;
|
||||
private Registration broadcasterRegistration;
|
||||
private int lastMessageCount = 0;
|
||||
|
||||
public MessagesView(MessageService messageService, AppUserService appUserService) {
|
||||
public MessagesView(MessageService messageService, AppUserService appUserService,
|
||||
MessageBroadcaster messageBroadcaster) {
|
||||
this.messageService = messageService;
|
||||
this.appUserService = appUserService;
|
||||
this.messageBroadcaster = messageBroadcaster;
|
||||
|
||||
// Create main layout
|
||||
VerticalLayout layout = new VerticalLayout();
|
||||
@@ -120,9 +126,9 @@ public class MessagesView extends Main {
|
||||
return new Span("0");
|
||||
})).setHeader("Ungelesen").setWidth("100px").setFlexGrow(0);
|
||||
|
||||
grid.addColumn(summary ->
|
||||
summary.getLastMessageDate() != null ? summary.getLastMessageDate().format(DATE_FORMATTER) : "-"
|
||||
).setHeader("Letzte Nachricht").setAutoWidth(true);
|
||||
grid.addColumn(summary -> summary.getLastMessageDate() != null
|
||||
? DateTimeFormatUtil.formatDateTime(summary.getLastMessageDate())
|
||||
: "-").setHeader("Letzte Nachricht").setAutoWidth(true);
|
||||
|
||||
grid.addColumn(new ComponentRenderer<>(summary -> {
|
||||
String preview = summary.getLastMessagePreview();
|
||||
@@ -157,7 +163,7 @@ public class MessagesView extends Main {
|
||||
} catch (Exception e) {
|
||||
log.error("Error loading client summaries: {}", e.getMessage(), e);
|
||||
Notification.show("Fehler beim Laden der Nachrichten", 3000, Notification.Position.MIDDLE)
|
||||
.addThemeVariants(NotificationVariant.LUMO_ERROR);
|
||||
.addThemeVariants(NotificationVariant.LUMO_ERROR);
|
||||
} finally {
|
||||
loading.set(false);
|
||||
}
|
||||
@@ -199,20 +205,17 @@ public class MessagesView extends Main {
|
||||
continue;
|
||||
}
|
||||
|
||||
conversation.sort(Comparator.comparing(Message::getCreatedAt,
|
||||
Comparator.nullsLast(LocalDateTime::compareTo)).reversed());
|
||||
conversation.sort(Comparator
|
||||
.comparing(Message::getCreatedAt, Comparator.nullsLast(LocalDateTime::compareTo)).reversed());
|
||||
|
||||
Message latest = conversation.stream()
|
||||
.filter(msg -> msg.getCreatedAt() != null)
|
||||
.findFirst()
|
||||
Message latest = conversation.stream().filter(msg -> msg.getCreatedAt() != null).findFirst()
|
||||
.orElse(conversation.get(0));
|
||||
|
||||
LocalDateTime lastDate = latest.getCreatedAt();
|
||||
String preview = resolvePreview(latest);
|
||||
int totalMessages = conversation.size();
|
||||
int unreadCount = (int) conversation.stream()
|
||||
.filter(msg -> msg.getOrigin() == MessageOrigin.CLIENT && !msg.isRead())
|
||||
.count();
|
||||
.filter(msg -> msg.getOrigin() == MessageOrigin.CLIENT && !msg.isRead()).count();
|
||||
|
||||
summary.setTotalMessages(summary.getTotalMessages() + totalMessages);
|
||||
summary.setUnreadCount(summary.getUnreadCount() + unreadCount);
|
||||
@@ -235,8 +238,9 @@ public class MessagesView extends Main {
|
||||
}
|
||||
|
||||
List<ClientMessageSummary> summaries = new ArrayList<>(summaryMap.values());
|
||||
summaries.sort(Comparator.comparing(ClientMessageSummary::getLastMessageDate,
|
||||
Comparator.nullsLast(LocalDateTime::compareTo)).reversed());
|
||||
summaries.sort(Comparator
|
||||
.comparing(ClientMessageSummary::getLastMessageDate, Comparator.nullsLast(LocalDateTime::compareTo))
|
||||
.reversed());
|
||||
return summaries;
|
||||
}
|
||||
|
||||
@@ -249,9 +253,7 @@ public class MessagesView extends Main {
|
||||
return "[Bildnachricht]";
|
||||
}
|
||||
|
||||
return Optional.ofNullable(message.getContent())
|
||||
.filter(content -> !content.isBlank())
|
||||
.orElse("(kein Inhalt)");
|
||||
return Optional.ofNullable(message.getContent()).filter(content -> !content.isBlank()).orElse("(kein Inhalt)");
|
||||
}
|
||||
|
||||
private String resolveParticipantKey(Message message) {
|
||||
@@ -326,6 +328,12 @@ public class MessagesView extends Main {
|
||||
UI ui = attachEvent.getUI();
|
||||
ui.setPollInterval(POLL_INTERVAL_MS);
|
||||
pollRegistration = ui.addPollListener(event -> loadClientSummaries());
|
||||
|
||||
// Register broadcaster for real-time notifications
|
||||
broadcasterRegistration = messageBroadcaster.register(message -> {
|
||||
handleIncomingMessage(ui, message);
|
||||
});
|
||||
|
||||
loadClientSummaries();
|
||||
}
|
||||
|
||||
@@ -336,6 +344,96 @@ public class MessagesView extends Main {
|
||||
pollRegistration.remove();
|
||||
pollRegistration = null;
|
||||
}
|
||||
if (broadcasterRegistration != null) {
|
||||
broadcasterRegistration.remove();
|
||||
broadcasterRegistration = null;
|
||||
}
|
||||
detachEvent.getUI().setPollInterval(-1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming message for notification purposes
|
||||
*/
|
||||
private void handleIncomingMessage(UI ui, Message message) {
|
||||
if (message == null || message.getOrigin() != MessageOrigin.CLIENT) {
|
||||
return;
|
||||
}
|
||||
|
||||
ui.access(() -> {
|
||||
try {
|
||||
// Count total messages to detect new ones
|
||||
int currentMessageCount = messageService.countAllMessages();
|
||||
if (currentMessageCount > lastMessageCount) {
|
||||
lastMessageCount = currentMessageCount;
|
||||
|
||||
// Play notification sound and show browser notification
|
||||
playNotificationSound(ui);
|
||||
showBrowserNotification(ui, message);
|
||||
|
||||
// Refresh the grid
|
||||
loadClientSummaries();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("Error handling incoming message notification", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Play a notification sound when a new message arrives
|
||||
*/
|
||||
private void playNotificationSound(UI ui) {
|
||||
ui.getPage().executeJs(
|
||||
"const audio = new Audio('data:audio/wav;base64,UklGRnoGAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQoGAACBhYqFbF1fdJivrJBhNjVgodDbq2EcBj+a2/LDciUFLIHO8tiJNwgZaLvt559NEAxQp+PwtmMcBjiR1/LMeSwFJHfH8N2QQAoUXrTp66hVFApGn+DyvmwhBTGH0fPTgjMGHm7A7+OZSA0PVK3m8LRjHAU7k9nzyn0tBSd5ye/glEIKEl206O2oVhQLSKHi8r5uIgU0idT01IMzByBwwvHjmEgNDlWs5PCzYhsFO5TY88p+Kwcme8jw4JVCChNdt+jvp1QVDEih4vK+bSIGNIrV9dODMggib8Lx5JdIDQ9VrObws2IbBT6U2PXKfi0IJnzH8OCVQgoVXbfp76dVFQ5IouLyvW0jCDSL1fTSgTQJJG7C8eSWSA8RVq3m8LJgGwg/lNj0yn4tCSV7x+/glUILFl237++nVhYOSKPi8rxtIwo0i9X00oE1CiNuwvDklkkREVat5u+yXxwJP5PY9Ml+Lgoge8fv4JVCDBVct+7vqFYYEUij4vG8bSQKNIvV89GBNQshbcLw5JZJERFV\u003d\u003d'); audio.play().catch(err => console.log('Audio play failed:', err));");
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a browser notification when a new message arrives
|
||||
*/
|
||||
private void showBrowserNotification(UI ui, Message message) {
|
||||
String senderName = resolveAppUserName(message.getReceiver());
|
||||
String preview = resolveNotificationPreview(message);
|
||||
String title = senderName != null ? ("Nachricht von " + senderName) : "Neue Nachricht";
|
||||
|
||||
ui.getPage().executeJs(
|
||||
"if (!('Notification' in window)) {" + " console.log('Browser does not support notifications');"
|
||||
+ "} else if (Notification.permission === 'granted') {" + " new Notification($0, { body: $1 });"
|
||||
+ "} else if (Notification.permission !== 'denied') {" + " Notification.requestPermission().then(permission => {"
|
||||
+ " if (permission === 'granted') {" + " new Notification($0, { body: $1 });" + " }" + " });" + "}",
|
||||
title, preview);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the AppUser name by ID
|
||||
*/
|
||||
private String resolveAppUserName(String appUserId) {
|
||||
if (appUserId == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
ObjectId id = new ObjectId(appUserId);
|
||||
AppUser appUser = appUserService.findById(id);
|
||||
return appUser != null ? appUser.getBezeichnung() : null;
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a short preview text for the notification
|
||||
*/
|
||||
private String resolveNotificationPreview(Message message) {
|
||||
if (message == null) {
|
||||
return "";
|
||||
}
|
||||
if (message.getContentType() == MessageContentType.IMAGE) {
|
||||
return "📷 Bild";
|
||||
}
|
||||
String content = message.getContent();
|
||||
if (content == null || content.isBlank()) {
|
||||
return "(kein Inhalt)";
|
||||
}
|
||||
// Limit preview to 100 characters
|
||||
return content.length() > 100 ? content.substring(0, 97) + "..." : content;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,13 +23,13 @@ import com.vaadin.flow.component.UI;
|
||||
import com.vaadin.flow.server.StreamResource;
|
||||
import com.vaadin.flow.server.StreamRegistration;
|
||||
import de.assecutor.votianlt.service.SystemInvoiceService;
|
||||
import de.assecutor.votianlt.util.DateTimeFormatUtil;
|
||||
import de.assecutor.votianlt.model.invoices.SystemInvoiceData;
|
||||
import de.assecutor.votianlt.model.invoices.SystemInvoiceItem;
|
||||
import java.io.ByteArrayInputStream;
|
||||
|
||||
import java.text.NumberFormat;
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
@@ -50,7 +50,6 @@ public class MyInvoicesView extends Main {
|
||||
private final Div emptyState = new Div();
|
||||
private final SystemInvoiceService systemInvoiceService;
|
||||
|
||||
private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("dd.MM.yyyy");
|
||||
private static final NumberFormat CURRENCY_FMT = NumberFormat.getCurrencyInstance(Locale.GERMANY);
|
||||
|
||||
public MyInvoicesView(SystemInvoiceService systemInvoiceService) {
|
||||
@@ -76,9 +75,7 @@ public class MyInvoicesView extends Main {
|
||||
private Component createTopCards() {
|
||||
// Container mit responsiven Spalten
|
||||
Div container = new Div();
|
||||
container.getStyle()
|
||||
.set("display", "grid")
|
||||
.set("grid-template-columns", "repeat(auto-fit, minmax(280px, 1fr))")
|
||||
container.getStyle().set("display", "grid").set("grid-template-columns", "repeat(auto-fit, minmax(280px, 1fr))")
|
||||
.set("gap", "var(--lumo-space-m)");
|
||||
|
||||
// Karte: Offene Rechnungen
|
||||
@@ -143,7 +140,8 @@ public class MyInvoicesView extends Main {
|
||||
grid.addColumn(new ComponentRenderer<>(row -> statusBadge(row.status()))).setHeader("Status").setAutoWidth(true)
|
||||
.setFlexGrow(0);
|
||||
grid.addColumn(MyInvoicesView::formatInvoiceNumber).setHeader("Rechnungsnummer").setAutoWidth(true);
|
||||
grid.addColumn(row -> DATE_FMT.format(row.date())).setHeader("Datum").setAutoWidth(true).setFlexGrow(0);
|
||||
grid.addColumn(row -> DateTimeFormatUtil.formatDate(row.date())).setHeader("Datum").setAutoWidth(true)
|
||||
.setFlexGrow(0);
|
||||
grid.addColumn(row -> CURRENCY_FMT.format(row.amount())).setHeader("Betrag").setAutoWidth(true)
|
||||
.setTextAlign(ColumnTextAlign.END).setFlexGrow(0);
|
||||
grid.setAllRowsVisible(true);
|
||||
@@ -276,7 +274,7 @@ public class MyInvoicesView extends Main {
|
||||
private byte[] generateSystemInvoicePdf(MyInvoiceRow row) throws Exception {
|
||||
SystemInvoiceData data = new SystemInvoiceData();
|
||||
data.setInvoiceNumber(row.invoiceNumber());
|
||||
data.setInvoiceDate(DATE_FMT.format(row.date()));
|
||||
data.setInvoiceDate(DateTimeFormatUtil.formatDate(row.date()));
|
||||
data.setInvoiceText("Rechnung " + row.invoiceNumber());
|
||||
|
||||
// Minimal recipient information
|
||||
|
||||
@@ -61,8 +61,7 @@ public class PdfTestView extends VerticalLayout {
|
||||
try {
|
||||
byte[] pdfBytes = systemInvoiceService.generateInvoicePdfFromHtml();
|
||||
|
||||
StreamResource resource = new StreamResource("vlt-invoice.pdf",
|
||||
() -> new ByteArrayInputStream(pdfBytes));
|
||||
StreamResource resource = new StreamResource("vlt-invoice.pdf", () -> new ByteArrayInputStream(pdfBytes));
|
||||
resource.setContentType("application/pdf");
|
||||
|
||||
getUI().ifPresent(ui -> {
|
||||
@@ -72,8 +71,8 @@ public class PdfTestView extends VerticalLayout {
|
||||
|
||||
Notification.show("PDF aus HTML erfolgreich generiert!", 3000, Notification.Position.BOTTOM_CENTER);
|
||||
} catch (Exception ex) {
|
||||
Notification.show("Fehler beim Generieren des PDFs aus HTML: " + ex.getMessage(),
|
||||
5000, Notification.Position.BOTTOM_CENTER);
|
||||
Notification.show("Fehler beim Generieren des PDFs aus HTML: " + ex.getMessage(), 5000,
|
||||
Notification.Position.BOTTOM_CENTER);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,8 +91,8 @@ public class PdfTestView extends VerticalLayout {
|
||||
|
||||
Notification.show("Customer PDF erfolgreich generiert!", 3000, Notification.Position.BOTTOM_CENTER);
|
||||
} catch (Exception ex) {
|
||||
Notification.show("Fehler beim Generieren des Customer PDFs: " + ex.getMessage(),
|
||||
5000, Notification.Position.BOTTOM_CENTER);
|
||||
Notification.show("Fehler beim Generieren des Customer PDFs: " + ex.getMessage(), 5000,
|
||||
Notification.Position.BOTTOM_CENTER);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,26 +1,43 @@
|
||||
package de.assecutor.votianlt.pages.view;
|
||||
|
||||
import com.vaadin.flow.component.datepicker.DatePicker;
|
||||
import com.vaadin.flow.component.AttachEvent;
|
||||
import com.vaadin.flow.component.DetachEvent;
|
||||
import com.vaadin.flow.component.UI;
|
||||
import com.vaadin.flow.component.button.Button;
|
||||
import com.vaadin.flow.component.button.ButtonVariant;
|
||||
import com.vaadin.flow.component.combobox.ComboBox;
|
||||
import com.vaadin.flow.component.confirmdialog.ConfirmDialog;
|
||||
import com.vaadin.flow.component.datepicker.DatePicker;
|
||||
import com.vaadin.flow.component.grid.Grid;
|
||||
import com.vaadin.flow.component.html.Anchor;
|
||||
import com.vaadin.flow.component.html.H2;
|
||||
import com.vaadin.flow.component.icon.Icon;
|
||||
import com.vaadin.flow.component.icon.VaadinIcon;
|
||||
import com.vaadin.flow.component.notification.Notification;
|
||||
import com.vaadin.flow.component.notification.NotificationVariant;
|
||||
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
|
||||
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
|
||||
import com.vaadin.flow.component.textfield.TextField;
|
||||
import com.vaadin.flow.server.StreamResource;
|
||||
import com.vaadin.flow.shared.Registration;
|
||||
import com.vaadin.flow.router.PageTitle;
|
||||
import com.vaadin.flow.router.Route;
|
||||
import de.assecutor.votianlt.model.Job;
|
||||
import de.assecutor.votianlt.model.JobStatus;
|
||||
import de.assecutor.votianlt.repository.JobRepository;
|
||||
import de.assecutor.votianlt.security.SecurityService;
|
||||
import de.assecutor.votianlt.service.JobBroadcaster;
|
||||
import de.assecutor.votianlt.service.JobHistoryService;
|
||||
import jakarta.annotation.security.RolesAllowed;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@PageTitle("Aufträge")
|
||||
@Route(value = "jobs", layout = de.assecutor.votianlt.pages.base.ui.view.MainLayout.class)
|
||||
@RolesAllowed({ "USER" })
|
||||
@Slf4j
|
||||
public class ShowJobsView extends VerticalLayout {
|
||||
|
||||
private final DatePicker startDate = new DatePicker("Startdatum");
|
||||
@@ -28,11 +45,19 @@ public class ShowJobsView extends VerticalLayout {
|
||||
private final TextField searchField = new TextField("Auftragsnummer suchen");
|
||||
private final ComboBox<String> statusFilter = new ComboBox<>("Status");
|
||||
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 Registration broadcasterRegistration;
|
||||
|
||||
@Autowired
|
||||
public ShowJobsView(JobRepository jobRepository) {
|
||||
public ShowJobsView(JobRepository jobRepository, JobHistoryService jobHistoryService,
|
||||
SecurityService securityService, JobBroadcaster jobBroadcaster) {
|
||||
this.jobRepository = jobRepository;
|
||||
this.jobHistoryService = jobHistoryService;
|
||||
this.securityService = securityService;
|
||||
this.jobBroadcaster = jobBroadcaster;
|
||||
setSizeFull();
|
||||
setPadding(true);
|
||||
setSpacing(true);
|
||||
@@ -78,11 +103,28 @@ public class ShowJobsView extends VerticalLayout {
|
||||
endDate.addValueChangeListener(e -> loadData());
|
||||
|
||||
// Configure grid columns: Auftraggeber, Auftragsnummer, Auftragsdatum, Zielort
|
||||
grid.addColumn(job -> extractCompanyName(job.getCustomerSelection())).setHeader("Auftraggeber").setAutoWidth(true).setFlexGrow(1).setSortable(true);
|
||||
grid.addColumn(job -> extractCompanyName(job.getCustomerSelection())).setHeader("Auftraggeber")
|
||||
.setAutoWidth(true).setFlexGrow(1).setSortable(true);
|
||||
grid.addColumn(Job::getJobNumber).setHeader("Auftragsnummer").setAutoWidth(true).setSortable(true);
|
||||
grid.addColumn(Job::getCreatedAt).setHeader("Auftragsdatum").setAutoWidth(true).setSortable(true);
|
||||
grid.addColumn(Job::getDeliveryCity).setHeader("Zielort").setAutoWidth(true).setFlexGrow(1).setSortable(true);
|
||||
|
||||
// Action column: manual completion for jobs without digital processing
|
||||
grid.addComponentColumn(job -> {
|
||||
if (!job.isDigitalProcessing() && job.getStatus() != JobStatus.COMPLETED
|
||||
&& job.getStatus() != JobStatus.CANCELLED) {
|
||||
Button completeBtn = new Button(new Icon(VaadinIcon.CHECK_CIRCLE));
|
||||
completeBtn.addThemeVariants(ButtonVariant.LUMO_TERTIARY, ButtonVariant.LUMO_SUCCESS);
|
||||
completeBtn.setTooltipText("Auftrag manuell abschließen");
|
||||
completeBtn.addClickListener(e -> {
|
||||
e.getSource().getElement().getNode(); // prevent row click
|
||||
showCompleteJobDialog(job);
|
||||
});
|
||||
return completeBtn;
|
||||
}
|
||||
return new com.vaadin.flow.component.html.Span();
|
||||
}).setHeader("").setAutoWidth(true).setFlexGrow(0);
|
||||
|
||||
grid.setMultiSort(true);
|
||||
grid.setSizeFull();
|
||||
|
||||
@@ -103,6 +145,32 @@ public class ShowJobsView extends VerticalLayout {
|
||||
loadData();
|
||||
}
|
||||
|
||||
private void showCompleteJobDialog(Job job) {
|
||||
ConfirmDialog dialog = new ConfirmDialog();
|
||||
dialog.setHeader("Auftrag abschließen");
|
||||
dialog.setText("Möchten Sie den Auftrag " + job.getJobNumber() + " manuell abschließen?");
|
||||
dialog.setCancelable(true);
|
||||
dialog.setCancelText("Abbrechen");
|
||||
dialog.setConfirmText("Abschließen");
|
||||
dialog.setConfirmButtonTheme("primary");
|
||||
dialog.addConfirmListener(e -> {
|
||||
try {
|
||||
JobStatus oldStatus = job.getStatus();
|
||||
job.setStatus(JobStatus.COMPLETED);
|
||||
job.setUpdatedAt(LocalDateTime.now());
|
||||
jobRepository.save(job);
|
||||
jobHistoryService.logStatusChange(job, oldStatus, JobStatus.COMPLETED, "Manuell");
|
||||
Notification.show("Auftrag " + job.getJobNumber() + " wurde abgeschlossen.", 3000,
|
||||
Notification.Position.BOTTOM_END).addThemeVariants(NotificationVariant.LUMO_SUCCESS);
|
||||
loadData();
|
||||
} catch (Exception ex) {
|
||||
Notification.show("Fehler beim Abschließen: " + ex.getMessage(), 5000, Notification.Position.BOTTOM_END)
|
||||
.addThemeVariants(NotificationVariant.LUMO_ERROR);
|
||||
}
|
||||
});
|
||||
dialog.open();
|
||||
}
|
||||
|
||||
private void loadData() {
|
||||
var start = startDate.getValue();
|
||||
var end = endDate.getValue();
|
||||
@@ -132,8 +200,10 @@ public class ShowJobsView extends VerticalLayout {
|
||||
// wenn
|
||||
// leer
|
||||
|
||||
// Verwende die erweiterte Suchmethode
|
||||
var filteredJobs = jobRepository.findWithFilters(startDt, endDt, jobNumberPattern, statusList);
|
||||
// Verwende die erweiterte Suchmethode, gefiltert nach aktuellem Benutzer
|
||||
String currentUserId = securityService.getCurrentUserId().toHexString();
|
||||
var filteredJobs = jobRepository.findWithFiltersByCreatedBy(currentUserId, startDt, endDt, jobNumberPattern,
|
||||
statusList);
|
||||
grid.setItems(filteredJobs);
|
||||
}
|
||||
|
||||
@@ -193,4 +263,75 @@ public class ShowJobsView extends VerticalLayout {
|
||||
}
|
||||
return customerSelection.trim();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onAttach(AttachEvent attachEvent) {
|
||||
super.onAttach(attachEvent);
|
||||
UI ui = attachEvent.getUI();
|
||||
|
||||
// Register broadcaster for real-time job notifications
|
||||
broadcasterRegistration = jobBroadcaster.register(job -> {
|
||||
handleNewJob(ui, job);
|
||||
});
|
||||
|
||||
log.info("ShowJobsView attached and job listener registered");
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDetach(DetachEvent detachEvent) {
|
||||
super.onDetach(detachEvent);
|
||||
if (broadcasterRegistration != null) {
|
||||
broadcasterRegistration.remove();
|
||||
broadcasterRegistration = null;
|
||||
}
|
||||
log.info("ShowJobsView detached and job listener unregistered");
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle new job notification
|
||||
*/
|
||||
private void handleNewJob(UI ui, Job job) {
|
||||
if (job == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
ui.access(() -> {
|
||||
try {
|
||||
// Play notification sound and show browser notification
|
||||
playNotificationSound(ui);
|
||||
showBrowserNotification(ui, job);
|
||||
|
||||
// Refresh the grid
|
||||
loadData();
|
||||
|
||||
log.info("New job notification displayed for job {}", job.getJobNumber());
|
||||
} catch (Exception e) {
|
||||
log.error("Error handling new job notification", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Play a notification sound when a new job is created
|
||||
*/
|
||||
private void playNotificationSound(UI ui) {
|
||||
ui.getPage().executeJs(
|
||||
"const audio = new Audio('data:audio/wav;base64,UklGRnoGAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQoGAACBhYqFbF1fdJivrJBhNjVgodDbq2EcBj+a2/LDciUFLIHO8tiJNwgZaLvt559NEAxQp+PwtmMcBjiR1/LMeSwFJHfH8N2QQAoUXrTp66hVFApGn+DyvmwhBTGH0fPTgjMGHm7A7+OZSA0PVK3m8LRjHAU7k9nzyn0tBSd5ye/glEIKEl206O2oVhQLSKHi8r5uIgU0idT01IMzByBwwvHjmEgNDlWs5PCzYhsFO5TY88p+Kwcme8jw4JVCChNdt+jvp1QVDEih4vK+bSIGNIrV9dODMggib8Lx5JdIDQ9VrObws2IbBT6U2PXKfi0IJnzH8OCVQgoVXbfp76dVFQ5IouLyvW0jCDSL1fTSgTQJJG7C8eSWSA8RVq3m8LJgGwg/lNj0yn4tCSV7x+/glUILFl237++nVhYOSKPi8rxtIwo0i9X00oE1CiNuwvDklkkREVat5u+yXxwJP5PY9Ml+Lgoge8fv4JVCDBVct+7vqFYYEUij4vG8bSQKNIvV89GBNQshbcLw5JZJERFV\u003d\u003d'); audio.play().catch(err => console.log('Audio play failed:', err));");
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a browser notification when a new job is created
|
||||
*/
|
||||
private void showBrowserNotification(UI ui, Job job) {
|
||||
String jobNumber = job.getJobNumber() != null ? job.getJobNumber() : "Neuer Auftrag";
|
||||
String company = extractCompanyName(job.getCustomerSelection());
|
||||
String message = company != null && !company.isBlank() ? ("Kunde: " + company) : "Neuer Auftrag erstellt";
|
||||
|
||||
ui.getPage().executeJs(
|
||||
"if (!('Notification' in window)) {" + " console.log('Browser does not support notifications');"
|
||||
+ "} else if (Notification.permission === 'granted') {" + " new Notification($0, { body: $1 });"
|
||||
+ "} else if (Notification.permission !== 'denied') {" + " Notification.requestPermission().then(permission => {"
|
||||
+ " if (permission === 'granted') {" + " new Notification($0, { body: $1 });" + " }" + " });" + "}",
|
||||
jobNumber, message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -330,8 +330,7 @@ public class StartView extends VerticalLayout implements BeforeEnterObserver {
|
||||
// Versionsnummer
|
||||
Span versionSpan = new Span("Version " + appVersion);
|
||||
versionSpan.getStyle().set("color", "var(--lumo-secondary-text-color)")
|
||||
.set("font-size", "var(--lumo-font-size-s)")
|
||||
.set("margin-top", "var(--lumo-space-l)");
|
||||
.set("font-size", "var(--lumo-font-size-s)").set("margin-top", "var(--lumo-space-l)");
|
||||
|
||||
footer.add(companyTitle, companyInfo, ctaText, slogan, versionSpan);
|
||||
return footer;
|
||||
|
||||
@@ -20,11 +20,11 @@ import com.vaadin.flow.component.textfield.TextField;
|
||||
import com.vaadin.flow.router.PageTitle;
|
||||
import com.vaadin.flow.router.Route;
|
||||
import de.assecutor.votianlt.ai.service.AiStatisticsService;
|
||||
import de.assecutor.votianlt.util.DateTimeFormatUtil;
|
||||
import jakarta.annotation.security.RolesAllowed;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.UUID;
|
||||
|
||||
@PageTitle("KI-Statistiken")
|
||||
@@ -37,7 +37,6 @@ public class StatisticsView extends VerticalLayout {
|
||||
private final AiStatisticsService aiStatisticsService;
|
||||
private final VerticalLayout chatContainer;
|
||||
private final TextField promptField;
|
||||
private final DateTimeFormatter timeFormatter = DateTimeFormatter.ofPattern("HH:mm");
|
||||
|
||||
public StatisticsView(AiStatisticsService aiStatisticsService) {
|
||||
this.aiStatisticsService = aiStatisticsService;
|
||||
@@ -69,7 +68,6 @@ public class StatisticsView extends VerticalLayout {
|
||||
scroller.setScrollDirection(Scroller.ScrollDirection.VERTICAL);
|
||||
scroller.getStyle().set("background", "var(--lumo-contrast-5pct)");
|
||||
|
||||
|
||||
add(scroller);
|
||||
setFlexGrow(1, scroller);
|
||||
|
||||
@@ -83,9 +81,8 @@ public class StatisticsView extends VerticalLayout {
|
||||
header.setWidthFull();
|
||||
header.setPadding(true);
|
||||
header.setAlignItems(FlexComponent.Alignment.CENTER);
|
||||
header.getStyle()
|
||||
.set("background", "var(--lumo-base-color)")
|
||||
.set("border-bottom", "1px solid var(--lumo-contrast-10pct)");
|
||||
header.getStyle().set("background", "var(--lumo-base-color)").set("border-bottom",
|
||||
"1px solid var(--lumo-contrast-10pct)");
|
||||
|
||||
Icon aiIcon = VaadinIcon.MAGIC.create();
|
||||
aiIcon.getStyle().set("color", "var(--lumo-primary-color)");
|
||||
@@ -94,9 +91,7 @@ public class StatisticsView extends VerticalLayout {
|
||||
title.getStyle().set("margin", "0").set("font-size", "var(--lumo-font-size-xl)");
|
||||
|
||||
Span subtitle = new Span("Frage mich zu Aufträgen, Umsätzen und Statistiken");
|
||||
subtitle.getStyle()
|
||||
.set("color", "var(--lumo-secondary-text-color)")
|
||||
.set("font-size", "var(--lumo-font-size-s)")
|
||||
subtitle.getStyle().set("color", "var(--lumo-secondary-text-color)").set("font-size", "var(--lumo-font-size-s)")
|
||||
.set("margin-left", "var(--lumo-space-m)");
|
||||
|
||||
header.add(aiIcon, title, subtitle);
|
||||
@@ -109,9 +104,8 @@ public class StatisticsView extends VerticalLayout {
|
||||
inputArea.setPadding(true);
|
||||
inputArea.setSpacing(true);
|
||||
inputArea.setAlignItems(FlexComponent.Alignment.CENTER);
|
||||
inputArea.getStyle()
|
||||
.set("background", "var(--lumo-base-color)")
|
||||
.set("border-top", "1px solid var(--lumo-contrast-10pct)");
|
||||
inputArea.getStyle().set("background", "var(--lumo-base-color)").set("border-top",
|
||||
"1px solid var(--lumo-contrast-10pct)");
|
||||
|
||||
Button sendButton = new Button(VaadinIcon.PAPERPLANE.create());
|
||||
sendButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
|
||||
@@ -119,9 +113,11 @@ public class StatisticsView extends VerticalLayout {
|
||||
sendButton.getStyle().set("min-width", "50px");
|
||||
|
||||
// Quick Action Buttons
|
||||
Button jobCountBtn = createQuickActionButton("Aufträge zählen", "Wie viele Aufträge gibt es insgesamt und nach Status?");
|
||||
Button jobCountBtn = createQuickActionButton("Aufträge zählen",
|
||||
"Wie viele Aufträge gibt es insgesamt und nach Status?");
|
||||
Button revenueBtn = createQuickActionButton("Umsatz", "Zeige mir den Umsatz pro Kunde.");
|
||||
Button trendBtn = createQuickActionButton("Monatstrend", "Zeige mir den Monatstrend der Aufträge für dieses Jahr.");
|
||||
Button trendBtn = createQuickActionButton("Monatstrend",
|
||||
"Zeige mir den Monatstrend der Aufträge für dieses Jahr.");
|
||||
|
||||
HorizontalLayout quickActions = new HorizontalLayout(jobCountBtn, revenueBtn, trendBtn);
|
||||
quickActions.setSpacing(true);
|
||||
@@ -194,30 +190,22 @@ public class StatisticsView extends VerticalLayout {
|
||||
Div messageDiv = new Div();
|
||||
messageDiv.addClassName("chat-message");
|
||||
messageDiv.addClassName("user-message");
|
||||
messageDiv.getStyle()
|
||||
.set("display", "flex")
|
||||
.set("justify-content", "flex-end")
|
||||
.set("margin-bottom", "var(--lumo-space-m)");
|
||||
messageDiv.getStyle().set("display", "flex").set("justify-content", "flex-end").set("margin-bottom",
|
||||
"var(--lumo-space-m)");
|
||||
|
||||
Div bubble = new Div();
|
||||
bubble.getStyle()
|
||||
.set("background", "var(--lumo-primary-color)")
|
||||
bubble.getStyle().set("background", "var(--lumo-primary-color)")
|
||||
.set("color", "var(--lumo-primary-contrast-color)")
|
||||
.set("padding", "var(--lumo-space-s) var(--lumo-space-m)")
|
||||
.set("border-radius", "var(--lumo-border-radius-l)")
|
||||
.set("max-width", "70%")
|
||||
.set("border-radius", "var(--lumo-border-radius-l)").set("max-width", "70%")
|
||||
.set("word-wrap", "break-word");
|
||||
|
||||
Paragraph text = new Paragraph(message);
|
||||
text.getStyle().set("margin", "0");
|
||||
|
||||
Span time = new Span(LocalDateTime.now().format(timeFormatter));
|
||||
time.getStyle()
|
||||
.set("font-size", "var(--lumo-font-size-xs)")
|
||||
.set("opacity", "0.7")
|
||||
.set("display", "block")
|
||||
.set("text-align", "right")
|
||||
.set("margin-top", "var(--lumo-space-xs)");
|
||||
Span time = new Span(DateTimeFormatUtil.formatTime(LocalDateTime.now()));
|
||||
time.getStyle().set("font-size", "var(--lumo-font-size-xs)").set("opacity", "0.7").set("display", "block")
|
||||
.set("text-align", "right").set("margin-top", "var(--lumo-space-xs)");
|
||||
|
||||
bubble.add(text, time);
|
||||
messageDiv.add(bubble);
|
||||
@@ -228,18 +216,13 @@ public class StatisticsView extends VerticalLayout {
|
||||
Div messageDiv = new Div();
|
||||
messageDiv.addClassName("chat-message");
|
||||
messageDiv.addClassName("ai-message");
|
||||
messageDiv.getStyle()
|
||||
.set("display", "flex")
|
||||
.set("justify-content", "flex-start")
|
||||
.set("margin-bottom", "var(--lumo-space-m)");
|
||||
messageDiv.getStyle().set("display", "flex").set("justify-content", "flex-start").set("margin-bottom",
|
||||
"var(--lumo-space-m)");
|
||||
|
||||
Div bubble = new Div();
|
||||
bubble.getStyle()
|
||||
.set("background", "var(--lumo-base-color)")
|
||||
.set("border", "1px solid var(--lumo-contrast-10pct)")
|
||||
.set("padding", "var(--lumo-space-m)")
|
||||
.set("border-radius", "var(--lumo-border-radius-l)")
|
||||
.set("max-width", "85%")
|
||||
bubble.getStyle().set("background", "var(--lumo-base-color)")
|
||||
.set("border", "1px solid var(--lumo-contrast-10pct)").set("padding", "var(--lumo-space-m)")
|
||||
.set("border-radius", "var(--lumo-border-radius-l)").set("max-width", "85%")
|
||||
.set("box-shadow", "var(--lumo-box-shadow-xs)");
|
||||
|
||||
// AI Icon
|
||||
@@ -252,9 +235,7 @@ public class StatisticsView extends VerticalLayout {
|
||||
aiIcon.getStyle().set("color", "var(--lumo-primary-color)");
|
||||
|
||||
Span aiLabel = new Span("KI-Assistent");
|
||||
aiLabel.getStyle()
|
||||
.set("font-weight", "bold")
|
||||
.set("font-size", "var(--lumo-font-size-s)");
|
||||
aiLabel.getStyle().set("font-weight", "bold").set("font-size", "var(--lumo-font-size-s)");
|
||||
|
||||
header.add(aiIcon, aiLabel);
|
||||
bubble.add(header);
|
||||
@@ -273,20 +254,15 @@ public class StatisticsView extends VerticalLayout {
|
||||
if (response.chartData() != null && !response.chartData().isEmpty()) {
|
||||
Div chartContainer = createChart(response.chartType(), response.chartData());
|
||||
if (chartContainer != null) {
|
||||
chartContainer.getStyle()
|
||||
.set("margin-top", "var(--lumo-space-m)")
|
||||
.set("height", "300px");
|
||||
chartContainer.getStyle().set("margin-top", "var(--lumo-space-m)").set("height", "300px");
|
||||
bubble.add(chartContainer);
|
||||
}
|
||||
}
|
||||
|
||||
// Timestamp
|
||||
Span time = new Span(LocalDateTime.now().format(timeFormatter));
|
||||
time.getStyle()
|
||||
.set("font-size", "var(--lumo-font-size-xs)")
|
||||
.set("color", "var(--lumo-secondary-text-color)")
|
||||
.set("display", "block")
|
||||
.set("margin-top", "var(--lumo-space-s)");
|
||||
Span time = new Span(DateTimeFormatUtil.formatTime(LocalDateTime.now()));
|
||||
time.getStyle().set("font-size", "var(--lumo-font-size-xs)").set("color", "var(--lumo-secondary-text-color)")
|
||||
.set("display", "block").set("margin-top", "var(--lumo-space-s)");
|
||||
bubble.add(time);
|
||||
|
||||
messageDiv.add(bubble);
|
||||
@@ -295,18 +271,14 @@ public class StatisticsView extends VerticalLayout {
|
||||
|
||||
private void addErrorMessage(String message) {
|
||||
Div messageDiv = new Div();
|
||||
messageDiv.getStyle()
|
||||
.set("display", "flex")
|
||||
.set("justify-content", "flex-start")
|
||||
.set("margin-bottom", "var(--lumo-space-m)");
|
||||
messageDiv.getStyle().set("display", "flex").set("justify-content", "flex-start").set("margin-bottom",
|
||||
"var(--lumo-space-m)");
|
||||
|
||||
Div bubble = new Div();
|
||||
bubble.getStyle()
|
||||
.set("background", "var(--lumo-error-color-10pct)")
|
||||
bubble.getStyle().set("background", "var(--lumo-error-color-10pct)")
|
||||
.set("border", "1px solid var(--lumo-error-color)")
|
||||
.set("padding", "var(--lumo-space-s) var(--lumo-space-m)")
|
||||
.set("border-radius", "var(--lumo-border-radius-l)")
|
||||
.set("max-width", "70%");
|
||||
.set("border-radius", "var(--lumo-border-radius-l)").set("max-width", "70%");
|
||||
|
||||
Icon errorIcon = VaadinIcon.EXCLAMATION_CIRCLE.create();
|
||||
errorIcon.setSize("16px");
|
||||
@@ -326,22 +298,17 @@ public class StatisticsView extends VerticalLayout {
|
||||
|
||||
private Div createLoadingMessage() {
|
||||
Div messageDiv = new Div();
|
||||
messageDiv.getStyle()
|
||||
.set("display", "flex")
|
||||
.set("justify-content", "flex-start")
|
||||
.set("margin-bottom", "var(--lumo-space-m)");
|
||||
messageDiv.getStyle().set("display", "flex").set("justify-content", "flex-start").set("margin-bottom",
|
||||
"var(--lumo-space-m)");
|
||||
|
||||
Div bubble = new Div();
|
||||
bubble.getStyle()
|
||||
.set("background", "var(--lumo-base-color)")
|
||||
bubble.getStyle().set("background", "var(--lumo-base-color)")
|
||||
.set("border", "1px solid var(--lumo-contrast-10pct)")
|
||||
.set("padding", "var(--lumo-space-s) var(--lumo-space-m)")
|
||||
.set("border-radius", "var(--lumo-border-radius-l)");
|
||||
|
||||
Span dots = new Span("Analysiere...");
|
||||
dots.getStyle()
|
||||
.set("color", "var(--lumo-secondary-text-color)")
|
||||
.set("font-style", "italic");
|
||||
dots.getStyle().set("color", "var(--lumo-secondary-text-color)").set("font-style", "italic");
|
||||
|
||||
bubble.add(dots);
|
||||
messageDiv.add(bubble);
|
||||
@@ -356,10 +323,8 @@ public class StatisticsView extends VerticalLayout {
|
||||
String canvasId = "chart-" + UUID.randomUUID().toString().substring(0, 8);
|
||||
Div chartContainer = new Div();
|
||||
chartContainer.addClassName("chart-wrapper");
|
||||
chartContainer.getStyle()
|
||||
.set("background", "var(--lumo-contrast-5pct)")
|
||||
.set("border-radius", "var(--lumo-border-radius-m)")
|
||||
.set("padding", "var(--lumo-space-s)");
|
||||
chartContainer.getStyle().set("background", "var(--lumo-contrast-5pct)")
|
||||
.set("border-radius", "var(--lumo-border-radius-m)").set("padding", "var(--lumo-space-s)");
|
||||
|
||||
chartContainer.getElement().setProperty("innerHTML",
|
||||
"<canvas id='" + canvasId + "' style='width: 100%; height: 100%;'></canvas>");
|
||||
@@ -410,277 +375,274 @@ public class StatisticsView extends VerticalLayout {
|
||||
private String getChartOptions(String chartType) {
|
||||
// Gradient und moderne Farben für verschiedene Chart-Typen
|
||||
return switch (chartType) {
|
||||
case "line" -> """
|
||||
{
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
intersect: false,
|
||||
mode: 'index'
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
labels: {
|
||||
usePointStyle: true,
|
||||
padding: 20
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(0,0,0,0.8)',
|
||||
titleFont: { size: 14, weight: 'bold' },
|
||||
bodyFont: { size: 13 },
|
||||
padding: 12,
|
||||
cornerRadius: 8
|
||||
case "line" -> """
|
||||
{
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
intersect: false,
|
||||
mode: 'index'
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
labels: {
|
||||
usePointStyle: true,
|
||||
padding: 20
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
grid: {
|
||||
color: 'rgba(0,0,0,0.05)'
|
||||
}
|
||||
},
|
||||
x: {
|
||||
grid: {
|
||||
display: false
|
||||
}
|
||||
}
|
||||
},
|
||||
elements: {
|
||||
line: {
|
||||
tension: 0.4,
|
||||
borderWidth: 3
|
||||
},
|
||||
point: {
|
||||
radius: 4,
|
||||
hoverRadius: 6,
|
||||
hitRadius: 10
|
||||
}
|
||||
},
|
||||
animation: {
|
||||
duration: 1000,
|
||||
easing: 'easeOutQuart'
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(0,0,0,0.8)',
|
||||
titleFont: { size: 14, weight: 'bold' },
|
||||
bodyFont: { size: 13 },
|
||||
padding: 12,
|
||||
cornerRadius: 8
|
||||
}
|
||||
}
|
||||
""";
|
||||
case "bar" -> """
|
||||
{
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
labels: {
|
||||
usePointStyle: true,
|
||||
padding: 20
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(0,0,0,0.8)',
|
||||
titleFont: { size: 14, weight: 'bold' },
|
||||
bodyFont: { size: 13 },
|
||||
padding: 12,
|
||||
cornerRadius: 8
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
grid: {
|
||||
color: 'rgba(0,0,0,0.05)'
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
grid: {
|
||||
color: 'rgba(0,0,0,0.05)'
|
||||
}
|
||||
},
|
||||
x: {
|
||||
grid: {
|
||||
display: false
|
||||
}
|
||||
x: {
|
||||
grid: {
|
||||
display: false
|
||||
}
|
||||
},
|
||||
elements: {
|
||||
bar: {
|
||||
borderRadius: 6,
|
||||
borderSkipped: false
|
||||
}
|
||||
},
|
||||
animation: {
|
||||
duration: 800,
|
||||
easing: 'easeOutQuart'
|
||||
}
|
||||
}
|
||||
""";
|
||||
case "doughnut" -> """
|
||||
{
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'right',
|
||||
labels: {
|
||||
usePointStyle: true,
|
||||
padding: 15,
|
||||
font: { size: 12 }
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(0,0,0,0.8)',
|
||||
titleFont: { size: 14, weight: 'bold' },
|
||||
bodyFont: { size: 13 },
|
||||
padding: 12,
|
||||
cornerRadius: 8
|
||||
}
|
||||
},
|
||||
elements: {
|
||||
line: {
|
||||
tension: 0.4,
|
||||
borderWidth: 3
|
||||
},
|
||||
cutout: '60%',
|
||||
animation: {
|
||||
animateRotate: true,
|
||||
animateScale: true,
|
||||
duration: 1000,
|
||||
easing: 'easeOutQuart'
|
||||
point: {
|
||||
radius: 4,
|
||||
hoverRadius: 6,
|
||||
hitRadius: 10
|
||||
}
|
||||
},
|
||||
animation: {
|
||||
duration: 1000,
|
||||
easing: 'easeOutQuart'
|
||||
}
|
||||
""";
|
||||
case "pie" -> """
|
||||
{
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'right',
|
||||
labels: {
|
||||
usePointStyle: true,
|
||||
padding: 15,
|
||||
font: { size: 12 }
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(0,0,0,0.8)',
|
||||
titleFont: { size: 14, weight: 'bold' },
|
||||
bodyFont: { size: 13 },
|
||||
padding: 12,
|
||||
cornerRadius: 8
|
||||
}
|
||||
""";
|
||||
case "bar" -> """
|
||||
{
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
labels: {
|
||||
usePointStyle: true,
|
||||
padding: 20
|
||||
}
|
||||
},
|
||||
animation: {
|
||||
animateRotate: true,
|
||||
animateScale: true,
|
||||
duration: 1000,
|
||||
easing: 'easeOutQuart'
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(0,0,0,0.8)',
|
||||
titleFont: { size: 14, weight: 'bold' },
|
||||
bodyFont: { size: 13 },
|
||||
padding: 12,
|
||||
cornerRadius: 8
|
||||
}
|
||||
}
|
||||
""";
|
||||
case "radar" -> """
|
||||
{
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'top',
|
||||
labels: {
|
||||
usePointStyle: true,
|
||||
padding: 15
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(0,0,0,0.8)',
|
||||
titleFont: { size: 14, weight: 'bold' },
|
||||
bodyFont: { size: 13 },
|
||||
padding: 12,
|
||||
cornerRadius: 8
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
grid: {
|
||||
color: 'rgba(0,0,0,0.05)'
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
r: {
|
||||
beginAtZero: true,
|
||||
grid: {
|
||||
color: 'rgba(0,0,0,0.1)'
|
||||
},
|
||||
pointLabels: {
|
||||
font: { size: 12 }
|
||||
}
|
||||
x: {
|
||||
grid: {
|
||||
display: false
|
||||
}
|
||||
},
|
||||
elements: {
|
||||
line: {
|
||||
borderWidth: 2
|
||||
},
|
||||
point: {
|
||||
radius: 4,
|
||||
hoverRadius: 6
|
||||
}
|
||||
},
|
||||
animation: {
|
||||
duration: 1000,
|
||||
easing: 'easeOutQuart'
|
||||
}
|
||||
},
|
||||
elements: {
|
||||
bar: {
|
||||
borderRadius: 6,
|
||||
borderSkipped: false
|
||||
}
|
||||
},
|
||||
animation: {
|
||||
duration: 800,
|
||||
easing: 'easeOutQuart'
|
||||
}
|
||||
""";
|
||||
case "polarArea" -> """
|
||||
{
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'right',
|
||||
labels: {
|
||||
usePointStyle: true,
|
||||
padding: 15,
|
||||
font: { size: 12 }
|
||||
}
|
||||
}
|
||||
""";
|
||||
case "doughnut" -> """
|
||||
{
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'right',
|
||||
labels: {
|
||||
usePointStyle: true,
|
||||
padding: 15,
|
||||
font: { size: 12 }
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(0,0,0,0.8)',
|
||||
titleFont: { size: 14, weight: 'bold' },
|
||||
bodyFont: { size: 13 },
|
||||
padding: 12,
|
||||
cornerRadius: 8
|
||||
}
|
||||
},
|
||||
cutout: '60%',
|
||||
animation: {
|
||||
animateRotate: true,
|
||||
animateScale: true,
|
||||
duration: 1000,
|
||||
easing: 'easeOutQuart'
|
||||
}
|
||||
}
|
||||
""";
|
||||
case "pie" -> """
|
||||
{
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'right',
|
||||
labels: {
|
||||
usePointStyle: true,
|
||||
padding: 15,
|
||||
font: { size: 12 }
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(0,0,0,0.8)',
|
||||
titleFont: { size: 14, weight: 'bold' },
|
||||
bodyFont: { size: 13 },
|
||||
padding: 12,
|
||||
cornerRadius: 8
|
||||
}
|
||||
},
|
||||
animation: {
|
||||
animateRotate: true,
|
||||
animateScale: true,
|
||||
duration: 1000,
|
||||
easing: 'easeOutQuart'
|
||||
}
|
||||
}
|
||||
""";
|
||||
case "radar" -> """
|
||||
{
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'top',
|
||||
labels: {
|
||||
usePointStyle: true,
|
||||
padding: 15
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(0,0,0,0.8)',
|
||||
titleFont: { size: 14, weight: 'bold' },
|
||||
bodyFont: { size: 13 },
|
||||
padding: 12,
|
||||
cornerRadius: 8
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
r: {
|
||||
beginAtZero: true,
|
||||
grid: {
|
||||
color: 'rgba(0,0,0,0.1)'
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(0,0,0,0.8)',
|
||||
titleFont: { size: 14, weight: 'bold' },
|
||||
bodyFont: { size: 13 },
|
||||
padding: 12,
|
||||
cornerRadius: 8
|
||||
pointLabels: {
|
||||
font: { size: 12 }
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
r: {
|
||||
beginAtZero: true,
|
||||
grid: {
|
||||
color: 'rgba(0,0,0,0.1)'
|
||||
}
|
||||
}
|
||||
},
|
||||
animation: {
|
||||
animateRotate: true,
|
||||
animateScale: true,
|
||||
duration: 1000,
|
||||
easing: 'easeOutQuart'
|
||||
}
|
||||
},
|
||||
elements: {
|
||||
line: {
|
||||
borderWidth: 2
|
||||
},
|
||||
point: {
|
||||
radius: 4,
|
||||
hoverRadius: 6
|
||||
}
|
||||
},
|
||||
animation: {
|
||||
duration: 1000,
|
||||
easing: 'easeOutQuart'
|
||||
}
|
||||
""";
|
||||
default -> """
|
||||
{
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom'
|
||||
}
|
||||
""";
|
||||
case "polarArea" -> """
|
||||
{
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'right',
|
||||
labels: {
|
||||
usePointStyle: true,
|
||||
padding: 15,
|
||||
font: { size: 12 }
|
||||
}
|
||||
},
|
||||
animation: {
|
||||
duration: 800
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(0,0,0,0.8)',
|
||||
titleFont: { size: 14, weight: 'bold' },
|
||||
bodyFont: { size: 13 },
|
||||
padding: 12,
|
||||
cornerRadius: 8
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
r: {
|
||||
beginAtZero: true,
|
||||
grid: {
|
||||
color: 'rgba(0,0,0,0.1)'
|
||||
}
|
||||
}
|
||||
},
|
||||
animation: {
|
||||
animateRotate: true,
|
||||
animateScale: true,
|
||||
duration: 1000,
|
||||
easing: 'easeOutQuart'
|
||||
}
|
||||
""";
|
||||
}
|
||||
""";
|
||||
default -> """
|
||||
{
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom'
|
||||
}
|
||||
},
|
||||
animation: {
|
||||
duration: 800
|
||||
}
|
||||
}
|
||||
""";
|
||||
};
|
||||
}
|
||||
|
||||
private String formatMarkdown(String text) {
|
||||
if (text == null) return "";
|
||||
if (text == null)
|
||||
return "";
|
||||
// Einfache Markdown-Formatierung
|
||||
return text
|
||||
.replace("\n", "<br>")
|
||||
.replaceAll("\\*\\*(.+?)\\*\\*", "<strong>$1</strong>")
|
||||
.replaceAll("\\*(.+?)\\*", "<em>$1</em>")
|
||||
.replaceAll("`(.+?)`", "<code>$1</code>");
|
||||
return text.replace("\n", "<br>").replaceAll("\\*\\*(.+?)\\*\\*", "<strong>$1</strong>")
|
||||
.replaceAll("\\*(.+?)\\*", "<em>$1</em>").replaceAll("`(.+?)`", "<code>$1</code>");
|
||||
}
|
||||
|
||||
private void scrollToBottom() {
|
||||
chatContainer.getElement().executeJs(
|
||||
"this.parentElement.scrollTop = this.parentElement.scrollHeight");
|
||||
chatContainer.getElement().executeJs("this.parentElement.scrollTop = this.parentElement.scrollHeight");
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -21,12 +21,12 @@ import de.assecutor.votianlt.model.MessageOrigin;
|
||||
import de.assecutor.votianlt.model.MessageType;
|
||||
import de.assecutor.votianlt.pages.service.AppUserService;
|
||||
import de.assecutor.votianlt.service.MessageService;
|
||||
import de.assecutor.votianlt.util.DateTimeFormatUtil;
|
||||
import jakarta.annotation.security.RolesAllowed;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.bson.types.ObjectId;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
@@ -46,7 +46,6 @@ public class UserMessagesView extends Main implements HasUrlParameter<String> {
|
||||
|
||||
private String participantKey;
|
||||
private VerticalLayout contentLayout;
|
||||
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm");
|
||||
|
||||
public UserMessagesView(AppUserService appUserService, MessageService messageService) {
|
||||
this.appUserService = appUserService;
|
||||
@@ -79,15 +78,15 @@ public class UserMessagesView extends Main implements HasUrlParameter<String> {
|
||||
log.debug("Could not resolve AppUser for participant key {}: {}", participantKey, e.getMessage());
|
||||
}
|
||||
|
||||
String clientName = client != null ?
|
||||
client.getVorname() + " " + client.getNachname() : Optional.ofNullable(participantKey).orElse("Unbekannter Teilnehmer");
|
||||
String clientName = client != null ? client.getVorname() + " " + client.getNachname()
|
||||
: Optional.ofNullable(participantKey).orElse("Unbekannter Teilnehmer");
|
||||
|
||||
HorizontalLayout headerLayout = createHeaderLayout(clientName);
|
||||
contentLayout.add(headerLayout);
|
||||
|
||||
List<Message> conversation = messageService.getMessagesForAppUserAscending(participantKey);
|
||||
Map<MessageType, List<Message>> messagesByType = conversation.stream()
|
||||
.collect(Collectors.groupingBy(message -> Optional.ofNullable(message.getMessageType()).orElse(MessageType.GENERAL)));
|
||||
Map<MessageType, List<Message>> messagesByType = conversation.stream().collect(Collectors
|
||||
.groupingBy(message -> Optional.ofNullable(message.getMessageType()).orElse(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.sort(Comparator.comparing(Message::getCreatedAt, Comparator.nullsLast(LocalDateTime::compareTo)));
|
||||
sortedMessages
|
||||
.sort(Comparator.comparing(Message::getCreatedAt, Comparator.nullsLast(LocalDateTime::compareTo)));
|
||||
|
||||
Message latest = sortedMessages.isEmpty() ? null : sortedMessages.get(sortedMessages.size() - 1);
|
||||
int unreadCount = (int) sortedMessages.stream()
|
||||
.filter(message -> message.getOrigin() == MessageOrigin.CLIENT && !message.isRead())
|
||||
.count();
|
||||
.filter(message -> message.getOrigin() == MessageOrigin.CLIENT && !message.isRead()).count();
|
||||
int messageCount = sortedMessages.size();
|
||||
LocalDateTime lastMessageTime = latest != null ? latest.getCreatedAt() : null;
|
||||
String preview = resolvePreview(latest);
|
||||
|
||||
section.add(createMessageCard(
|
||||
"Allgemeine Unterhaltung",
|
||||
preview,
|
||||
lastMessageTime,
|
||||
messageCount,
|
||||
unreadCount,
|
||||
"general"
|
||||
));
|
||||
section.add(createMessageCard("Allgemeine Unterhaltung", preview, lastMessageTime, messageCount, unreadCount,
|
||||
"general"));
|
||||
|
||||
return section;
|
||||
}
|
||||
@@ -173,18 +166,11 @@ public class UserMessagesView extends Main implements HasUrlParameter<String> {
|
||||
messages.sort(Comparator.comparing(Message::getCreatedAt, Comparator.nullsLast(LocalDateTime::compareTo)));
|
||||
Message latest = messages.get(messages.size() - 1);
|
||||
int unreadCount = (int) messages.stream()
|
||||
.filter(message -> message.getOrigin() == MessageOrigin.CLIENT && !message.isRead())
|
||||
.count();
|
||||
.filter(message -> message.getOrigin() == MessageOrigin.CLIENT && !message.isRead()).count();
|
||||
|
||||
String conversationTitle = "Auftrag " + jobKey;
|
||||
section.add(createMessageCard(
|
||||
conversationTitle,
|
||||
resolvePreview(latest),
|
||||
latest.getCreatedAt(),
|
||||
messages.size(),
|
||||
unreadCount,
|
||||
"job-" + sanitizeConversationId(jobKey)
|
||||
));
|
||||
section.add(createMessageCard(conversationTitle, resolvePreview(latest), latest.getCreatedAt(),
|
||||
messages.size(), unreadCount, "job-" + sanitizeConversationId(jobKey)));
|
||||
});
|
||||
|
||||
return section;
|
||||
@@ -199,14 +185,11 @@ public class UserMessagesView extends Main implements HasUrlParameter<String> {
|
||||
return "[Bildnachricht]";
|
||||
}
|
||||
|
||||
return Optional.ofNullable(message.getContent())
|
||||
.map(String::trim)
|
||||
.orElse("");
|
||||
return Optional.ofNullable(message.getContent()).map(String::trim).orElse("");
|
||||
}
|
||||
|
||||
private Div createMessageCard(String conversationTitle, String lastMessagePreview,
|
||||
LocalDateTime lastMessageTime, int messageCount,
|
||||
int unreadCount, String conversationId) {
|
||||
private Div createMessageCard(String conversationTitle, String lastMessagePreview, LocalDateTime lastMessageTime,
|
||||
int messageCount, int unreadCount, String conversationId) {
|
||||
Div card = new Div();
|
||||
card.setWidthFull();
|
||||
card.getStyle().set("padding", "15px");
|
||||
@@ -251,7 +234,8 @@ public class UserMessagesView extends Main implements HasUrlParameter<String> {
|
||||
titleRow.expand(titleSpan);
|
||||
|
||||
// Preview text
|
||||
Span preview = new Span(Optional.ofNullable(lastMessagePreview).filter(s -> !s.isBlank()).orElse("(kein Inhalt)"));
|
||||
Span preview = new Span(
|
||||
Optional.ofNullable(lastMessagePreview).filter(s -> !s.isBlank()).orElse("(kein Inhalt)"));
|
||||
preview.getStyle().set("color", "#666666");
|
||||
preview.getStyle().set("font-size", "14px");
|
||||
|
||||
@@ -259,7 +243,7 @@ public class UserMessagesView extends Main implements HasUrlParameter<String> {
|
||||
HorizontalLayout metaRow = new HorizontalLayout();
|
||||
metaRow.setWidthFull();
|
||||
|
||||
Span timeSpan = new Span(lastMessageTime != null ? lastMessageTime.format(DATE_FORMATTER) : "-");
|
||||
Span timeSpan = new Span(lastMessageTime != null ? DateTimeFormatUtil.formatDateTime(lastMessageTime) : "-");
|
||||
timeSpan.getStyle().set("color", "#999999");
|
||||
timeSpan.getStyle().set("font-size", "12px");
|
||||
|
||||
@@ -278,7 +262,8 @@ public class UserMessagesView extends Main implements HasUrlParameter<String> {
|
||||
card.add(cardContent);
|
||||
|
||||
// Click listener to navigate to message details
|
||||
card.addClickListener(e -> UI.getCurrent().navigate("message-details/" + participantKey + "/" + conversationId));
|
||||
card.addClickListener(
|
||||
e -> UI.getCurrent().navigate("message-details/" + participantKey + "/" + conversationId));
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
@@ -92,10 +92,10 @@ public interface JobRepository extends MongoRepository<Job, ObjectId> {
|
||||
long countByIsDraftTrue();
|
||||
|
||||
/**
|
||||
* Findet alle nicht abgeschlossenen Aufträge, die einem bestimmten App-Nutzer zugewiesen sind.
|
||||
* Excludes jobs with status COMPLETED or CANCELLED.
|
||||
* Uses explicit query because @Field("app_user") annotation is not always
|
||||
* respected by Spring Data MongoDB query derivation.
|
||||
* Findet alle nicht abgeschlossenen Aufträge, die einem bestimmten App-Nutzer
|
||||
* zugewiesen sind. Excludes jobs with status COMPLETED or CANCELLED. Uses
|
||||
* explicit query because @Field("app_user") annotation is not always respected
|
||||
* by Spring Data MongoDB query derivation.
|
||||
*/
|
||||
@Query("{'app_user': ?0, 'status': {'$nin': ['COMPLETED', 'CANCELLED']}}")
|
||||
List<Job> findByAppUser(String appUser);
|
||||
@@ -109,8 +109,17 @@ public interface JobRepository extends MongoRepository<Job, ObjectId> {
|
||||
/**
|
||||
* Erweiterte Suche: Zeitraum, Auftragsnummer und Status kombiniert
|
||||
*/
|
||||
@Query("{'createdAt': {'$gte': ?0, '$lte': ?1}, "
|
||||
+ "'jobNumber': {'$regex': ?2, '$options': 'i'}, " + "'status': {'$in': ?3}}")
|
||||
@Query("{'createdAt': {'$gte': ?0, '$lte': ?1}, " + "'jobNumber': {'$regex': ?2, '$options': 'i'}, "
|
||||
+ "'status': {'$in': ?3}}")
|
||||
List<Job> findWithFilters(LocalDateTime startDate, LocalDateTime endDate, String jobNumberPattern,
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -13,12 +13,14 @@ import java.util.List;
|
||||
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);
|
||||
|
||||
/**
|
||||
* 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);
|
||||
|
||||
|
||||
@@ -27,7 +27,8 @@ public interface PendingDeliveryRepository extends MongoRepository<PendingDelive
|
||||
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);
|
||||
|
||||
@@ -66,4 +67,3 @@ public interface PendingDeliveryRepository extends MongoRepository<PendingDelive
|
||||
*/
|
||||
void deleteByCreatedAtBefore(LocalDateTime dateTime);
|
||||
}
|
||||
|
||||
|
||||
@@ -17,8 +17,8 @@ import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* Service for managing client connections via Ping/Pong mechanism.
|
||||
* Tracks connected clients and periodically checks their connectivity.
|
||||
* Service for managing client connections via Ping/Pong mechanism. Tracks
|
||||
* connected clients and periodically checks their connectivity.
|
||||
*/
|
||||
@Service
|
||||
@Slf4j
|
||||
@@ -27,14 +27,8 @@ public class ClientConnectionService {
|
||||
/**
|
||||
* Represents the connection state of a client.
|
||||
*/
|
||||
public record ClientState(
|
||||
String clientId,
|
||||
String userId,
|
||||
boolean connected,
|
||||
Instant lastPingSent,
|
||||
Instant lastPongReceived,
|
||||
Instant connectedAt
|
||||
) {
|
||||
public record ClientState(String clientId, String userId, boolean connected, Instant lastPingSent,
|
||||
Instant lastPongReceived, Instant connectedAt) {
|
||||
public ClientState withPingSent(Instant pingSent) {
|
||||
return new ClientState(clientId, userId, connected, pingSent, lastPongReceived, connectedAt);
|
||||
}
|
||||
@@ -60,7 +54,7 @@ public class ClientConnectionService {
|
||||
private int pingTimeoutSeconds;
|
||||
|
||||
public ClientConnectionService(PluginManager pluginManager, ObjectMapper objectMapper,
|
||||
@Lazy MessageDeliveryService messageDeliveryService) {
|
||||
@Lazy MessageDeliveryService messageDeliveryService) {
|
||||
this.pluginManager = pluginManager;
|
||||
this.objectMapper = objectMapper;
|
||||
this.messageDeliveryService = messageDeliveryService;
|
||||
@@ -69,8 +63,10 @@ public class ClientConnectionService {
|
||||
/**
|
||||
* Registers a client as connected after successful login.
|
||||
*
|
||||
* @param clientId The unique client identifier
|
||||
* @param userId The user ID associated with this client
|
||||
* @param clientId
|
||||
* The unique client identifier
|
||||
* @param userId
|
||||
* The user ID associated with this client
|
||||
*/
|
||||
public void registerClient(String clientId, String userId) {
|
||||
if (clientId == null || clientId.isBlank()) {
|
||||
@@ -84,8 +80,8 @@ public class ClientConnectionService {
|
||||
Instant now = Instant.now();
|
||||
ClientState state = new ClientState(clientId, userId, true, null, now, now);
|
||||
connectedClients.put(clientId, state);
|
||||
log.info("[ClientConnectionService] Client registered: clientId={}, userId={}, totalClients={}",
|
||||
clientId, userId, connectedClients.size());
|
||||
log.info("[ClientConnectionService] Client registered: clientId={}, userId={}, totalClients={}", clientId,
|
||||
userId, connectedClients.size());
|
||||
|
||||
// If client was previously disconnected, retry pending messages
|
||||
if (wasDisconnected) {
|
||||
@@ -97,7 +93,8 @@ public class ClientConnectionService {
|
||||
/**
|
||||
* 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) {
|
||||
ClientState removed = connectedClients.remove(clientId);
|
||||
@@ -107,10 +104,11 @@ public class ClientConnectionService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles a pong response from a client.
|
||||
* Searches by both clientId and userId since pong is sent to /server/{userId}/pong.
|
||||
* Handles a pong response from a client. Searches by both clientId and userId
|
||||
* 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) {
|
||||
if (id == null || id.isBlank()) {
|
||||
@@ -149,10 +147,11 @@ public class ClientConnectionService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a client is currently connected.
|
||||
* Searches by both clientId and userId.
|
||||
* Checks if a client is currently connected. Searches by both clientId and
|
||||
* userId.
|
||||
*
|
||||
* @param id The client or user identifier
|
||||
* @param id
|
||||
* The client or user identifier
|
||||
* @return true if the client is connected
|
||||
*/
|
||||
public boolean isClientConnected(String id) {
|
||||
@@ -165,8 +164,7 @@ public class ClientConnectionService {
|
||||
return true;
|
||||
}
|
||||
// Then search by userId
|
||||
return connectedClients.values().stream()
|
||||
.anyMatch(s -> s.connected() && id.equals(s.userId()));
|
||||
return connectedClients.values().stream().anyMatch(s -> s.connected() && id.equals(s.userId()));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -175,16 +173,15 @@ public class ClientConnectionService {
|
||||
* @return Set of connected client IDs
|
||||
*/
|
||||
public Set<String> getConnectedClientIds() {
|
||||
return connectedClients.entrySet().stream()
|
||||
.filter(e -> e.getValue().connected())
|
||||
.map(Map.Entry::getKey)
|
||||
return connectedClients.entrySet().stream().filter(e -> e.getValue().connected()).map(Map.Entry::getKey)
|
||||
.collect(java.util.stream.Collectors.toSet());
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the connection state for a specific client.
|
||||
*
|
||||
* @param clientId The client identifier
|
||||
* @param clientId
|
||||
* The client identifier
|
||||
* @return ClientState or null if not found
|
||||
*/
|
||||
public ClientState getClientState(String clientId) {
|
||||
@@ -192,8 +189,8 @@ public class ClientConnectionService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Scheduled task to send pings to all connected clients.
|
||||
* Runs based on the configured interval (app.client.ping.interval-seconds).
|
||||
* Scheduled task to send pings to all connected clients. Runs based on the
|
||||
* configured interval (app.client.ping.interval-seconds).
|
||||
*/
|
||||
@Scheduled(fixedRateString = "${app.client.ping.interval-seconds:15}000")
|
||||
public void sendPingsToAllClients() {
|
||||
@@ -228,8 +225,8 @@ public class ClientConnectionService {
|
||||
// Client did not respond in time - mark as disconnected
|
||||
ClientState disconnectedState = state.withConnected(false);
|
||||
connectedClients.put(clientId, disconnectedState);
|
||||
log.warn("Client timed out, marking as disconnected: clientId={}, userId={}",
|
||||
clientId, state.userId());
|
||||
log.warn("Client timed out, marking as disconnected: clientId={}, userId={}", clientId,
|
||||
state.userId());
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -246,22 +243,17 @@ public class ClientConnectionService {
|
||||
/**
|
||||
* Sends a ping message to a specific user.
|
||||
*
|
||||
* @param userId The target user ID (MongoDB ObjectId)
|
||||
* @param userId
|
||||
* The target user ID (MongoDB ObjectId)
|
||||
*/
|
||||
private void sendPing(String userId) {
|
||||
try {
|
||||
Map<String, Object> pingPayload = Map.of(
|
||||
"type", "ping",
|
||||
"timestamp", Instant.now().toEpochMilli()
|
||||
);
|
||||
Map<String, Object> pingPayload = Map.of("type", "ping", "timestamp", Instant.now().toEpochMilli());
|
||||
|
||||
String json = objectMapper.writeValueAsString(pingPayload);
|
||||
byte[] payload = json.getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
SendOptions options = SendOptions.builder()
|
||||
.qos(1)
|
||||
.retained(false)
|
||||
.build();
|
||||
SendOptions options = SendOptions.builder().qos(1).retained(false).build();
|
||||
|
||||
pluginManager.sendToClient(userId, "ping", payload, options);
|
||||
|
||||
@@ -276,9 +268,7 @@ public class ClientConnectionService {
|
||||
* @return Number of connected clients
|
||||
*/
|
||||
public int getConnectedClientCount() {
|
||||
return (int) connectedClients.values().stream()
|
||||
.filter(ClientState::connected)
|
||||
.count();
|
||||
return (int) connectedClients.values().stream().filter(ClientState::connected).count();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -85,15 +85,12 @@ public class CustomerInvoiceService {
|
||||
List<CustomerInvoiceItem> items = new ArrayList<>();
|
||||
BigDecimal vatRate = new BigDecimal("0.19"); // 19% MwSt.
|
||||
|
||||
CustomerInvoiceItem item1 = new CustomerInvoiceItem(
|
||||
new BigDecimal("2"), "Std.", "Transportdienstleistung",
|
||||
new BigDecimal("85.00"), vatRate);
|
||||
CustomerInvoiceItem item2 = new CustomerInvoiceItem(
|
||||
new BigDecimal("1"), "Stk.", "Logistikkoordination",
|
||||
new BigDecimal("120.00"), vatRate);
|
||||
CustomerInvoiceItem item3 = new CustomerInvoiceItem(
|
||||
new BigDecimal("50"), "km", "Kilometergebühr",
|
||||
new BigDecimal("0.60"), vatRate);
|
||||
CustomerInvoiceItem item1 = new CustomerInvoiceItem(new BigDecimal("2"), "Std.", "Transportdienstleistung",
|
||||
new BigDecimal("85.00"), vatRate);
|
||||
CustomerInvoiceItem item2 = new CustomerInvoiceItem(new BigDecimal("1"), "Stk.", "Logistikkoordination",
|
||||
new BigDecimal("120.00"), vatRate);
|
||||
CustomerInvoiceItem item3 = new CustomerInvoiceItem(new BigDecimal("50"), "km", "Kilometergebühr",
|
||||
new BigDecimal("0.60"), vatRate);
|
||||
|
||||
items.add(item1);
|
||||
items.add(item2);
|
||||
@@ -101,9 +98,8 @@ public class CustomerInvoiceService {
|
||||
invoiceData.setItems(items);
|
||||
|
||||
// Beträge berechnen
|
||||
BigDecimal netAmount = items.stream()
|
||||
.map(CustomerInvoiceItem::getNetTotal)
|
||||
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
BigDecimal netAmount = items.stream().map(CustomerInvoiceItem::getNetTotal).reduce(BigDecimal.ZERO,
|
||||
BigDecimal::add);
|
||||
BigDecimal vatAmount = netAmount.multiply(vatRate);
|
||||
BigDecimal totalAmount = netAmount.add(vatAmount);
|
||||
|
||||
@@ -190,17 +186,13 @@ public class CustomerInvoiceService {
|
||||
StringBuilder itemRows = new StringBuilder();
|
||||
for (CustomerInvoiceItem item : data.getItems()) {
|
||||
itemRows.append("<tr>");
|
||||
itemRows.append("<td style='text-align: center;'>")
|
||||
.append(formatDecimal(item.getQuantity()))
|
||||
.append(" ").append(nvl(item.getUnit()))
|
||||
.append("</td>");
|
||||
itemRows.append("<td style='text-align: center;'>").append(formatDecimal(item.getQuantity()))
|
||||
.append(" ").append(nvl(item.getUnit())).append("</td>");
|
||||
itemRows.append("<td>").append(nvl(item.getDescription())).append("</td>");
|
||||
itemRows.append("<td style='text-align: right;'>")
|
||||
.append(formatCurrency(item.getUnitPrice()))
|
||||
.append("</td>");
|
||||
itemRows.append("<td style='text-align: right;'>")
|
||||
.append(formatCurrency(item.getNetTotal()))
|
||||
.append("</td>");
|
||||
itemRows.append("<td style='text-align: right;'>").append(formatCurrency(item.getUnitPrice()))
|
||||
.append("</td>");
|
||||
itemRows.append("<td style='text-align: right;'>").append(formatCurrency(item.getNetTotal()))
|
||||
.append("</td>");
|
||||
itemRows.append("</tr>");
|
||||
}
|
||||
filledHtml = filledHtml.replace("<!-- ITEM_ROWS -->", itemRows.toString());
|
||||
@@ -231,12 +223,14 @@ public class CustomerInvoiceService {
|
||||
}
|
||||
|
||||
private String formatCurrency(BigDecimal amount) {
|
||||
if (amount == null) return "0,00 €";
|
||||
if (amount == null)
|
||||
return "0,00 €";
|
||||
return NumberFormat.getCurrencyInstance(Locale.GERMANY).format(amount);
|
||||
}
|
||||
|
||||
private String formatDecimal(BigDecimal value) {
|
||||
if (value == null) return "0";
|
||||
if (value == null)
|
||||
return "0";
|
||||
return NumberFormat.getNumberInstance(Locale.GERMANY).format(value);
|
||||
}
|
||||
|
||||
|
||||
@@ -285,8 +285,8 @@ public class EmailService {
|
||||
body.append("ein neuer Job wurde erfolgreich erstellt:\n\n");
|
||||
body.append("Job: ").append(job.getJobNumber() != null ? job.getJobNumber() : "Unbekannt").append("\n");
|
||||
|
||||
if (job.getDeliveryCompany() != null) {
|
||||
body.append("Kunde: ").append(job.getDeliveryCompany()).append("\n");
|
||||
if (job.getCustomerSelection() != null && !job.getCustomerSelection().isBlank()) {
|
||||
body.append("Auftraggeber: ").append(job.getCustomerSelection()).append("\n");
|
||||
}
|
||||
|
||||
if (job.getPickupCity() != null || job.getDeliveryCity() != null) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -19,8 +19,8 @@ import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Service for job statistics and aggregations.
|
||||
* Provides data for MCP tools and reporting.
|
||||
* Service for job statistics and aggregations. Provides data for MCP tools and
|
||||
* reporting.
|
||||
*/
|
||||
@Service
|
||||
@Slf4j
|
||||
@@ -83,10 +83,8 @@ public class JobStatisticsService {
|
||||
*/
|
||||
public BigDecimal getTotalRevenue() {
|
||||
List<Job> allJobs = jobRepository.findAll();
|
||||
return allJobs.stream()
|
||||
.map(Job::getPrice)
|
||||
.filter(price -> price != null)
|
||||
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
return allJobs.stream().map(Job::getPrice).filter(price -> price != null).reduce(BigDecimal.ZERO,
|
||||
BigDecimal::add);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -110,10 +108,8 @@ public class JobStatisticsService {
|
||||
* Get top customers by revenue.
|
||||
*/
|
||||
public List<Map.Entry<String, BigDecimal>> getTopCustomersByRevenue(int limit) {
|
||||
return getRevenueByCustomer().entrySet().stream()
|
||||
.sorted((a, b) -> b.getValue().compareTo(a.getValue()))
|
||||
.limit(limit)
|
||||
.toList();
|
||||
return getRevenueByCustomer().entrySet().stream().sorted((a, b) -> b.getValue().compareTo(a.getValue()))
|
||||
.limit(limit).toList();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -162,7 +158,8 @@ public class JobStatisticsService {
|
||||
if (jobs.isEmpty()) {
|
||||
String containsRegex = ".*" + escapedCustomer + ".*";
|
||||
jobs = jobRepository.findByCustomerSelectionIgnoreCase(containsRegex);
|
||||
log.debug("getJobsByCustomer('{}') - contains regex: '{}' - found {} jobs", customer, containsRegex, jobs.size());
|
||||
log.debug("getJobsByCustomer('{}') - contains regex: '{}' - found {} jobs", customer, containsRegex,
|
||||
jobs.size());
|
||||
}
|
||||
|
||||
return jobs;
|
||||
@@ -227,12 +224,8 @@ public class JobStatisticsService {
|
||||
* Get all available customer names for autocomplete/filtering.
|
||||
*/
|
||||
public List<String> getAllCustomerNames() {
|
||||
return jobRepository.findAll().stream()
|
||||
.map(Job::getCustomerSelection)
|
||||
.filter(c -> c != null && !c.isBlank())
|
||||
.distinct()
|
||||
.sorted()
|
||||
.toList();
|
||||
return jobRepository.findAll().stream().map(Job::getCustomerSelection).filter(c -> c != null && !c.isBlank())
|
||||
.distinct().sorted().toList();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -261,9 +254,7 @@ public class JobStatisticsService {
|
||||
* Get total revenue for a customer.
|
||||
*/
|
||||
public BigDecimal getTotalRevenueForCustomer(String customer) {
|
||||
return getJobsByCustomer(customer).stream()
|
||||
.map(Job::getPrice)
|
||||
.filter(price -> price != null)
|
||||
return getJobsByCustomer(customer).stream().map(Job::getPrice).filter(price -> price != null)
|
||||
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
}
|
||||
|
||||
@@ -275,9 +266,7 @@ public class JobStatisticsService {
|
||||
if (customerJobs.isEmpty()) {
|
||||
return 0.0;
|
||||
}
|
||||
long completed = customerJobs.stream()
|
||||
.filter(j -> j.getStatus() == JobStatus.COMPLETED)
|
||||
.count();
|
||||
long completed = customerJobs.stream().filter(j -> j.getStatus() == JobStatus.COMPLETED).count();
|
||||
return (double) completed / customerJobs.size() * 100.0;
|
||||
}
|
||||
|
||||
@@ -289,11 +278,8 @@ public class JobStatisticsService {
|
||||
LocalDateTime yearStart = LocalDateTime.of(year, 1, 1, 0, 0);
|
||||
LocalDateTime yearEnd = LocalDateTime.of(year, 12, 31, 23, 59, 59);
|
||||
|
||||
List<Job> customerJobs = getJobsByCustomer(customer).stream()
|
||||
.filter(j -> j.getCreatedAt() != null
|
||||
&& !j.getCreatedAt().isBefore(yearStart)
|
||||
&& !j.getCreatedAt().isAfter(yearEnd))
|
||||
.toList();
|
||||
List<Job> customerJobs = getJobsByCustomer(customer).stream().filter(j -> j.getCreatedAt() != null
|
||||
&& !j.getCreatedAt().isBefore(yearStart) && !j.getCreatedAt().isAfter(yearEnd)).toList();
|
||||
|
||||
// Initialize all months with 0
|
||||
for (Month month : Month.values()) {
|
||||
@@ -317,9 +303,7 @@ public class JobStatisticsService {
|
||||
if (status == null) {
|
||||
return customerJobs;
|
||||
}
|
||||
return customerJobs.stream()
|
||||
.filter(j -> j.getStatus() == status)
|
||||
.toList();
|
||||
return customerJobs.stream().filter(j -> j.getStatus() == status).toList();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -351,7 +335,8 @@ public class JobStatisticsService {
|
||||
}
|
||||
}
|
||||
|
||||
// Third: extract potential customer name from query and check if it matches a customer
|
||||
// Third: extract potential customer name from query and check if it matches a
|
||||
// customer
|
||||
// This handles cases like "cAPPacity GmbH" matching "cAPPacity GmbH & Co. KG"
|
||||
String extractedName = extractCustomerNameFromQuery(query);
|
||||
if (extractedName != null) {
|
||||
@@ -360,13 +345,15 @@ public class JobStatisticsService {
|
||||
String lowerCustomer = customer.toLowerCase();
|
||||
// Check if customer name starts with the extracted name, or contains it
|
||||
if (lowerCustomer.startsWith(lowerExtracted) || lowerCustomer.contains(lowerExtracted)) {
|
||||
log.debug("findMatchingCustomer - Extracted name '{}' matches customer: '{}'", extractedName, customer);
|
||||
log.debug("findMatchingCustomer - Extracted name '{}' matches customer: '{}'", extractedName,
|
||||
customer);
|
||||
return customer;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fourth: word match (any significant word from the query matches customer name)
|
||||
// Fourth: word match (any significant word from the query matches customer
|
||||
// name)
|
||||
String[] queryWords = lowerQuery.split("\\s+");
|
||||
for (String customer : allCustomers) {
|
||||
String lowerCustomer = customer.toLowerCase();
|
||||
@@ -384,18 +371,15 @@ public class JobStatisticsService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract potential customer name from a query string.
|
||||
* Looks for patterns like "firma X", "kunde X", "für X", etc.
|
||||
* Extract potential customer name from a query string. Looks for patterns like
|
||||
* "firma X", "kunde X", "für X", etc.
|
||||
*/
|
||||
private String extractCustomerNameFromQuery(String query) {
|
||||
String lowerQuery = query.toLowerCase();
|
||||
|
||||
// Patterns that typically precede a customer name
|
||||
String[] patterns = {
|
||||
"firma ", "kunde ", "kunden ", "unternehmen ",
|
||||
"für die firma ", "für den kunden ", "von der firma ", "vom kunden ",
|
||||
"der firma ", "des kunden ", "bei "
|
||||
};
|
||||
String[] patterns = { "firma ", "kunde ", "kunden ", "unternehmen ", "für die firma ", "für den kunden ",
|
||||
"von der firma ", "vom kunden ", "der firma ", "des kunden ", "bei " };
|
||||
|
||||
for (String pattern : patterns) {
|
||||
int idx = lowerQuery.indexOf(pattern);
|
||||
@@ -421,10 +405,8 @@ public class JobStatisticsService {
|
||||
* Remove common trailing words from extracted customer name.
|
||||
*/
|
||||
private String removeTrailingCommonWords(String text) {
|
||||
String[] trailingPatterns = {
|
||||
" an$", " anzeigen$", " zeigen$", " auflisten$", " liste$",
|
||||
" status$", " mit status$", " die$", " der$", " das$"
|
||||
};
|
||||
String[] trailingPatterns = { " an$", " anzeigen$", " zeigen$", " auflisten$", " liste$", " status$",
|
||||
" mit status$", " die$", " der$", " das$" };
|
||||
|
||||
String result = text;
|
||||
for (String pattern : trailingPatterns) {
|
||||
@@ -435,12 +417,11 @@ public class JobStatisticsService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a word is a common German/English word that should be ignored in matching.
|
||||
* Check if a word is a common German/English word that should be ignored in
|
||||
* matching.
|
||||
*/
|
||||
private boolean isCommonWord(String word) {
|
||||
return Set.of(
|
||||
"zeige", "alle", "jobs", "der", "die", "das", "für", "von", "mit", "und",
|
||||
"firma", "kunde", "status", "welche", "sind", "gmbh", "show", "all", "the"
|
||||
).contains(word.toLowerCase());
|
||||
return Set.of("zeige", "alle", "jobs", "der", "die", "das", "für", "von", "mit", "und", "firma", "kunde",
|
||||
"status", "welche", "sind", "gmbh", "show", "all", "the").contains(word.toLowerCase());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,8 +12,8 @@ import java.util.concurrent.Executor;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
/**
|
||||
* Service that listens for message-related events and notifies registered UI components
|
||||
* to update their message badges (e.g., in the sidebar navigation)
|
||||
* Service that listens for message-related events and notifies registered UI
|
||||
* components to update their message badges (e.g., in the sidebar navigation)
|
||||
*/
|
||||
@Service
|
||||
@Slf4j
|
||||
@@ -25,7 +25,8 @@ public class MessageBadgeUpdateService {
|
||||
/**
|
||||
* Register a listener that will be called when message badge should be updated
|
||||
*
|
||||
* @param listener Runnable that will be called when badge update is needed
|
||||
* @param listener
|
||||
* Runnable that will be called when badge update is needed
|
||||
* @return Registration object that can be used to unregister the listener
|
||||
*/
|
||||
public synchronized Registration register(Runnable listener) {
|
||||
@@ -74,4 +75,3 @@ public class MessageBadgeUpdateService {
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,8 +13,8 @@ import java.util.concurrent.Executors;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* Broadcaster service that manages listeners for incoming messages
|
||||
* and notifies UI components in a thread-safe manner
|
||||
* Broadcaster service that manages listeners for incoming messages and notifies
|
||||
* UI components in a thread-safe manner
|
||||
*/
|
||||
@Service
|
||||
@Slf4j
|
||||
@@ -26,7 +26,8 @@ public class MessageBroadcaster {
|
||||
/**
|
||||
* Register a listener for incoming messages
|
||||
*
|
||||
* @param listener Consumer that will be called when a new message arrives
|
||||
* @param listener
|
||||
* Consumer that will be called when a new message arrives
|
||||
* @return Registration object that can be used to unregister the listener
|
||||
*/
|
||||
public synchronized Registration register(Consumer<Message> listener) {
|
||||
@@ -42,8 +43,8 @@ public class MessageBroadcaster {
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast a message to all registered listeners
|
||||
* This is called asynchronously to avoid blocking the message reception
|
||||
* Broadcast a message to all registered listeners This is called asynchronously
|
||||
* to avoid blocking the message reception
|
||||
*/
|
||||
private synchronized void broadcast(Message message) {
|
||||
log.debug("Broadcasting message to {} listeners", listeners.size());
|
||||
@@ -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
|
||||
public void onMessageReceived(MessageReceivedEvent event) {
|
||||
Message message = event.getMessage();
|
||||
log.info("MessageBroadcaster received event for message with origin {} for receiver {}",
|
||||
message.getOrigin(), message.getReceiver());
|
||||
log.info("MessageBroadcaster received event for message with origin {} for receiver {}", message.getOrigin(),
|
||||
message.getReceiver());
|
||||
broadcast(message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,8 +29,8 @@ public class MessageService {
|
||||
private final MqttPublisher mqttPublisher;
|
||||
private final ApplicationEventPublisher eventPublisher;
|
||||
|
||||
public MessageService(MessageRepository messageRepository, JobRepository jobRepository,
|
||||
MqttPublisher mqttPublisher, ApplicationEventPublisher eventPublisher) {
|
||||
public MessageService(MessageRepository messageRepository, JobRepository jobRepository, MqttPublisher mqttPublisher,
|
||||
ApplicationEventPublisher eventPublisher) {
|
||||
this.messageRepository = messageRepository;
|
||||
this.jobRepository = jobRepository;
|
||||
this.mqttPublisher = mqttPublisher;
|
||||
@@ -47,15 +47,17 @@ public class MessageService {
|
||||
|
||||
/**
|
||||
* Send a general message to a client via MQTT
|
||||
* @param content Message content
|
||||
* @param receiver AppUser ID (clientId)
|
||||
*
|
||||
* @param content
|
||||
* Message content
|
||||
* @param receiver
|
||||
* AppUser ID (clientId)
|
||||
*/
|
||||
public Message sendGeneralMessageToClient(String content, String receiver) {
|
||||
return sendGeneralMessageToClient(content, receiver, MessageContentType.TEXT);
|
||||
}
|
||||
|
||||
public Message sendGeneralMessageToClient(String content, String receiver,
|
||||
MessageContentType contentType) {
|
||||
public Message sendGeneralMessageToClient(String content, String receiver, MessageContentType contentType) {
|
||||
Message message = new Message(content, receiver, MessageOrigin.SERVER, contentType);
|
||||
message = saveMessage(message);
|
||||
publishMessageToMqtt(message, receiver);
|
||||
@@ -64,21 +66,25 @@ public class MessageService {
|
||||
|
||||
/**
|
||||
* Send a job-related message to a client via MQTT
|
||||
* @param content Message content
|
||||
* @param receiver AppUser ID (clientId)
|
||||
* @param jobId Job ObjectId
|
||||
* @param jobNumber Job number
|
||||
*
|
||||
* @param content
|
||||
* Message content
|
||||
* @param receiver
|
||||
* AppUser ID (clientId)
|
||||
* @param jobId
|
||||
* Job ObjectId
|
||||
* @param jobNumber
|
||||
* Job number
|
||||
*/
|
||||
public Message sendJobMessageToClient(String content, String receiver,
|
||||
ObjectId jobId, String jobNumber) {
|
||||
public Message sendJobMessageToClient(String content, String receiver, ObjectId jobId, String jobNumber) {
|
||||
return sendJobMessageToClient(content, receiver, MessageContentType.TEXT, jobId, jobNumber);
|
||||
}
|
||||
|
||||
public Message sendJobMessageToClient(String content, String receiver,
|
||||
MessageContentType contentType, ObjectId jobId, String jobNumber) {
|
||||
public Message sendJobMessageToClient(String content, String receiver, MessageContentType contentType,
|
||||
ObjectId jobId, String jobNumber) {
|
||||
JobContext context = resolveJobContext(jobId, jobNumber);
|
||||
Message message = new Message(content, receiver, MessageOrigin.SERVER, contentType,
|
||||
context.jobId(), context.jobNumber());
|
||||
Message message = new Message(content, receiver, MessageOrigin.SERVER, contentType, context.jobId(),
|
||||
context.jobNumber());
|
||||
message = saveMessage(message);
|
||||
publishMessageToMqtt(message, receiver);
|
||||
return message;
|
||||
@@ -86,7 +92,9 @@ public class MessageService {
|
||||
|
||||
/**
|
||||
* Handle incoming message from a client
|
||||
* @param payload Inbound message payload where receiver = AppUser ID (clientId)
|
||||
*
|
||||
* @param payload
|
||||
* Inbound message payload where receiver = AppUser ID (clientId)
|
||||
*/
|
||||
public Message receiveMessageFromClient(ChatMessageInboundPayload payload) {
|
||||
Message message;
|
||||
@@ -94,18 +102,17 @@ public class MessageService {
|
||||
if (payload.hasJobContext()) {
|
||||
JobContext context = resolveJobContext(payload.jobId(), payload.jobNumber());
|
||||
// receiver = AppUser ID (clientId)
|
||||
message = new Message(payload.content(), payload.receiver(),
|
||||
MessageOrigin.CLIENT, contentType, context.jobId(), context.jobNumber());
|
||||
message = new Message(payload.content(), payload.receiver(), MessageOrigin.CLIENT, contentType,
|
||||
context.jobId(), context.jobNumber());
|
||||
} else {
|
||||
// receiver = AppUser ID (clientId)
|
||||
message = new Message(payload.content(), payload.receiver(),
|
||||
MessageOrigin.CLIENT, contentType);
|
||||
message = new Message(payload.content(), payload.receiver(), MessageOrigin.CLIENT, contentType);
|
||||
}
|
||||
message = saveMessage(message);
|
||||
|
||||
// Publish event to notify UI components about the new message
|
||||
log.info("Publishing MessageReceivedEvent for message with origin {} for receiver {}",
|
||||
message.getOrigin(), message.getReceiver());
|
||||
log.info("Publishing MessageReceivedEvent for message with origin {} for receiver {}", message.getOrigin(),
|
||||
message.getReceiver());
|
||||
eventPublisher.publishEvent(new MessageReceivedEvent(this, message));
|
||||
|
||||
return message;
|
||||
@@ -154,8 +161,11 @@ public class MessageService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all messages for a specific AppUser (by receiver field), ordered by creation time ascending (oldest first)
|
||||
* @param appUserId AppUser ID (clientId)
|
||||
* Get all messages for a specific AppUser (by receiver field), ordered by
|
||||
* creation time ascending (oldest first)
|
||||
*
|
||||
* @param appUserId
|
||||
* AppUser ID (clientId)
|
||||
*/
|
||||
public List<Message> getMessagesForAppUserAscending(String appUserId) {
|
||||
if (appUserId == null || appUserId.isBlank()) {
|
||||
@@ -165,8 +175,11 @@ public class MessageService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all messages for a specific AppUser (by receiver field), ordered by creation time descending
|
||||
* @param appUserId AppUser ID (clientId)
|
||||
* Get all messages for a specific AppUser (by receiver field), ordered by
|
||||
* creation time descending
|
||||
*
|
||||
* @param appUserId
|
||||
* AppUser ID (clientId)
|
||||
*/
|
||||
public List<Message> getMessagesForAppUserDescending(String appUserId) {
|
||||
if (appUserId == null || appUserId.isBlank()) {
|
||||
@@ -225,6 +238,13 @@ public class MessageService {
|
||||
return messageRepository.countByReceiverAndIsReadFalse(receiver);
|
||||
}
|
||||
|
||||
/**
|
||||
* Count all messages in the system
|
||||
*/
|
||||
public int countAllMessages() {
|
||||
return (int) messageRepository.count();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a message by ID
|
||||
*/
|
||||
@@ -326,8 +346,7 @@ public class MessageService {
|
||||
}
|
||||
|
||||
return fuzzyMatches.stream()
|
||||
.filter(job -> normalizeJobToken(job.getJobNumber()).equalsIgnoreCase(normalizedCandidate))
|
||||
.findFirst();
|
||||
.filter(job -> normalizeJobToken(job.getJobNumber()).equalsIgnoreCase(normalizedCandidate)).findFirst();
|
||||
}
|
||||
|
||||
private String normalizeJobToken(String value) {
|
||||
|
||||
@@ -9,7 +9,8 @@ import java.time.LocalDate;
|
||||
import java.time.YearMonth;
|
||||
|
||||
/**
|
||||
* Service für monatliche Scheduler-Aufgaben, die am letzten Tag des Monats (Ultimo) ausgeführt werden.
|
||||
* Service für monatliche Scheduler-Aufgaben, die am letzten Tag des Monats
|
||||
* (Ultimo) ausgeführt werden.
|
||||
*/
|
||||
@Service
|
||||
public class MonthlySchedulerService {
|
||||
@@ -17,8 +18,8 @@ public class MonthlySchedulerService {
|
||||
private static final Logger logger = LoggerFactory.getLogger(MonthlySchedulerService.class);
|
||||
|
||||
/**
|
||||
* Scheduler, der täglich um 23:00 Uhr läuft und prüft, ob heute der letzte Tag des Monats ist.
|
||||
* Wenn ja, wird die monatliche Aufgabe ausgeführt.
|
||||
* Scheduler, der täglich um 23:00 Uhr läuft und prüft, ob heute der letzte Tag
|
||||
* des Monats ist. Wenn ja, wird die monatliche Aufgabe ausgeführt.
|
||||
*/
|
||||
@Scheduled(cron = "0 0 23 * * *") // Täglich um 23:00 Uhr
|
||||
public void checkAndRunMonthlyTask() {
|
||||
@@ -29,17 +30,16 @@ public class MonthlySchedulerService {
|
||||
logger.info("Heute ist der letzte Tag des Monats ({}). Führe monatliche Aufgabe aus.", today);
|
||||
runMonthlyUltimoTask();
|
||||
} else {
|
||||
logger.debug("Heute ({}) ist nicht der letzte Tag des Monats. Nächster Ultimo: {}",
|
||||
today, lastDayOfMonth);
|
||||
logger.debug("Heute ({}) ist nicht der letzte Tag des Monats. Nächster Ultimo: {}", today, lastDayOfMonth);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Alternative Implementierung: Direkter Cron-Ausdruck für den letzten Tag des Monats.
|
||||
* Dieser Scheduler läuft am letzten Tag jedes Monats um 23:00 Uhr.
|
||||
* Alternative Implementierung: Direkter Cron-Ausdruck für den letzten Tag des
|
||||
* Monats. Dieser Scheduler läuft am letzten Tag jedes Monats um 23:00 Uhr.
|
||||
*
|
||||
* Hinweis: Dieser Ansatz ist weniger flexibel, da er nicht alle Monate korrekt abdeckt.
|
||||
* Der obige Ansatz mit täglicher Prüfung ist robuster.
|
||||
* Hinweis: Dieser Ansatz ist weniger flexibel, da er nicht alle Monate korrekt
|
||||
* abdeckt. Der obige Ansatz mit täglicher Prüfung ist robuster.
|
||||
*/
|
||||
// @Scheduled(cron = "0 0 23 L * *") // Am letzten Tag des Monats um 23:00 Uhr
|
||||
public void runMonthlyUltimoTaskDirect() {
|
||||
@@ -48,8 +48,8 @@ public class MonthlySchedulerService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Die eigentliche monatliche Aufgabe, die am Ultimo ausgeführt wird.
|
||||
* Hier können Sie Ihre spezifische Geschäftslogik implementieren.
|
||||
* Die eigentliche monatliche Aufgabe, die am Ultimo ausgeführt wird. Hier
|
||||
* können Sie Ihre spezifische Geschäftslogik implementieren.
|
||||
*/
|
||||
private void runMonthlyUltimoTask() {
|
||||
try {
|
||||
|
||||
@@ -40,7 +40,8 @@ public class SystemInvoiceService {
|
||||
// Set sample data
|
||||
data.setInvoiceNumber("HHA-2021-007");
|
||||
data.setInvoiceDate("19.07.2021");
|
||||
data.setInvoiceText("Gemäß unserem Nutzungsvertrag zu der Bestellnummer 45519389 berechnen wir Ihnen für den Monat Juli 2021 wie folgt:");
|
||||
data.setInvoiceText(
|
||||
"Gemäß unserem Nutzungsvertrag zu der Bestellnummer 45519389 berechnen wir Ihnen für den Monat Juli 2021 wie folgt:");
|
||||
|
||||
data.setRecipientName("Hamburger Hochbahn AG");
|
||||
data.setRecipientDepartment("Kreditorenbuchhaltung");
|
||||
@@ -71,19 +72,25 @@ public class SystemInvoiceService {
|
||||
// Replace invoice data placeholders
|
||||
filledHtml = filledHtml.replace("HHA-2021-007", data.getInvoiceNumber() != null ? data.getInvoiceNumber() : "");
|
||||
filledHtml = filledHtml.replace("19.07.2021", data.getInvoiceDate() != null ? data.getInvoiceDate() : "");
|
||||
filledHtml = filledHtml.replace("Gemäß unserem Nutzungsvertrag zu der Bestellnummer 45519389 berechnen wir Ihnen für den Monat Juli 2021 wie folgt:",
|
||||
data.getInvoiceText() != null ? data.getInvoiceText() : "");
|
||||
filledHtml = filledHtml.replace(
|
||||
"Gemäß unserem Nutzungsvertrag zu der Bestellnummer 45519389 berechnen wir Ihnen für den Monat Juli 2021 wie folgt:",
|
||||
data.getInvoiceText() != null ? data.getInvoiceText() : "");
|
||||
|
||||
// Replace recipient address
|
||||
filledHtml = filledHtml.replace("Hamburger Hochbahn AG", data.getRecipientName() != null ? data.getRecipientName() : "");
|
||||
filledHtml = filledHtml.replace("Kreditorenbuchhaltung", data.getRecipientDepartment() != null ? data.getRecipientDepartment() : "");
|
||||
filledHtml = filledHtml.replace("Steinstraße 20", data.getRecipientStreet() != null ? data.getRecipientStreet() : "");
|
||||
filledHtml = filledHtml.replace("20095 Hamburg", data.getRecipientCity() != null ? data.getRecipientCity() : "");
|
||||
filledHtml = filledHtml.replace("Hamburger Hochbahn AG",
|
||||
data.getRecipientName() != null ? data.getRecipientName() : "");
|
||||
filledHtml = filledHtml.replace("Kreditorenbuchhaltung",
|
||||
data.getRecipientDepartment() != null ? data.getRecipientDepartment() : "");
|
||||
filledHtml = filledHtml.replace("Steinstraße 20",
|
||||
data.getRecipientStreet() != null ? data.getRecipientStreet() : "");
|
||||
filledHtml = filledHtml.replace("20095 Hamburg",
|
||||
data.getRecipientCity() != null ? data.getRecipientCity() : "");
|
||||
|
||||
// Replace invoice items
|
||||
if (data.getInvoiceItems() != null && !data.getInvoiceItems().isEmpty()) {
|
||||
SystemInvoiceItem item = data.getInvoiceItems().getFirst();
|
||||
filledHtml = filledHtml.replace("Mtl. Lizenzgebühr »ILLT«", item.getDescription() != null ? item.getDescription() : "");
|
||||
filledHtml = filledHtml.replace("Mtl. Lizenzgebühr »ILLT«",
|
||||
item.getDescription() != null ? item.getDescription() : "");
|
||||
}
|
||||
|
||||
// Replace amounts
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user